@dispatch9/client-sdk
Version:
Official Node.js SDK for Dispatch9 API - Complete solution with email/phone client creation, order management, client management, and dual-method authentication
1,033 lines (942 loc) • 44.8 kB
JavaScript
const axios = require('axios');
/**
* Dispatch9 API Client
* Official Node.js SDK for Dispatch9 API
*
* Core Operations:
* - Create Orders
* - Update Orders
* - Create Clients
* - Update Clients
*/
class Dispatch9Client {
/**
* Create a new Dispatch9 client instance
* @param {Object} config - Configuration object
* @param {string} config.apiKey - Your API key (required)
* @param {string} [config.baseURL='https://api.dispatch9.com'] - Base API URL
* @param {number} [config.timeout=30000] - Request timeout in milliseconds
* @param {Object} [config.headers] - Additional headers to send with requests
* @param {boolean} [config.debug=false] - Enable debug logging
*/
constructor(config) {
if (!config || !config.apiKey) {
throw new Error('API key is required. Get one from your Dispatch9 dashboard.');
}
this.config = {
baseURL: 'https://api.dispatch9.com',
timeout: 30000,
debug: false,
...config
};
// Create axios instance
this.http = axios.create({
baseURL: this.config.baseURL,
timeout: this.config.timeout,
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.config.apiKey,
'User-Agent': 'Dispatch9-NodeSDK/1.0.0',
...this.config.headers
}
});
// Add request interceptor for logging
this.http.interceptors.request.use(
(config) => {
if (this.config.debug) {
console.log(`[D9 SDK] ${config.method.toUpperCase()} ${config.url}`);
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor for error handling
this.http.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
// Server responded with error status
const { status, data } = error.response;
const message = data?.message || `HTTP ${status} Error`;
switch (status) {
case 401:
throw new Error(`Authentication failed: ${message}. Check your API key.`);
case 403:
throw new Error(`Access forbidden: ${message}. Check your permissions.`);
case 404:
throw new Error(`Resource not found: ${message}`);
case 429:
throw new Error(`Rate limit exceeded: ${message}. Please slow down your requests.`);
default:
throw new Error(`API Error (${status}): ${message}`);
}
} else if (error.request) {
// Request was made but no response received
throw new Error('No response from server. Check your internet connection.');
} else {
// Something else happened
throw new Error(`Request failed: ${error.message}`);
}
}
);
}
/**
* Make a GET request
* @private
*/
async _get(endpoint, params = {}) {
const response = await this.http.get(endpoint, { params });
return response.data;
}
/**
* Make a POST request
* @private
*/
async _post(endpoint, data = {}) {
const response = await this.http.post(endpoint, data);
return response.data;
}
/**
* Make a PATCH request
* @private
*/
async _patch(endpoint, data = {}) {
const response = await this.http.patch(endpoint, data);
return response.data;
}
// ==========================================
// ORDER MANAGEMENT
// ==========================================
/**
* Create a new order
* @param {Object} orderData - Order data
*
* REQUIRED FIELDS:
* @param {number} orderData.orderTotal - Total order amount (required, min: 0)
* @param {string} orderData.client - Client ID (required, must be valid ObjectId)
*
* OPTIONAL FIELDS:
* @param {string} [orderData.orderNumber] - Custom order number
* @param {string} [orderData.orderCurrency='USD'] - Order currency
* @param {boolean} [orderData.isPaid=false] - Payment status
* @param {boolean} [orderData.hasGoods=false] - Order contains goods/items
* @param {boolean} [orderData.hasServices=false] - Order contains services
* @param {boolean} [orderData.hasWorkers=false] - Order requires worker transport
* @param {number} [orderData.priority=0] - Order priority (0-10)
* @param {boolean} [orderData.autoAssign=false] - Auto-assign to workers
* @param {string} [orderData.manualAssignWorker] - Worker ID for manual assignment (ObjectId)
* @param {string} [orderData.status='created'] - Order status (created, assigned, in_progress, completed, failed, partially_completed)
* @param {number} [orderData.completeAfter=0] - Earliest completion timestamp
* @param {number} [orderData.completeBefore=0] - Latest completion timestamp
* @param {string} [orderData.pickupLocation] - Pickup address ID (ObjectId - must be created first via address API)
* @param {string} [orderData.deliveryLocation] - Delivery address ID (ObjectId - must be created first via address API)
* @param {string} [orderData.serviceLocation] - Service address ID (ObjectId - must be created first via address API, required if hasServices=true)
* @param {string} [orderData.specialInstructions] - Special delivery instructions
* @param {string} [orderData.customerNotes] - Customer notes
* @param {Object} [orderData.metadata] - Additional metadata
* @param {boolean} [orderData.isRecurring=false] - Is this a recurring order
* @param {Object} [orderData.recurringSettings] - Recurring settings (required if isRecurring=true)
* @param {Object} [orderData.requiredProof] - Proof of delivery requirements
* @param {boolean} [orderData.requiredProof.signature=false] - Require signature
* @param {boolean} [orderData.requiredProof.photo=false] - Require photo
*
* CONDITIONAL REQUIRED FIELDS:
* @param {Array} [orderData.items] - Items array (required if hasGoods=true)
* @param {Array} [orderData.services] - Services array (required if hasServices=true)
* @param {Array} [orderData.workers] - Workers array (required if hasWorkers=true)
*
* ITEM SCHEMA (when hasGoods=true):
* @param {string} orderData.items[].SKU - Item SKU (required)
* @param {string} orderData.items[].itemName - Item name (required)
* @param {number} orderData.items[].price - Item price (required, min: 0)
* @param {number} orderData.items[].quantity - Quantity (required, min: 1)
* @param {string} [orderData.items[].category] - Item category
* @param {string} [orderData.items[].description] - Item description
* @param {string} [orderData.items[].currency='USD'] - Item currency
* @param {number} [orderData.items[].weight] - Item weight
* @param {string} [orderData.items[].weightUnit='kg'] - Weight unit (kg, lb, g, oz)
* @param {number} [orderData.items[].dimensionH] - Height
* @param {number} [orderData.items[].dimensionW] - Width
* @param {number} [orderData.items[].dimensionL] - Length
* @param {string} [orderData.items[].dimensionUnit='cm'] - Dimension unit (cm, in)
* @param {string} [orderData.items[].packaging] - Packaging requirements
* @param {string} [orderData.items[].handling] - Handling instructions
* @param {string} [orderData.items[].notes] - Item notes
*
* SERVICE SCHEMA (when hasServices=true):
* @param {string} orderData.services[].serviceCode - Service code (required)
* @param {string} orderData.services[].serviceName - Service name (required)
* @param {string} orderData.services[].category - Service category (required): cleaning, maintenance, repair, installation, inspection, other
* @param {string} [orderData.services[].description] - Service description
* @param {number} [orderData.services[].estimatedDuration=60] - Estimated duration in minutes
* @param {number} orderData.services[].workersRequired - Number of workers required (required, min: 1, max: 10, default: 1)
* @param {number} orderData.services[].price - Service price (required, min: 0)
* @param {string} [orderData.services[].currency='USD'] - Service currency
* @param {Array} [orderData.services[].requirements] - Special equipment, materials, or skills required
* @param {string} [orderData.services[].notes] - Service notes or special instructions
*
* @returns {Promise<Object>} Created order
*/
async createOrder(orderData) {
// Validate required fields
if (!orderData) throw new Error('Order data is required');
if (typeof orderData.orderTotal === 'undefined' || orderData.orderTotal < 0) {
throw new Error('orderTotal is required and must be >= 0');
}
if (!orderData.client) throw new Error('client ID is required');
// Validate conditional requirements
if (orderData.hasGoods && (!orderData.items || !Array.isArray(orderData.items) || orderData.items.length === 0)) {
throw new Error('items array is required when hasGoods=true');
}
if (orderData.hasServices && (!orderData.services || !Array.isArray(orderData.services) || orderData.services.length === 0)) {
throw new Error('services array is required when hasServices=true');
}
if (orderData.hasWorkers && (!orderData.workers || !Array.isArray(orderData.workers) || orderData.workers.length === 0)) {
throw new Error('workers array is required when hasWorkers=true');
}
if (orderData.hasServices && !orderData.serviceLocation) {
throw new Error('serviceLocation is required when hasServices=true');
}
if (orderData.isRecurring && !orderData.recurringSettings) {
throw new Error('recurringSettings is required when isRecurring=true');
}
// Validate items if provided
if (orderData.items && Array.isArray(orderData.items)) {
orderData.items.forEach((item, index) => {
if (!item.SKU) throw new Error(`items[${index}].SKU is required`);
if (!item.itemName) throw new Error(`items[${index}].itemName is required`);
if (typeof item.price === 'undefined' || item.price < 0) {
throw new Error(`items[${index}].price is required and must be >= 0`);
}
if (!item.quantity || item.quantity < 1) {
throw new Error(`items[${index}].quantity is required and must be >= 1`);
}
});
}
// Validate services if provided
if (orderData.services && Array.isArray(orderData.services)) {
orderData.services.forEach((service, index) => {
if (!service.serviceCode) throw new Error(`services[${index}].serviceCode is required`);
if (!service.serviceName) throw new Error(`services[${index}].serviceName is required`);
if (!service.category) throw new Error(`services[${index}].category is required`);
if (typeof service.category !== 'string') {
throw new Error(`services[${index}].category must be a string`);
}
if (typeof service.price === 'undefined' || service.price < 0) {
throw new Error(`services[${index}].price is required and must be >= 0`);
}
// Validate requirements array if provided
if (service.requirements !== undefined) {
if (typeof service.requirements === 'string') {
throw new Error(`services[${index}].requirements must be an array, not a string. If you're passing JSON, parse it first with JSON.parse()`);
}
if (!Array.isArray(service.requirements)) {
throw new Error(`services[${index}].requirements must be an array`);
}
service.requirements.forEach((req, reqIndex) => {
if (typeof req === 'object' && req !== null) {
if (!req.type) {
throw new Error(`services[${index}].requirements[${reqIndex}].type is required for object requirements`);
}
if (!['workers', 'tools', 'qualifications', 'equipment'].includes(req.type)) {
throw new Error(`services[${index}].requirements[${reqIndex}].type must be one of: workers, tools, qualifications, equipment`);
}
if (req.type === 'workers') {
const workerCount = req.count || req.workers || req.numberOfWorkers;
if (!workerCount || workerCount < 1 || workerCount > 10) {
throw new Error(`services[${index}].requirements[${reqIndex}] worker count must be between 1 and 10`);
}
}
}
});
}
});
}
return this._post('/v1/orders', orderData);
}
/**
* Update an existing order
* @param {string} orderId - Order ID (required, must be valid ObjectId)
* @param {Object} updateData - Data to update (at least one field required)
*
* ALL FIELDS ARE OPTIONAL (but at least one must be provided):
* @param {string} [updateData.orderNumber] - Custom order number
* @param {boolean} [updateData.hasGoods] - Order contains goods/items
* @param {boolean} [updateData.hasServices] - Order contains services
* @param {boolean} [updateData.hasWorkers] - Order requires worker transport
* @param {number} [updateData.priority] - Order priority (0-10)
* @param {boolean} [updateData.autoAssign] - Auto-assign to workers
* @param {string} [updateData.status] - Order status (created, confirmed, in_progress, completed, cancelled, partially_completed)
* @param {Array} [updateData.items] - Items array (follows same schema as createOrder)
* @param {Array} [updateData.services] - Services array
* @param {Array} [updateData.workers] - Workers array
* @param {string} [updateData.pickupLocation] - Pickup address ID (ObjectId - must be created first via address API)
* @param {string} [updateData.deliveryLocation] - Delivery address ID (ObjectId - must be created first via address API)
* @param {string} [updateData.serviceLocation] - Service address ID (ObjectId - must be created first via address API)
* @param {string} [updateData.specialInstructions] - Special delivery instructions
* @param {string} [updateData.customerNotes] - Customer notes
* @param {string} [updateData.statusNotes] - Status notes
* @param {Object} [updateData.metadata] - Additional metadata
* @param {boolean} [updateData.isRecurring] - Is this a recurring order
* @param {Object} [updateData.recurringSettings] - Recurring settings
*
* ITEM SCHEMA (when updating items):
* @param {string} updateData.items[].SKU - Item SKU (required)
* @param {string} updateData.items[].itemName - Item name (required)
* @param {number} updateData.items[].price - Item price (required, min: 0)
* @param {number} updateData.items[].quantity - Quantity (required, min: 1)
* @param {string} [updateData.items[].category] - Item category
* @param {string} [updateData.items[].description] - Item description
* @param {string} [updateData.items[].currency='USD'] - Item currency
* @param {number} [updateData.items[].weight] - Item weight
* @param {string} [updateData.items[].weightUnit='kg'] - Weight unit (kg, lb, g, oz)
* @param {number} [updateData.items[].dimensionH] - Height
* @param {number} [updateData.items[].dimensionW] - Width
* @param {number} [updateData.items[].dimensionL] - Length
* @param {string} [updateData.items[].dimensionUnit='cm'] - Dimension unit (cm, in)
* @param {string} [updateData.items[].packaging] - Packaging requirements
* @param {string} [updateData.items[].handling] - Handling instructions
* @param {string} [updateData.items[].notes] - Item notes
*
* SERVICE SCHEMA (when updating services):
* @param {string} updateData.services[].serviceCode - Service code (required)
* @param {string} updateData.services[].serviceName - Service name (required)
* @param {string} updateData.services[].category - Service category (required): cleaning, maintenance, repair, installation, inspection, other
* @param {string} [updateData.services[].description] - Service description
* @param {number} [updateData.services[].estimatedDuration=60] - Estimated duration in minutes
* @param {number} updateData.services[].workersRequired - Number of workers required (required, min: 1, max: 10, default: 1)
* @param {number} updateData.services[].price - Service price (required, min: 0)
* @param {string} [updateData.services[].currency='USD'] - Service currency
* @param {Array} [updateData.services[].requirements] - Special equipment, materials, or skills required
* @param {string} [updateData.services[].notes] - Service notes or special instructions
*
* @returns {Promise<Object>} Updated order
*/
async updateOrder(orderId, updateData) {
// Validate required parameters
if (!orderId) throw new Error('orderId is required');
if (!updateData || Object.keys(updateData).length === 0) {
throw new Error('updateData is required and must contain at least one field to update');
}
// Validate priority if provided
if (typeof updateData.priority !== 'undefined' && (updateData.priority < 0 || updateData.priority > 10)) {
throw new Error('priority must be between 0 and 10');
}
// Validate status if provided
if (updateData.status && !['created', 'confirmed', 'in_progress', 'completed', 'cancelled', 'partially_completed'].includes(updateData.status)) {
throw new Error('status must be one of: created, confirmed, in_progress, completed, cancelled, partially_completed');
}
// Validate items if provided
if (updateData.items && Array.isArray(updateData.items)) {
updateData.items.forEach((item, index) => {
if (!item.SKU) throw new Error(`items[${index}].SKU is required`);
if (!item.itemName) throw new Error(`items[${index}].itemName is required`);
if (typeof item.price === 'undefined' || item.price < 0) {
throw new Error(`items[${index}].price is required and must be >= 0`);
}
if (!item.quantity || item.quantity < 1) {
throw new Error(`items[${index}].quantity is required and must be >= 1`);
}
});
}
// Validate services if provided
if (updateData.services && Array.isArray(updateData.services)) {
updateData.services.forEach((service, index) => {
if (!service.serviceCode) throw new Error(`services[${index}].serviceCode is required`);
if (!service.serviceName) throw new Error(`services[${index}].serviceName is required`);
if (!service.category) throw new Error(`services[${index}].category is required`);
if (typeof service.category !== 'string') {
throw new Error(`services[${index}].category must be a string`);
}
if (typeof service.price === 'undefined' || service.price < 0) {
throw new Error(`services[${index}].price is required and must be >= 0`);
}
// Validate requirements array if provided
if (service.requirements !== undefined) {
if (typeof service.requirements === 'string') {
throw new Error(`services[${index}].requirements must be an array, not a string. If you're passing JSON, parse it first with JSON.parse()`);
}
if (!Array.isArray(service.requirements)) {
throw new Error(`services[${index}].requirements must be an array`);
}
service.requirements.forEach((req, reqIndex) => {
if (typeof req === 'object' && req !== null) {
if (!req.type) {
throw new Error(`services[${index}].requirements[${reqIndex}].type is required for object requirements`);
}
if (!['workers', 'tools', 'qualifications', 'equipment'].includes(req.type)) {
throw new Error(`services[${index}].requirements[${reqIndex}].type must be one of: workers, tools, qualifications, equipment`);
}
if (req.type === 'workers') {
const workerCount = req.count || req.workers || req.numberOfWorkers;
if (!workerCount || workerCount < 1 || workerCount > 10) {
throw new Error(`services[${index}].requirements[${reqIndex}] worker count must be between 1 and 10`);
}
}
}
});
}
});
}
return this._patch(`/v1/orders/${orderId}`, updateData);
}
/**
* Get a specific order by ID
* @param {string} orderId - Order ID (required, must be valid ObjectId)
* @param {Object} [options={}] - Query options (all optional)
* @param {string} [options.populate] - Comma-separated list of fields to populate
* Available fields: client, pickupLocation, deliveryLocation, serviceLocation, workers.worker
* @param {boolean} [options.includeJobs] - Include associated jobs in response
*
* @returns {Promise<Object>} Order details
* @throws {Error} If orderId is invalid or order not found
*
* @example
* // Get basic order details
* const order = await dispatch9.getOrderById('507f1f77bcf86cd799439011');
*
* @example
* // Get order with populated client and location details
* const orderWithDetails = await dispatch9.getOrderById('507f1f77bcf86cd799439011', {
* populate: 'client,pickupLocation,deliveryLocation,serviceLocation'
* });
*
* @example
* // Get order with associated jobs
* const orderWithJobs = await dispatch9.getOrderById('507f1f77bcf86cd799439011', {
* includeJobs: true
* });
*/
async getOrderById(orderId, options = {}) {
// Validate required fields
if (!orderId) {
throw new Error('orderId is required');
}
if (typeof orderId !== 'string') {
throw new Error('orderId must be a string');
}
// Validate ObjectId format (24 character hex string)
if (!/^[0-9a-fA-F]{24}$/.test(orderId)) {
throw new Error('orderId must be a valid ObjectId (24 character hex string)');
}
// Build query parameters
const params = new URLSearchParams();
if (options.populate) {
if (typeof options.populate !== 'string') {
throw new Error('populate must be a string');
}
params.append('populate', options.populate);
}
if (options.includeJobs) {
if (typeof options.includeJobs !== 'boolean') {
throw new Error('includeJobs must be a boolean');
}
params.append('includeJobs', options.includeJobs.toString());
}
const queryString = params.toString();
const url = `/v1/orders/${orderId}${queryString ? `?${queryString}` : ''}`;
return this._get(url);
}
// ==========================================
// CLIENT MANAGEMENT
// ==========================================
/**
* Get clients with optional filtering and pagination
* @param {Object} [options={}] - Query options (all optional)
* @param {string} [options.name] - Filter by client name
* @param {string} [options.status] - Filter by status (active, inactive, suspended)
* @param {string} [options.businessType] - Filter by business type (restaurant, retail, grocery, pharmacy, other)
* @param {string} [options.sortBy] - Sort field
* @param {number} [options.limit] - Items per page
* @param {number} [options.page] - Page number
* @returns {Promise<Object>} Clients response with pagination
*/
async getClients(options = {}) {
return this._get('/v1/clients', options);
}
/**
* Create a new client
* @param {Object} clientData - Client data
*
* REQUIRED FIELDS:
* @param {string} clientData.name - Client/business name (required, min: 1 character)
* @param {string} [clientData.email] - Client email (optional, must be valid email if provided)
* @param {string} [clientData.phone] - Client phone number (optional, must be valid phone if provided)
*
* NOTE: At least one of email OR phone is required for client creation
*
* OPTIONAL FIELDS:
* @param {string} [clientData.websiteURL] - Website URL (must be valid URI)
* @param {string} [clientData.logoURL] - Logo URL (must be valid URI)
* @param {string} [clientData.businessType] - Business type (restaurant, retail, grocery, pharmacy, other)
* @param {string} [clientData.taxId] - Tax identification number
* @param {string} [clientData.address] - Address ID (ObjectId)
* @param {string} [clientData.webhookURL] - Webhook URL for notifications (must be valid URI)
*
* OPTIONAL OBJECTS:
* @param {Object} [clientData.apiConfig] - API configuration
* @param {boolean} [clientData.apiConfig.enabled=true] - Enable API access
* @param {number} [clientData.apiConfig.rateLimit=1000] - API rate limit (min: 1)
*
* @param {Array} [clientData.integrations] - Third-party integrations
* @param {string} clientData.integrations[].platform - Platform name (required if integrations provided)
* @param {boolean} [clientData.integrations[].enabled=false] - Integration enabled
* @param {Object} [clientData.integrations[].config={}] - Integration configuration
* @param {string} [clientData.integrations[].webhookSecret] - Webhook secret
* @param {boolean} [clientData.integrations[].syncOrders=true] - Sync orders
*
* @param {Object} [clientData.orderSettings] - Order settings
* @param {boolean} [clientData.orderSettings.autoAccept=false] - Auto-accept orders
* @param {boolean} [clientData.orderSettings.autoAssign=false] - Auto-assign workers
* @param {number} [clientData.orderSettings.maxOrdersPerHour=50] - Max orders per hour (min: 1)
* @param {number} [clientData.orderSettings.preparationTime=15] - Preparation time in minutes (min: 1)
* @param {number} [clientData.orderSettings.deliveryRadius=10] - Delivery radius (min: 0)
*
* @param {Object} [clientData.permissions] - Client permissions
* @param {boolean} [clientData.permissions.modify=false] - Can modify settings
* @param {boolean} [clientData.permissions.delete=false] - Can delete data
* @param {boolean} [clientData.permissions.createOrders=true] - Can create orders
* @param {boolean} [clientData.permissions.viewOrders=true] - Can view orders
*
* @param {Object} [clientData.authentication] - Authentication settings
* @param {boolean} clientData.authentication.enablePortalAccess - Enable portal access (required if authentication provided)
* @param {string} [clientData.authentication.phone] - Phone number for authentication
* @param {string} [clientData.authentication.password] - Password for authentication
* @param {string} [clientData.authentication.firstName] - First name
* @param {string} [clientData.authentication.lastName] - Last name
* @param {string} [clientData.authentication.businessName] - Business name
*
* @returns {Promise<Object>} Created client
*
* @example
* // Create client with email
* const clientWithEmail = await dispatch9.createClient({
* name: 'Acme Corporation',
* email: 'contact@acmecorp.com',
* businessType: 'retail',
* websiteURL: 'https://www.acmecorp.com'
* });
*
* @example
* // Create client with phone number only
* const clientWithPhone = await dispatch9.createClient({
* name: 'Mobile Business',
* phone: '+1234567890',
* businessType: 'restaurant'
* });
*
* @example
* // Create client with both email and phone
* const clientWithBoth = await dispatch9.createClient({
* name: 'Full Contact Business',
* email: 'info@business.com',
* phone: '+1234567890',
* businessType: 'grocery',
* authentication: {
* enablePortalAccess: true,
* password: 'securePassword123',
* firstName: 'John',
* lastName: 'Doe'
* }
* });
*/
async createClient(clientData) {
// Validate required fields
if (!clientData) throw new Error('Client data is required');
if (!clientData.name || clientData.name.trim().length === 0) {
throw new Error('name is required and must be at least 1 character');
}
// Validate that at least one of email or phone is provided
if (!clientData.email && !clientData.phone) {
throw new Error('Either email or phone number is required');
}
// Validate email format if provided
if (clientData.email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(clientData.email)) {
throw new Error('email must be a valid email address');
}
}
// Validate phone format if provided
if (clientData.phone) {
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
const cleanPhone = clientData.phone.replace(/[\s\-\(\)]/g, '');
if (!phoneRegex.test(cleanPhone)) {
throw new Error('phone must be a valid phone number (e.g., +1234567890)');
}
}
// Validate business type if provided
if (clientData.businessType && !['restaurant', 'retail', 'grocery', 'pharmacy', 'other'].includes(clientData.businessType)) {
throw new Error('businessType must be one of: restaurant, retail, grocery, pharmacy, other');
}
// Validate URLs if provided
if (clientData.websiteURL && clientData.websiteURL.trim() && !this._isValidUrl(clientData.websiteURL)) {
throw new Error('websiteURL must be a valid URI');
}
if (clientData.logoURL && clientData.logoURL.trim() && !this._isValidUrl(clientData.logoURL)) {
throw new Error('logoURL must be a valid URI');
}
if (clientData.webhookURL && clientData.webhookURL.trim() && !this._isValidUrl(clientData.webhookURL)) {
throw new Error('webhookURL must be a valid URI');
}
// Validate integrations if provided
if (clientData.integrations && Array.isArray(clientData.integrations)) {
clientData.integrations.forEach((integration, index) => {
if (!integration.platform) {
throw new Error(`integrations[${index}].platform is required`);
}
});
}
// Validate authentication if provided
if (clientData.authentication && typeof clientData.authentication.enablePortalAccess === 'undefined') {
throw new Error('authentication.enablePortalAccess is required when authentication is provided');
}
return this._post('/v1/clients', clientData);
}
/**
* Update an existing client
* @param {string} clientId - Client ID (required, must be valid ObjectId)
* @param {Object} updateData - Data to update (at least one field required)
*
* ALL FIELDS ARE OPTIONAL (but at least one must be provided):
* @param {string} [updateData.name] - Client name (min: 1 character)
* @param {string} [updateData.email] - Client email (must be valid email)
* @param {string} [updateData.websiteURL] - Website URL (must be valid URI)
* @param {string} [updateData.logoURL] - Logo URL (must be valid URI)
* @param {string} [updateData.status] - Status (active, inactive, suspended)
* @param {string} [updateData.businessType] - Business type (restaurant, retail, grocery, pharmacy, other)
* @param {string} [updateData.taxId] - Tax ID
* @param {string} [updateData.address] - Address ID (ObjectId)
* @param {string} [updateData.contactName] - Contact name (min: 1 character)
* @param {string} [updateData.contactPhone] - Contact phone (min: 10 characters)
* @param {Object} [updateData.timeWindow] - Time window
* @param {Date} [updateData.timeWindow.start] - Start time
* @param {Date} [updateData.timeWindow.end] - End time
* @param {string} [updateData.webhookURL] - Webhook URL (must be valid URI)
*
* @param {Object} [updateData.apiConfig] - API configuration
* @param {boolean} [updateData.apiConfig.enabled] - Enable API access
* @param {number} [updateData.apiConfig.rateLimit] - API rate limit (min: 1)
*
* @param {Object} [updateData.permissions] - Client permissions
* @param {boolean} [updateData.permissions.modify] - Can modify settings
* @param {boolean} [updateData.permissions.delete] - Can delete data
* @param {boolean} [updateData.permissions.createOrders] - Can create orders
* @param {boolean} [updateData.permissions.viewOrders] - Can view orders
*
* @param {Object} [updateData.orderSettings] - Order settings
* @param {boolean} [updateData.orderSettings.autoAccept] - Auto-accept orders
* @param {boolean} [updateData.orderSettings.autoAssign] - Auto-assign workers
* @param {number} [updateData.orderSettings.maxOrdersPerHour] - Max orders per hour (min: 1)
* @param {number} [updateData.orderSettings.preparationTime] - Preparation time (min: 1)
* @param {number} [updateData.orderSettings.deliveryRadius] - Delivery radius (min: 0)
*
* @returns {Promise<Object>} Updated client
*/
async updateClient(clientId, updateData) {
// Validate required parameters
if (!clientId) throw new Error('clientId is required');
if (!updateData || Object.keys(updateData).length === 0) {
throw new Error('updateData is required and must contain at least one field to update');
}
// Validate email format if provided
if (updateData.email && !this._isValidEmail(updateData.email)) {
throw new Error('email must be a valid email address');
}
// Validate business type if provided
if (updateData.businessType && !['restaurant', 'retail', 'grocery', 'pharmacy', 'other'].includes(updateData.businessType)) {
throw new Error('businessType must be one of: restaurant, retail, grocery, pharmacy, other');
}
// Validate status if provided
if (updateData.status && !['active', 'inactive', 'suspended'].includes(updateData.status)) {
throw new Error('status must be one of: active, inactive, suspended');
}
// Validate URLs if provided
if (updateData.websiteURL && updateData.websiteURL.trim() && !this._isValidUrl(updateData.websiteURL)) {
throw new Error('websiteURL must be a valid URI');
}
if (updateData.logoURL && updateData.logoURL.trim() && !this._isValidUrl(updateData.logoURL)) {
throw new Error('logoURL must be a valid URI');
}
if (updateData.webhookURL && updateData.webhookURL.trim() && !this._isValidUrl(updateData.webhookURL)) {
throw new Error('webhookURL must be a valid URI');
}
// Validate string length requirements
if (updateData.name !== undefined && updateData.name.trim().length === 0) {
throw new Error('name must be at least 1 character');
}
if (updateData.contactName !== undefined && updateData.contactName.trim().length === 0) {
throw new Error('contactName must be at least 1 character');
}
if (updateData.contactPhone !== undefined && updateData.contactPhone.trim().length < 10) {
throw new Error('contactPhone must be at least 10 characters');
}
// Validate numeric constraints
if (updateData.apiConfig?.rateLimit !== undefined && updateData.apiConfig.rateLimit < 1) {
throw new Error('apiConfig.rateLimit must be at least 1');
}
if (updateData.orderSettings?.maxOrdersPerHour !== undefined && updateData.orderSettings.maxOrdersPerHour < 1) {
throw new Error('orderSettings.maxOrdersPerHour must be at least 1');
}
if (updateData.orderSettings?.preparationTime !== undefined && updateData.orderSettings.preparationTime < 1) {
throw new Error('orderSettings.preparationTime must be at least 1');
}
if (updateData.orderSettings?.deliveryRadius !== undefined && updateData.orderSettings.deliveryRadius < 0) {
throw new Error('orderSettings.deliveryRadius must be at least 0');
}
return this._patch(`/v1/clients/${clientId}`, updateData);
}
/**
* Get a specific client by ID
* @param {string} clientId - Client ID (required, must be valid ObjectId)
* @param {Object} [options={}] - Query options (all optional)
* @param {string} [options.populate] - Comma-separated list of fields to populate
* Available fields: addresses, primaryAddress, billingAddress
* @param {boolean} [options.includeStats] - Include client statistics (order count, total revenue, etc.)
*
* @returns {Promise<Object>} Client details
* @throws {Error} If clientId is invalid or client not found
*
* @example
* // Get basic client details
* const client = await dispatch9.getClientById('507f1f77bcf86cd799439011');
*
* @example
* // Get client with populated address details
* const clientWithAddresses = await dispatch9.getClientById('507f1f77bcf86cd799439011', {
* populate: 'addresses,primaryAddress,billingAddress'
* });
*
* @example
* // Get client with statistics
* const clientWithStats = await dispatch9.getClientById('507f1f77bcf86cd799439011', {
* includeStats: true
* });
*/
async getClientById(clientId, options = {}) {
// Validate required fields
if (!clientId) {
throw new Error('clientId is required');
}
if (typeof clientId !== 'string') {
throw new Error('clientId must be a string');
}
// Validate ObjectId format (24 character hex string)
if (!/^[0-9a-fA-F]{24}$/.test(clientId)) {
throw new Error('clientId must be a valid ObjectId (24 character hex string)');
}
// Build query parameters
const params = new URLSearchParams();
if (options.populate) {
if (typeof options.populate !== 'string') {
throw new Error('populate must be a string');
}
params.append('populate', options.populate);
}
if (options.includeStats) {
if (typeof options.includeStats !== 'boolean') {
throw new Error('includeStats must be a boolean');
}
params.append('includeStats', options.includeStats.toString());
}
const queryString = params.toString();
const url = `/v1/clients/${clientId}${queryString ? `?${queryString}` : ''}`;
return this._get(url);
}
// ==========================================
// CLIENT AUTHENTICATION
// ==========================================
/**
* Login client with email or phone number
* @param {Object} credentials - Login credentials (required)
* @param {string} credentials.identifier - Email address or phone number (required)
* @param {string} credentials.password - Client password (required)
* @param {string} [credentials.providerId] - Provider ID for multi-provider clients (optional)
*
* @returns {Promise<Object>} Login result with tokens and client data
* @throws {Error} If credentials are invalid or login fails
*
* @example
* // Login with email
* const result = await dispatch9.loginClient({
* identifier: 'client@example.com',
* password: 'securePassword123'
* });
*
* @example
* // Login with phone number
* const result = await dispatch9.loginClient({
* identifier: '+1234567890',
* password: 'securePassword123'
* });
*
* @example
* // Login with provider selection
* const result = await dispatch9.loginClient({
* identifier: 'client@example.com',
* password: 'securePassword123',
* providerId: '507f1f77bcf86cd799439011'
* });
*/
async loginClient(credentials) {
// Validate required fields
if (!credentials) {
throw new Error('credentials object is required');
}
if (!credentials.identifier) {
throw new Error('identifier is required');
}
if (typeof credentials.identifier !== 'string') {
throw new Error('identifier must be a string');
}
if (!credentials.password) {
throw new Error('password is required');
}
if (typeof credentials.password !== 'string') {
throw new Error('password must be a string');
}
if (credentials.providerId && typeof credentials.providerId !== 'string') {
throw new Error('providerId must be a string');
}
// Validate identifier format (email or phone)
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(credentials.identifier);
const isPhone = /^\+?[1-9]\d{1,14}$/.test(credentials.identifier.replace(/[\s\-\(\)]/g, ''));
if (!isEmail && !isPhone) {
throw new Error('identifier must be a valid email address or phone number');
}
return this._post('/v1/client-auth/login', {
identifier: credentials.identifier,
password: credentials.password,
...(credentials.providerId && { providerId: credentials.providerId })
});
}
/**
* Select provider for multi-provider clients
* @param {Object} selection - Provider selection data (required)
* @param {string} selection.clientId - Client ID (required)
* @param {string} selection.providerId - Provider ID to select (required)
*
* @returns {Promise<Object>} Authentication result with tokens
* @throws {Error} If selection data is invalid
*
* @example
* // Select provider after login
* const result = await dispatch9.selectClientProvider({
* clientId: '507f1f77bcf86cd799439011',
* providerId: '507f1f77bcf86cd799439012'
* });
*/
async selectClientProvider(selection) {
// Validate required fields
if (!selection) {
throw new Error('selection object is required');
}
if (!selection.clientId) {
throw new Error('clientId is required');
}
if (typeof selection.clientId !== 'string') {
throw new Error('clientId must be a string');
}
if (!selection.providerId) {
throw new Error('providerId is required');
}
if (typeof selection.providerId !== 'string') {
throw new Error('providerId must be a string');
}
// Validate ObjectId format for both IDs
const objectIdRegex = /^[0-9a-fA-F]{24}$/;
if (!objectIdRegex.test(selection.clientId)) {
throw new Error('clientId must be a valid ObjectId (24 character hex string)');
}
if (!objectIdRegex.test(selection.providerId)) {
throw new Error('providerId must be a valid ObjectId (24 character hex string)');
}
return this._post('/v1/client-auth/select-provider', {
clientId: selection.clientId,
providerId: selection.providerId
});
}
/**
* Verify client login OTP for first-time login
* @param {Object} verification - OTP verification data (required)
* @param {string} verification.clientId - Client ID (required)
* @param {string} verification.otp - OTP code (required, 6 digits)
*
* @returns {Promise<Object>} Verification result with tokens
* @throws {Error} If verification data is invalid
*
* @example
* // Verify OTP for first-time login
* const result = await dispatch9.verifyClientLoginOTP({
* clientId: '507f1f77bcf86cd799439011',
* otp: '123456'
* });
*/
async verifyClientLoginOTP(verification) {
// Validate required fields
if (!verification) {
throw new Error('verification object is required');
}
if (!verification.clientId) {
throw new Error('clientId is required');
}
if (typeof verification.clientId !== 'string') {
throw new Error('clientId must be a string');
}
if (!verification.otp) {
throw new Error('otp is required');
}
if (typeof verification.otp !== 'string') {
throw new Error('otp must be a string');
}
// Validate ObjectId format
if (!/^[0-9a-fA-F]{24}$/.test(verification.clientId)) {
throw new Error('clientId must be a valid ObjectId (24 character hex string)');
}
// Validate OTP format (6 digits)
if (!/^\d{6}$/.test(verification.otp)) {
throw new Error('otp must be a 6-digit number');
}
return this._post('/v1/client-auth/verify-login-otp', {
clientId: verification.clientId,
otp: verification.otp
});
}
/**
* Get current client profile (requires authentication)
* @returns {Promise<Object>} Current client profile data
* @throws {Error} If not authenticated or request fails
*
* @example
* // Get current client profile
* const profile = await dispatch9.getClientProfile();
* console.log(`Welcome ${profile.name}`);
*/
async getClientProfile() {
return this._get('/v1/client-auth/profile');
}
// ==========================================
// HELPER METHODS
// ==========================================
/**
* Validate email format
* @private
*/
_isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Validate URL format
* @private
*/
_isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
}
module.exports = Dispatch9Client;