diff --git a/src/config/env.ts b/src/config/env.ts index d0d4821..0861c54 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -37,6 +37,25 @@ interface EnvConfig { SOROBAN_RPC_RETRY_MAX_MS: number; /** Maximum attempts to retry a transaction that fails with tx_bad_seq. Default: 3 */ STELLAR_BAD_SEQ_MAX_RETRIES: number; + + // ── Push notifications (Firebase Cloud Messaging) ─────────────────────────── + /** + * Firebase project id. Push sending is disabled when this (or either + * credential below) is blank, so local development runs without Firebase. + */ + FCM_PROJECT_ID: string; + /** Service-account client email used to mint OAuth2 access tokens. */ + FCM_CLIENT_EMAIL: string; + /** Service-account private key (PEM; literal `\n` sequences are normalised). */ + FCM_PRIVATE_KEY: string; + /** Timeout (ms) for FCM and Google token endpoint requests. Default: 10000 */ + FCM_REQUEST_TIMEOUT_MS: number; + + // ── Bulk delivery CSV import ──────────────────────────────────────────────── + /** Maximum accepted upload size (bytes) for the bulk CSV endpoint. Default: 5MB */ + BULK_UPLOAD_MAX_BYTES: number; + /** Maximum data rows accepted in a single bulk upload. Default: 1000 */ + BULK_UPLOAD_MAX_ROWS: number; } const envSchema = z.object({ @@ -69,6 +88,16 @@ const envSchema = z.object({ SOROBAN_RPC_RETRY_BASE_MS: z.coerce.number().int().min(50).default(250), SOROBAN_RPC_RETRY_MAX_MS: z.coerce.number().int().min(500).default(8000), STELLAR_BAD_SEQ_MAX_RETRIES: z.coerce.number().int().min(1).max(10).default(3), + + // ── Push notifications (Firebase Cloud Messaging) ─────────────────────────── + FCM_PROJECT_ID: z.string().default(''), + FCM_CLIENT_EMAIL: z.string().default(''), + FCM_PRIVATE_KEY: z.string().default(''), + FCM_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1000).default(10000), + + // ── Bulk delivery CSV import ──────────────────────────────────────────────── + BULK_UPLOAD_MAX_BYTES: z.coerce.number().int().min(1024).default(5 * 1024 * 1024), + BULK_UPLOAD_MAX_ROWS: z.coerce.number().int().min(1).max(10000).default(1000), }); let env: EnvConfig; diff --git a/src/controllers/bulkDeliveryController.ts b/src/controllers/bulkDeliveryController.ts new file mode 100644 index 0000000..0e6794e --- /dev/null +++ b/src/controllers/bulkDeliveryController.ts @@ -0,0 +1,105 @@ +import { NextFunction, Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import type { IUser } from '../interfaces/IUser'; +import { bulkDeliveryService } from '../services/bulkDeliveryService'; +import AppError from '../utils/AppError'; +import logger from '../config/logger'; + +/** + * BulkDeliveryController — CSV batch creation of deliveries. + * + * Accepts `multipart/form-data` with a single `file` field. The upload is held + * in memory by multer (see the route) and handed to the service as text; no + * file is written to disk. + */ + +/** MIME types browsers and spreadsheet tools use for `.csv`. */ +const ACCEPTED_MIME_TYPES = new Set([ + 'text/csv', + 'application/csv', + 'text/plain', + 'application/vnd.ms-excel', + 'application/octet-stream', +]); + +/** Whether an uploaded file looks like CSV by MIME type or extension. */ +export const isAcceptedCsvUpload = (file: Express.Multer.File): boolean => + ACCEPTED_MIME_TYPES.has(file.mimetype) || file.originalname.toLowerCase().endsWith('.csv'); + +// ─── POST /api/v1/deliveries/bulk ────────────────────────────────────────────── + +/** + * Batch-create deliveries from an uploaded CSV file. + * + * Responds `201 Created` when every row was imported, and `207 Multi-Status` + * when some rows were rejected — the body always carries the per-row error + * report so the client can correct and resubmit only the failures. + * + * A file that is entirely unusable (unparseable, missing required columns, + * over the row limit) is a `400`, raised by the service. + * + * Errors: + * 400 — no file uploaded, wrong type, or the CSV itself is unusable + * 401 — not authenticated + * 413 — upload exceeds BULK_UPLOAD_MAX_BYTES (raised by multer) + * 422 — the file parsed but no row could be imported + */ +export const bulkCreateDeliveries = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const user = (req as Request & { user?: IUser }).user; + const userId = user?._id ? String(user._id) : undefined; + + if (!userId) { + throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); + } + + const file = req.file; + if (!file) { + throw new AppError( + 'No CSV file uploaded. Attach the file under the "file" field.', + StatusCodes.BAD_REQUEST, + ); + } + + if (!isAcceptedCsvUpload(file)) { + throw new AppError( + `Unsupported file type "${file.mimetype}". Upload a .csv file.`, + StatusCodes.BAD_REQUEST, + ); + } + + const result = await bulkDeliveryService.importFromCsv(file.buffer.toString('utf8'), userId); + + logger.info( + `[BulkDeliveryController] Import by user=${userId} — ` + + `created=${result.successCount} failed=${result.failureCount}`, + ); + + // Nothing imported but the file was well-formed: the content is the + // problem, so 422 rather than 400. + if (result.successCount === 0) { + res.status(StatusCodes.UNPROCESSABLE_ENTITY).json({ + status: 'error', + message: 'No deliveries could be created from the uploaded file', + data: result, + }); + return; + } + + const partial = result.failureCount > 0; + + res.status(partial ? StatusCodes.MULTI_STATUS : StatusCodes.CREATED).json({ + status: partial ? 'partial' : 'success', + message: partial + ? `Imported ${result.successCount} of ${result.totalRows} deliveries` + : `Imported ${result.successCount} deliveries`, + data: result, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/controllers/notificationController.ts b/src/controllers/notificationController.ts new file mode 100644 index 0000000..0975235 --- /dev/null +++ b/src/controllers/notificationController.ts @@ -0,0 +1,200 @@ +import { NextFunction, Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { z } from 'zod'; +import type { IUser } from '../interfaces/IUser'; +import { NotificationEvent } from '../models/NotificationPreference'; +import { notificationService } from '../services/notificationService'; +import AppError from '../utils/AppError'; + +/** + * NotificationController — HTTP surface for push notification preferences, + * device registration and notification history. + * + * All routes operate on the authenticated user; none accept a user id from + * the client, so one user can never read or mutate another's preferences. + */ + +// ─── Validation schemas ──────────────────────────────────────────────────────── + +/** Body accepted by `PATCH /api/v1/notifications/preferences`. */ +export const updatePreferencesSchema = z + .object({ + pushEnabled: z.boolean().optional(), + enabledEvents: z.array(z.nativeEnum(NotificationEvent)).optional(), + }) + .refine((value) => value.pushEnabled !== undefined || value.enabledEvents !== undefined, { + message: 'Provide at least one of "pushEnabled" or "enabledEvents"', + }); + +/** Body accepted by `POST /api/v1/notifications/devices`. */ +export const registerDeviceSchema = z.object({ + token: z.string().trim().min(1, 'Device token is required').max(4096), + platform: z.enum(['ios', 'android', 'web']), +}); + +/** Body accepted by `DELETE /api/v1/notifications/devices`. */ +export const unregisterDeviceSchema = z.object({ + token: z.string().trim().min(1, 'Device token is required').max(4096), +}); + +/** Query accepted by `GET /api/v1/notifications`. */ +export const listNotificationsSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Resolve the authenticated user's id from the request. + * + * `authenticate` attaches the hydrated user document; this narrows it and + * fails closed if the middleware was somehow bypassed. + */ +const requireUserId = (req: Request): string => { + const user = (req as Request & { user?: IUser }).user; + const id = user?._id ? String(user._id) : undefined; + + if (!id) { + throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED); + } + return id; +}; + +// ─── GET /api/v1/notifications/preferences ───────────────────────────────────── + +/** + * Return the authenticated user's notification preferences. + * + * Defaults are created on first access, so this never 404s for a valid user. + */ +export const getPreferences = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const preference = await notificationService.getPreferences(requireUserId(req)); + + res.status(StatusCodes.OK).json({ + status: 'success', + data: { + pushEnabled: preference.pushEnabled, + enabledEvents: preference.enabledEvents, + deviceCount: preference.devices.length, + }, + }); + } catch (error) { + next(error); + } +}; + +// ─── PATCH /api/v1/notifications/preferences ─────────────────────────────────── + +/** + * Update the authenticated user's notification preferences. + * + * Both fields are optional; `validateRequest` rejects an empty body. + */ +export const updatePreferences = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const body = req.body as z.infer; + const preference = await notificationService.updatePreferences(requireUserId(req), body); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: 'Notification preferences updated', + data: { + pushEnabled: preference.pushEnabled, + enabledEvents: preference.enabledEvents, + deviceCount: preference.devices.length, + }, + }); + } catch (error) { + next(error); + } +}; + +// ─── POST /api/v1/notifications/devices ──────────────────────────────────────── + +/** + * Register (or refresh) a push token for one of the user's devices. + * + * Registering a token already held by another account detaches it from that + * account first — see NotificationPreferenceRepository#registerDevice. + */ +export const registerDevice = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const body = req.body as z.infer; + const preference = await notificationService.registerDevice({ + userId: requireUserId(req), + token: body.token, + platform: body.platform, + }); + + res.status(StatusCodes.CREATED).json({ + status: 'success', + message: 'Device registered for push notifications', + data: { deviceCount: preference.devices.length }, + }); + } catch (error) { + next(error); + } +}; + +// ─── DELETE /api/v1/notifications/devices ────────────────────────────────────── + +/** Remove a device push token, e.g. on logout. */ +export const unregisterDevice = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const body = req.body as z.infer; + const preference = await notificationService.unregisterDevice(requireUserId(req), body.token); + + res.status(StatusCodes.OK).json({ + status: 'success', + message: 'Device unregistered', + data: { deviceCount: preference.devices.length }, + }); + } catch (error) { + next(error); + } +}; + +// ─── GET /api/v1/notifications ───────────────────────────────────────────────── + +/** Return a page of the authenticated user's notification history. */ +export const listNotifications = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const { page, limit } = req.query as unknown as z.infer; + const result = await notificationService.listForUser(requireUserId(req), page, limit); + + res.status(StatusCodes.OK).json({ + status: 'success', + data: result.data, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages, + }, + }); + } catch (error) { + next(error); + } +}; diff --git a/src/models/Notification.ts b/src/models/Notification.ts new file mode 100644 index 0000000..2a19366 --- /dev/null +++ b/src/models/Notification.ts @@ -0,0 +1,82 @@ +import mongoose, { Document, Schema, Types } from 'mongoose'; +import { NotificationChannel, NotificationEvent } from './NotificationPreference'; + +/** Terminal and in-flight states for a single notification attempt. */ +export enum NotificationStatus { + /** Accepted by the provider for at least one device. */ + SENT = 'sent', + /** Rejected by the provider, or every target device failed. */ + FAILED = 'failed', + /** Suppressed because the user opted out or has no registered device. */ + SKIPPED = 'skipped', +} + +export interface INotification extends Document { + user: Types.ObjectId; + event: NotificationEvent; + channel: NotificationChannel; + title: string; + body: string; + /** Structured payload the client app uses to deep-link. */ + data: Record; + status: NotificationStatus; + /** Number of device tokens the provider accepted. */ + acceptedCount: number; + /** Number of device tokens the provider rejected. */ + rejectedCount: number; + /** Provider error or skip reason, when not `SENT`. */ + failureReason?: string; + /** Delivery this notification concerns, when applicable. */ + delivery?: Types.ObjectId; + createdAt: Date; + updatedAt: Date; +} + +const NotificationSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + event: { + type: String, + enum: Object.values(NotificationEvent), + required: true, + }, + channel: { + type: String, + enum: Object.values(NotificationChannel), + default: NotificationChannel.PUSH, + }, + title: { type: String, required: true, trim: true }, + body: { type: String, required: true, trim: true }, + data: { type: Schema.Types.Mixed, default: {} }, + status: { + type: String, + enum: Object.values(NotificationStatus), + required: true, + }, + acceptedCount: { type: Number, default: 0 }, + rejectedCount: { type: Number, default: 0 }, + failureReason: { type: String }, + delivery: { + type: Schema.Types.ObjectId, + ref: 'Delivery', + }, + }, + { timestamps: true }, +); + +// ─── Indexes ──────────────────────────────────────────────────────────────── +// GET /api/v1/notifications returns a user's history newest-first +// (src/services/notificationService.ts#listForUser). +NotificationSchema.index({ user: 1, createdAt: -1 }); + +// Supports "what was sent for this delivery" lookups during support triage. +NotificationSchema.index({ delivery: 1, createdAt: -1 }); + +const Notification = mongoose.model('Notification', NotificationSchema); + +export default Notification; +export { Notification }; diff --git a/src/models/NotificationPreference.ts b/src/models/NotificationPreference.ts new file mode 100644 index 0000000..5dcb929 --- /dev/null +++ b/src/models/NotificationPreference.ts @@ -0,0 +1,92 @@ +import mongoose, { Document, Schema, Types } from 'mongoose'; + +/** + * Delivery lifecycle events a user can subscribe to. + * + * These mirror the `DeliveryStatus` transitions that matter to an end user; + * intermediate bookkeeping states are deliberately not notifiable. + */ +export enum NotificationEvent { + DELIVERY_PENDING = 'delivery.pending', + DELIVERY_ASSIGNED = 'delivery.assigned', + DELIVERY_IN_PROGRESS = 'delivery.in_progress', + DELIVERY_COMPLETED = 'delivery.completed', + DELIVERY_CANCELLED = 'delivery.cancelled', +} + +/** Transport a notification can be delivered over. */ +export enum NotificationChannel { + PUSH = 'push', +} + +/** A registered push token for one of a user's devices. */ +export interface IDeviceToken { + /** Provider-issued registration token (FCM registration id). */ + token: string; + platform: 'ios' | 'android' | 'web'; + /** Last time the client re-registered this token. */ + lastSeenAt: Date; +} + +export interface INotificationPreference extends Document { + user: Types.ObjectId; + /** Master switch — when false, no push is sent regardless of event opt-ins. */ + pushEnabled: boolean; + /** Events the user has opted into. Absent from the array means opted out. */ + enabledEvents: NotificationEvent[]; + devices: IDeviceToken[]; + createdAt: Date; + updatedAt: Date; +} + +const DeviceTokenSchema = new Schema( + { + token: { type: String, required: true, trim: true }, + platform: { + type: String, + enum: ['ios', 'android', 'web'], + required: true, + }, + lastSeenAt: { type: Date, default: Date.now }, + }, + { _id: false }, +); + +const NotificationPreferenceSchema = new Schema( + { + user: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + unique: true, + }, + pushEnabled: { type: Boolean, default: true }, + enabledEvents: { + type: [String], + enum: Object.values(NotificationEvent), + // New users are opted into every event; they can narrow this afterwards. + // A factory (not a shared array literal) so each document gets its own + // copy and one user's edit cannot mutate another's defaults. + default: (): NotificationEvent[] => Object.values(NotificationEvent), + }, + devices: { type: [DeviceTokenSchema], default: [] }, + }, + { timestamps: true }, +); + +// ─── Indexes ──────────────────────────────────────────────────────────────── +// Preferences are always resolved by user before a send +// (src/services/notificationService.ts#notifyDeliveryTransition). The unique +// constraint on `user` above already provides that index. + +// Token registration looks up the owning preference document by raw token so a +// device that moves between accounts can be detached from the previous owner. +NotificationPreferenceSchema.index({ 'devices.token': 1 }); + +const NotificationPreference = mongoose.model( + 'NotificationPreference', + NotificationPreferenceSchema, +); + +export default NotificationPreference; +export { NotificationPreference }; diff --git a/src/repositories/BaseRepository.ts b/src/repositories/BaseRepository.ts new file mode 100644 index 0000000..84c935c --- /dev/null +++ b/src/repositories/BaseRepository.ts @@ -0,0 +1,174 @@ +import { Document, FilterQuery, Model, Query, QueryOptions, Types, UpdateQuery } from 'mongoose'; +import { IRepository, Page, ReadOptions, WriteOptions } from './types'; + +/** + * Generic Mongoose-backed implementation of {@link IRepository}. + * + * This is the only layer in the application allowed to touch a Mongoose model + * directly. Concrete repositories extend it to add domain-specific queries; + * services consume those repositories and never import a model themselves. + * + * Invalid ObjectId strings resolve to `null`/`false` rather than throwing, so + * a malformed path parameter surfaces as a clean 404 in the service layer + * instead of a Mongoose CastError leaking out as a 500. + * + * @typeParam T - The hydrated document type (must extend Mongoose `Document`). + */ +export abstract class BaseRepository implements IRepository { + protected constructor(protected readonly model: Model) {} + + /** The registered Mongoose model name, useful for logs and error messages. */ + public get modelName(): string { + return this.model.modelName; + } + + /** + * Apply the shared read options to a query. + * + * Kept in one place so every read path treats projection, sorting, + * pagination and session handling identically. + */ + protected applyReadOptions(query: Query, options?: ReadOptions): Query { + if (!options) return query; + + if (options.queryOptions) query.setOptions(options.queryOptions as QueryOptions); + // `select` rather than `projection`: it accepts the full ProjectionType + // union (string shorthand such as '+password' included), which the + // narrower `projection` overloads reject. + if (options.projection !== undefined) { + query.select( + options.projection as string | string[] | Record, + ); + } + if (options.sort) query.sort(options.sort); + if (typeof options.skip === 'number') query.skip(options.skip); + if (typeof options.limit === 'number') query.limit(options.limit); + if (options.populate) query.populate(options.populate as string | string[]); + if (options.session) query.session(options.session); + if (options.lean) query.lean(); + + return query; + } + + /** + * Guard against Mongoose CastErrors on user-supplied identifiers. + * + * @returns `true` when `id` is a well-formed ObjectId. + */ + protected isValidId(id: string): boolean { + return Types.ObjectId.isValid(id); + } + + async create(data: Partial, options?: WriteOptions): Promise { + const [created] = await this.model.create([data], { + session: options?.session, + }); + return created; + } + + /** + * Insert many documents in a single round trip. + * + * Pass `ordered: false` to let the driver continue past individual failures + * — required by bulk imports that must report partial success. + */ + async createMany(data: Partial[], options?: WriteOptions): Promise { + if (data.length === 0) return []; + + const inserted = await this.model.insertMany(data, { + session: options?.session, + ordered: options?.ordered ?? true, + rawResult: false, + }); + + return inserted as unknown as T[]; + } + + async findById(id: string, options?: ReadOptions): Promise { + if (!this.isValidId(id)) return null; + return this.applyReadOptions(this.model.findById(id), options).exec() as Promise; + } + + async findOne(filter: FilterQuery, options?: ReadOptions): Promise { + return this.applyReadOptions(this.model.findOne(filter), options).exec() as Promise; + } + + async find(filter: FilterQuery, options?: ReadOptions): Promise { + return this.applyReadOptions(this.model.find(filter), options).exec() as Promise; + } + + /** + * Run a filtered query and its matching count concurrently. + * + * `page` and `limit` are clamped so a hostile or buggy caller cannot request + * a negative skip or an unbounded result set. + */ + async paginate( + filter: FilterQuery, + page: number, + limit: number, + options?: ReadOptions, + ): Promise> { + const safePage = Math.max(1, Math.floor(page) || 1); + const safeLimit = Math.min(Math.max(1, Math.floor(limit) || 10), 100); + const skip = (safePage - 1) * safeLimit; + + const [data, total] = await Promise.all([ + this.find(filter, { ...options, skip, limit: safeLimit }), + this.count(filter, options), + ]); + + return { + data, + total, + page: safePage, + limit: safeLimit, + totalPages: Math.ceil(total / safeLimit), + }; + } + + async count(filter: FilterQuery, options?: ReadOptions): Promise { + const query = this.model.countDocuments(filter); + if (options?.queryOptions) query.setOptions(options.queryOptions as QueryOptions); + if (options?.session) query.session(options.session); + return query.exec(); + } + + async exists(filter: FilterQuery, options?: ReadOptions): Promise { + const query = this.model.exists(filter); + if (options?.queryOptions) query.setOptions(options.queryOptions as QueryOptions); + if (options?.session) query.session(options.session); + return (await query.exec()) !== null; + } + + async updateById(id: string, update: UpdateQuery, options?: WriteOptions): Promise { + if (!this.isValidId(id)) return null; + return this.model + .findByIdAndUpdate(id, update, { + new: true, + runValidators: options?.runValidators ?? true, + session: options?.session, + }) + .exec() as Promise; + } + + async updateOne( + filter: FilterQuery, + update: UpdateQuery, + options?: WriteOptions, + ): Promise { + return this.model + .findOneAndUpdate(filter, update, { + new: true, + runValidators: options?.runValidators ?? true, + session: options?.session, + }) + .exec() as Promise; + } + + async deleteById(id: string, options?: WriteOptions): Promise { + if (!this.isValidId(id)) return false; + const result = await this.model.findByIdAndDelete(id, { session: options?.session }).exec(); + return result !== null; + } +} diff --git a/src/repositories/ChatMessageRepository.ts b/src/repositories/ChatMessageRepository.ts new file mode 100644 index 0000000..7ef3b21 --- /dev/null +++ b/src/repositories/ChatMessageRepository.ts @@ -0,0 +1,22 @@ +import ChatMessage, { IChatMessage } from '../models/ChatMessage'; +import { BaseRepository } from './BaseRepository'; + +/** Persistence gateway for realtime chat messages. */ +export class ChatMessageRepository extends BaseRepository { + constructor() { + super(ChatMessage); + } + + /** + * The most recent messages, newest first. + * + * Callers that render a transcript reverse the result to get chronological + * order; the query itself stays newest-first so it can use the descending + * `createdAt` index. + */ + async findRecent(limit: number): Promise { + return this.find({}, { sort: { createdAt: -1 }, limit, lean: true }); + } +} + +export const chatMessageRepository = new ChatMessageRepository(); diff --git a/src/repositories/DeliveryRepository.ts b/src/repositories/DeliveryRepository.ts new file mode 100644 index 0000000..77015fb --- /dev/null +++ b/src/repositories/DeliveryRepository.ts @@ -0,0 +1,156 @@ +import { FilterQuery } from 'mongoose'; +import Delivery, { DeliveryStatus, IDelivery } from '../models/Delivery'; +import { BaseRepository } from './BaseRepository'; +import { Page, ReadOptions, WriteOptions } from './types'; + +/** Filter criteria accepted by {@link DeliveryRepository.listPaginated}. */ +export interface DeliveryQueryFilter { + status?: DeliveryStatus; + driverId?: string; + /** Case-insensitive match against tracking number, customer name or phone. */ + search?: string; +} + +/** + * Persistence gateway for the `Delivery` collection. + * + * Archived (soft-deleted) documents are excluded by default; the `*Archived` + * methods opt back in via the `includeDeleted` query option that the model's + * soft-delete behaviour reads. + */ +export class DeliveryRepository extends BaseRepository { + constructor() { + super(Delivery); + } + + /** Look up a delivery by its externally-visible tracking number. */ + async findByTrackingNumber( + trackingNumber: string, + options?: ReadOptions, + ): Promise { + return this.findOne({ trackingNumber }, options); + } + + /** + * Check whether a tracking number is already taken. + * + * Includes archived deliveries — a tracking number stays reserved after a + * delivery is archived so restoring one can never collide with a newer record. + */ + async trackingNumberExists(trackingNumber: string): Promise { + return this.exists( + { trackingNumber }, + { queryOptions: { includeDeleted: true } as Record }, + ); + } + + /** + * Return only the tracking numbers already present from the given candidates. + * + * Used by bulk import to detect duplicates in one query instead of issuing + * one existence check per row. + */ + async findExistingTrackingNumbers(trackingNumbers: string[]): Promise> { + if (trackingNumbers.length === 0) return new Set(); + + const found = await this.find( + { trackingNumber: { $in: trackingNumbers } }, + { + projection: { trackingNumber: 1 }, + lean: true, + queryOptions: { includeDeleted: true } as Record, + }, + ); + + return new Set( + found + .map((doc) => (doc as unknown as { trackingNumber?: string }).trackingNumber) + .filter((value): value is string => typeof value === 'string'), + ); + } + + /** Translate domain filters into a Mongo query and return one page. */ + async listPaginated( + filter: DeliveryQueryFilter, + page: number, + limit: number, + ): Promise> { + return this.paginate(this.buildFilter(filter), page, limit, { + sort: { createdAt: -1 }, + }); + } + + /** List archived deliveries, most recently archived first. */ + async listArchived(page: number, limit: number): Promise> { + return this.paginate({ isDeleted: true }, page, limit, { + sort: { deletedAt: -1 }, + queryOptions: { includeDeleted: true } as Record, + }); + } + + /** + * Load a delivery even if it has been archived. + * + * Archive/restore flows need to see soft-deleted documents that the default + * read path hides. + */ + async findByIdIncludingArchived(id: string): Promise { + return this.findById(id, { + queryOptions: { includeDeleted: true } as Record, + }); + } + + /** Deliveries currently assigned to a driver, newest first. */ + async findByDriver(driverId: string, options?: ReadOptions): Promise { + return this.find({ driverId }, { sort: { createdAt: -1 }, ...options }); + } + + /** + * Atomically move a delivery from one status to another. + * + * The expected current status is part of the filter, so two concurrent + * transition requests cannot both succeed — the loser matches no document + * and receives `null`. This is what makes status transitions safe without a + * distributed lock. + * + * @returns The updated delivery, or `null` if it was not in `from`. + */ + async transitionStatus( + id: string, + from: DeliveryStatus | DeliveryStatus[], + to: DeliveryStatus, + options?: WriteOptions, + ): Promise { + if (!this.isValidId(id)) return null; + + const expected = Array.isArray(from) ? from : [from]; + return this.updateOne( + { _id: id, status: { $in: expected } } as FilterQuery, + { $set: { status: to } }, + options, + ); + } + + /** Compose the Mongo filter for {@link listPaginated}. */ + private buildFilter(filter: DeliveryQueryFilter): FilterQuery { + const query: FilterQuery = {}; + + if (filter.status) query.status = filter.status; + if (filter.driverId) query.driverId = filter.driverId; + + if (filter.search) { + // Escape user input so regex metacharacters are matched literally + // rather than interpreted as a pattern. + const escaped = filter.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + query.$or = [ + { trackingNumber: { $regex: escaped, $options: 'i' } }, + { 'customer.name': { $regex: escaped, $options: 'i' } }, + { 'customer.phone': { $regex: escaped, $options: 'i' } }, + ]; + } + + return query; + } +} + +export const deliveryRepository = new DeliveryRepository(); diff --git a/src/repositories/EscrowRepository.ts b/src/repositories/EscrowRepository.ts new file mode 100644 index 0000000..320e851 --- /dev/null +++ b/src/repositories/EscrowRepository.ts @@ -0,0 +1,93 @@ +import { Types } from 'mongoose'; +import Escrow, { EscrowLockStatus, IEscrow, IEscrowTransaction } from '../models/Escrow'; +import { BaseRepository } from './BaseRepository'; +import { ReadOptions, WriteOptions } from './types'; + +/** + * Persistence gateway for the `Escrow` collection. + * + * Escrow records mirror on-chain Soroban contract state, so writes here are + * expressed as explicit state transitions rather than free-form updates. The + * transition helpers below make the expected prior state part of the query, + * which prevents a replayed or duplicated chain event from, say, releasing an + * escrow twice. + */ +export class EscrowRepository extends BaseRepository { + constructor() { + super(Escrow); + } + + /** The escrow guarding a given delivery, if one has been created. */ + async findByDeliveryId( + deliveryId: string | Types.ObjectId, + options?: ReadOptions, + ): Promise { + if (typeof deliveryId === 'string' && !this.isValidId(deliveryId)) return null; + return this.findOne({ delivery: deliveryId }, options); + } + + /** Look up an escrow by its Soroban contract id. */ + async findByContractId( + contractId: string, + options?: ReadOptions, + ): Promise { + return this.findOne({ contractId }, options); + } + + /** All escrows currently in a given lock state. */ + async findByLockStatus( + lockStatus: EscrowLockStatus, + options?: ReadOptions, + ): Promise { + return this.find({ lockStatus }, options); + } + + /** + * Whether a transaction hash has already been recorded on any escrow. + * + * Chain indexers can deliver the same event more than once; this is the + * idempotency check that keeps a replay from double-appending. + */ + async transactionHashExists(hash: string): Promise { + return this.exists({ 'transactions.hash': hash }); + } + + /** + * Append an on-chain transaction to an escrow's audit trail. + * + * `$addToSet` on the hash would not work here because transactions are + * subdocuments, so callers should pair this with + * {@link transactionHashExists} when replay is possible. + */ + async appendTransaction( + id: string, + transaction: IEscrowTransaction, + options?: WriteOptions, + ): Promise { + return this.updateById(id, { $push: { transactions: transaction } }, options); + } + + /** + * Move an escrow between lock states, asserting the prior state. + * + * @param expectedFrom - States the escrow may legally be in for this move. + * @param timestampField - Lifecycle timestamp to stamp with the current time. + * @returns The updated escrow, or `null` if it was not in `expectedFrom`. + */ + async transitionLockStatus( + id: string, + expectedFrom: EscrowLockStatus[], + to: EscrowLockStatus, + timestampField?: 'lockedAt' | 'releasedAt' | 'refundedAt', + options?: WriteOptions, + ): Promise { + if (!this.isValidId(id)) return null; + + const set: Record = { lockStatus: to }; + if (timestampField) set[timestampField] = new Date(); + + return this.updateOne({ _id: id, lockStatus: { $in: expectedFrom } }, { $set: set }, options); + } +} + +export const escrowRepository = new EscrowRepository(); diff --git a/src/repositories/NotificationPreferenceRepository.ts b/src/repositories/NotificationPreferenceRepository.ts new file mode 100644 index 0000000..a392e79 --- /dev/null +++ b/src/repositories/NotificationPreferenceRepository.ts @@ -0,0 +1,156 @@ +import { Types } from 'mongoose'; +import NotificationPreference, { + IDeviceToken, + INotificationPreference, + NotificationEvent, +} from '../models/NotificationPreference'; +import { BaseRepository } from './BaseRepository'; +import { WriteOptions } from './types'; + +/** Fields a user is allowed to change on their own preferences. */ +export interface PreferenceUpdate { + pushEnabled?: boolean; + enabledEvents?: NotificationEvent[]; +} + +/** + * Persistence gateway for per-user notification preferences and device tokens. + */ +export class NotificationPreferenceRepository extends BaseRepository { + constructor() { + super(NotificationPreference); + } + + /** Preferences for a user, or `null` if they have never been initialised. */ + async findByUserId(userId: string | Types.ObjectId): Promise { + if (typeof userId === 'string' && !this.isValidId(userId)) return null; + return this.findOne({ user: userId }); + } + + /** + * Return a user's preferences, creating the default document if absent. + * + * Uses an upsert with `$setOnInsert` so two concurrent first-time requests + * cannot race into a duplicate-key error — the second one matches the + * document the first inserted. + */ + async findOrCreateByUserId(userId: string | Types.ObjectId): Promise { + const preference = await this.model + .findOneAndUpdate( + { user: userId }, + { $setOnInsert: { user: userId } }, + { new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true }, + ) + .exec(); + + return preference as INotificationPreference; + } + + /** Apply a partial preference update, creating the document if needed. */ + async updateForUser( + userId: string | Types.ObjectId, + update: PreferenceUpdate, + options?: WriteOptions, + ): Promise { + const set: Record = {}; + if (typeof update.pushEnabled === 'boolean') set.pushEnabled = update.pushEnabled; + if (update.enabledEvents) set.enabledEvents = update.enabledEvents; + + if (Object.keys(set).length === 0) { + return this.findOrCreateByUserId(userId); + } + + return this.model + .findOneAndUpdate( + { user: userId }, + { $set: set, $setOnInsert: { user: userId } }, + { + new: true, + upsert: true, + setDefaultsOnInsert: true, + runValidators: options?.runValidators ?? true, + session: options?.session, + }, + ) + .exec() as Promise; + } + + /** + * Register (or refresh) a device token for a user. + * + * A token identifies a device install, not a person: when the same token + * appears under a different account the device has been handed over or the + * app re-authenticated, so it is detached from the previous owner first. + * Skipping that step would push one user's delivery updates to another's + * phone. + */ + async registerDevice( + userId: string | Types.ObjectId, + device: Omit, + ): Promise { + await this.model + .updateMany( + { user: { $ne: userId }, 'devices.token': device.token }, + { $pull: { devices: { token: device.token } } }, + ) + .exec(); + + // Refresh the timestamp if this user already has the token, so an + // existing registration is not duplicated. + const refreshed = await this.model + .findOneAndUpdate( + { user: userId, 'devices.token': device.token }, + { + $set: { + 'devices.$.platform': device.platform, + 'devices.$.lastSeenAt': new Date(), + }, + }, + { new: true }, + ) + .exec(); + + if (refreshed) return refreshed as INotificationPreference; + + const updated = await this.model + .findOneAndUpdate( + { user: userId }, + { + $push: { devices: { ...device, lastSeenAt: new Date() } }, + $setOnInsert: { user: userId }, + }, + { new: true, upsert: true, setDefaultsOnInsert: true, runValidators: true }, + ) + .exec(); + + return updated as INotificationPreference; + } + + /** Remove a device token from a user (logout or manual unsubscribe). */ + async removeDevice( + userId: string | Types.ObjectId, + token: string, + ): Promise { + return this.model + .findOneAndUpdate({ user: userId }, { $pull: { devices: { token } } }, { new: true }) + .exec() as Promise; + } + + /** + * Drop tokens the provider reported as permanently invalid. + * + * Called after a send so uninstalled apps stop consuming provider quota. + */ + async pruneTokens(tokens: string[]): Promise { + if (tokens.length === 0) return 0; + const result = await this.model + .updateMany( + { 'devices.token': { $in: tokens } }, + { $pull: { devices: { token: { $in: tokens } } } }, + ) + .exec(); + return result.modifiedCount ?? 0; + } +} + +export const notificationPreferenceRepository = new NotificationPreferenceRepository(); diff --git a/src/repositories/NotificationRepository.ts b/src/repositories/NotificationRepository.ts new file mode 100644 index 0000000..aed9e93 --- /dev/null +++ b/src/repositories/NotificationRepository.ts @@ -0,0 +1,34 @@ +import { Types } from 'mongoose'; +import Notification, { INotification } from '../models/Notification'; +import { BaseRepository } from './BaseRepository'; +import { Page } from './types'; + +/** + * Persistence gateway for the notification audit log. + * + * Every send attempt is recorded — including suppressed and failed ones — so + * "why didn't I get notified?" is answerable from the database rather than + * from application logs. + */ +export class NotificationRepository extends BaseRepository { + constructor() { + super(Notification); + } + + /** One page of a user's notification history, newest first. */ + async listForUser( + userId: string | Types.ObjectId, + page: number, + limit: number, + ): Promise> { + return this.paginate({ user: userId }, page, limit, { sort: { createdAt: -1 } }); + } + + /** Every notification recorded for a delivery, newest first. */ + async listForDelivery(deliveryId: string | Types.ObjectId): Promise { + if (typeof deliveryId === 'string' && !this.isValidId(deliveryId)) return []; + return this.find({ delivery: deliveryId }, { sort: { createdAt: -1 } }); + } +} + +export const notificationRepository = new NotificationRepository(); diff --git a/src/repositories/UserRepository.ts b/src/repositories/UserRepository.ts new file mode 100644 index 0000000..476f935 --- /dev/null +++ b/src/repositories/UserRepository.ts @@ -0,0 +1,83 @@ +import User from '../models/User'; +import { IUser, UserRole, UserStatus } from '../interfaces/IUser'; +import { BaseRepository } from './BaseRepository'; +import { ReadOptions, WriteOptions } from './types'; + +/** + * Persistence gateway for the `User` collection. + * + * The `password` field is `select: false` on the schema, so it is absent from + * every read unless {@link findByEmailWithPassword} is used. Keeping that the + * single opt-in point means a hash cannot leak into an API response by accident. + */ +export class UserRepository extends BaseRepository { + constructor() { + super(User); + } + + /** Find a user by email. The password hash is **not** included. */ + async findByEmail(email: string, options?: ReadOptions): Promise { + return this.findOne({ email: email.toLowerCase().trim() }, options); + } + + /** + * Find a user by email **including** the password hash. + * + * Intended solely for credential verification during login. + */ + async findByEmailWithPassword(email: string): Promise { + return this.findOne({ email: email.toLowerCase().trim() }, { projection: '+password' }); + } + + /** Find a user by their Stellar public key. */ + async findByWalletAddress(walletAddress: string): Promise { + return this.findOne({ walletAddress }); + } + + /** Whether an account already exists for this email. */ + async emailExists(email: string): Promise { + return this.exists({ email: email.toLowerCase().trim() }); + } + + /** Resolve several users by id in one query, skipping malformed ids. */ + async findByIds(ids: string[], options?: ReadOptions): Promise { + const valid = ids.filter((id) => this.isValidId(id)); + if (valid.length === 0) return []; + return this.find({ _id: { $in: valid } }, options); + } + + /** All users holding a given role — used for administrative fan-out. */ + async findByRole(role: UserRole, options?: ReadOptions): Promise { + return this.find({ role }, options); + } + + /** Suspend an account, recording the reason and the time it took effect. */ + async suspend(id: string, reason: string, options?: WriteOptions): Promise { + return this.updateById( + id, + { + $set: { + status: UserStatus.SUSPENDED, + suspendedReason: reason, + suspendedAt: new Date(), + isActive: false, + }, + }, + options, + ); + } + + /** Reactivate a suspended account and clear the suspension metadata. */ + async reactivate(id: string, options?: WriteOptions): Promise { + return this.updateById( + id, + { + $set: { status: UserStatus.ACTIVE, isActive: true }, + $unset: { suspendedReason: '', suspendedAt: '' }, + }, + options, + ); + } +} + +export const userRepository = new UserRepository(); diff --git a/src/repositories/index.ts b/src/repositories/index.ts new file mode 100644 index 0000000..08995e5 --- /dev/null +++ b/src/repositories/index.ts @@ -0,0 +1,24 @@ +/** + * Repository layer. + * + * Every database access in the application goes through one of these classes. + * Services depend on repositories; only repositories import Mongoose models. + * + * Each repository is exported both as a class (for tests that want an isolated + * instance) and as a shared singleton (used by services at runtime). + */ +export { BaseRepository } from './BaseRepository'; +export type { IRepository, Page, ReadOptions, WriteOptions } from './types'; + +export { DeliveryRepository, deliveryRepository } from './DeliveryRepository'; +export type { DeliveryQueryFilter } from './DeliveryRepository'; + +export { UserRepository, userRepository } from './UserRepository'; +export { EscrowRepository, escrowRepository } from './EscrowRepository'; + +export { + NotificationPreferenceRepository, + notificationPreferenceRepository, +} from './NotificationPreferenceRepository'; +export { NotificationRepository, notificationRepository } from './NotificationRepository'; +export { ChatMessageRepository, chatMessageRepository } from './ChatMessageRepository'; diff --git a/src/repositories/types.ts b/src/repositories/types.ts new file mode 100644 index 0000000..dfed4e4 --- /dev/null +++ b/src/repositories/types.ts @@ -0,0 +1,89 @@ +import { ClientSession, FilterQuery, ProjectionType, QueryOptions, UpdateQuery } from 'mongoose'; + +/** + * Options accepted by every read operation on a repository. + * + * These intentionally mirror the subset of Mongoose's `QueryOptions` that the + * service layer legitimately needs, so callers never have to import Mongoose + * types to talk to a repository. + */ +export interface ReadOptions { + /** Field selection, e.g. `'+password'` or `{ password: 1 }`. */ + projection?: ProjectionType; + /** Sort specification, e.g. `{ createdAt: -1 }`. */ + sort?: Record; + /** Number of documents to skip (pagination offset). */ + skip?: number; + /** Maximum number of documents to return. */ + limit?: number; + /** Paths to populate. */ + populate?: string | string[]; + /** + * Return plain JavaScript objects instead of hydrated Mongoose documents. + * Faster, but the result has no instance methods (e.g. `softDelete`). + */ + lean?: boolean; + /** Transaction session to run the query in. */ + session?: ClientSession; + /** + * Extra driver-level options passed through to `Query.setOptions`. + * + * Needed for schema plugins that read custom options — the soft-delete + * plugin on `Delivery`, for instance, honours `includeDeleted`. + */ + queryOptions?: QueryOptions; +} + +/** Options accepted by every write operation on a repository. */ +export interface WriteOptions { + /** Transaction session to run the write in. */ + session?: ClientSession; + /** Run schema validators on update operations. Defaults to `true`. */ + runValidators?: boolean; + /** + * Continue past individual failures during `insertMany` instead of aborting + * on the first error. Required for partial-success bulk imports. + */ + ordered?: boolean; +} + +/** A single page of results plus the metadata needed to render pagination. */ +export interface Page { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +/** + * The persistence contract every repository satisfies. + * + * Services depend on this interface rather than on Mongoose models, which is + * what makes the business logic testable against a fake and swappable if the + * storage engine ever changes. + * + * @typeParam T - The document shape managed by the repository. + */ +export interface IRepository { + create(data: Partial, options?: WriteOptions): Promise; + createMany(data: Partial[], options?: WriteOptions): Promise; + findById(id: string, options?: ReadOptions): Promise; + findOne(filter: FilterQuery, options?: ReadOptions): Promise; + find(filter: FilterQuery, options?: ReadOptions): Promise; + paginate( + filter: FilterQuery, + page: number, + limit: number, + options?: ReadOptions, + ): Promise>; + count(filter: FilterQuery, options?: ReadOptions): Promise; + exists(filter: FilterQuery, options?: ReadOptions): Promise; + updateById(id: string, update: UpdateQuery, options?: WriteOptions): Promise; + updateOne( + filter: FilterQuery, + update: UpdateQuery, + options?: WriteOptions, + ): Promise; + deleteById(id: string, options?: WriteOptions): Promise; +} diff --git a/src/routes/bulkDeliveryRoutes.ts b/src/routes/bulkDeliveryRoutes.ts new file mode 100644 index 0000000..3e3459a --- /dev/null +++ b/src/routes/bulkDeliveryRoutes.ts @@ -0,0 +1,75 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import multer, { MulterError } from 'multer'; +import { StatusCodes } from 'http-status-codes'; +import authenticate from '../middleware/authenticate'; +import { bulkCreateDeliveries } from '../controllers/bulkDeliveryController'; +import env from '../config/env'; +import AppError from '../utils/AppError'; + +const router = Router(); + +/** + * CSV uploads are buffered in memory rather than written to disk: the file is + * parsed once and discarded, so there is no reason to touch the filesystem. + * `BULK_UPLOAD_MAX_BYTES` bounds the memory a single request can consume. + */ +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: env.BULK_UPLOAD_MAX_BYTES, + files: 1, + }, +}); + +/** + * Translate multer's own errors into the application's error shape. + * + * Without this, an oversized upload surfaces as an unhandled `MulterError` + * and the client gets a 500 for what is really a 413. + */ +const handleUploadErrors = ( + error: unknown, + _req: Request, + _res: Response, + next: NextFunction, +): void => { + if (error instanceof MulterError) { + if (error.code === 'LIMIT_FILE_SIZE') { + const limitMb = (env.BULK_UPLOAD_MAX_BYTES / (1024 * 1024)).toFixed(1); + next( + new AppError(`Uploaded file exceeds the ${limitMb}MB limit.`, StatusCodes.REQUEST_TOO_LONG), + ); + return; + } + + if (error.code === 'LIMIT_UNEXPECTED_FILE') { + next( + new AppError( + 'Unexpected file field. Attach a single file under the "file" field.', + StatusCodes.BAD_REQUEST, + ), + ); + return; + } + + next(new AppError(`File upload failed: ${error.message}`, StatusCodes.BAD_REQUEST)); + return; + } + + next(error); +}; + +/** + * @route POST /api/v1/deliveries/bulk + * @desc Batch-create deliveries from a CSV file + * @access Private + * @body multipart/form-data with a "file" field containing the CSV + * + * Required columns: trackingNumber, customerName, customerPhone, + * pickupAddress, dropoffAddress, packageDescription, packageWeight, + * deliveryFee, escrowAmount. + * Optional columns: customerEmail, notes. + */ +router.post('/bulk', authenticate, upload.single('file'), handleUploadErrors, bulkCreateDeliveries); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index e817959..f393f9b 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -10,11 +10,15 @@ import disputeRoutes from './disputeRoutes'; import eventLogRoutes from './eventLogRoutes'; import profileRoutes from './profileRoutes'; import healthRoutes from './healthRoutes'; -import userRoutes from './userRoutes'; +import bulkDeliveryRoutes from './bulkDeliveryRoutes'; +import notificationRoutes from './notificationRoutes'; const router = Router(); router.use('/v1/auth', authRoutes); +// Registered before the CRUD routes so the literal /bulk path is matched +// before any /:id parameter route can capture "bulk" as an identifier. +router.use('/v1/deliveries', bulkDeliveryRoutes); router.use('/v1/deliveries', deliveryCrudRoutes); router.use('/v1/deliveries', deliveryEtaRoutes); router.use('/v1/deliveries', deliveryStatusRoutes); @@ -24,6 +28,7 @@ router.use('/v1/fleets', fleetRoutes); router.use('/v1/disputes', disputeRoutes); router.use('/v1/eventlog', eventLogRoutes); router.use('/v1/profile', profileRoutes); +router.use('/v1/notifications', notificationRoutes); router.use('/v1/health', healthRoutes); router.use('/v1/users', userRoutes); diff --git a/src/routes/notificationRoutes.ts b/src/routes/notificationRoutes.ts new file mode 100644 index 0000000..4b6bed3 --- /dev/null +++ b/src/routes/notificationRoutes.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import authenticate from '../middleware/authenticate'; +import { validateRequest } from '../middlewares/validateRequest'; +import { + getPreferences, + listNotifications, + listNotificationsSchema, + registerDevice, + registerDeviceSchema, + unregisterDevice, + unregisterDeviceSchema, + updatePreferences, + updatePreferencesSchema, +} from '../controllers/notificationController'; + +const router = Router(); + +/** + * Every notification route acts on the authenticated user's own records. + * No handler accepts a user id from the client. + */ +router.use(authenticate); + +/** + * @route GET /api/v1/notifications + * @desc Paginated notification history for the authenticated user + * @access Private + */ +router.get('/', validateRequest({ query: listNotificationsSchema }), listNotifications); + +/** + * @route GET /api/v1/notifications/preferences + * @desc Read notification preferences (defaults created on first access) + * @access Private + */ +router.get('/preferences', getPreferences); + +/** + * @route PATCH /api/v1/notifications/preferences + * @desc Enable/disable push and choose which events to receive + * @access Private + */ +router.patch('/preferences', validateRequest({ body: updatePreferencesSchema }), updatePreferences); + +/** + * @route POST /api/v1/notifications/devices + * @desc Register or refresh a device push token + * @access Private + */ +router.post('/devices', validateRequest({ body: registerDeviceSchema }), registerDevice); + +/** + * @route DELETE /api/v1/notifications/devices + * @desc Remove a device push token + * @access Private + */ +router.delete('/devices', validateRequest({ body: unregisterDeviceSchema }), unregisterDevice); + +export default router; diff --git a/src/services/bulkDeliveryService.ts b/src/services/bulkDeliveryService.ts new file mode 100644 index 0000000..c230e90 --- /dev/null +++ b/src/services/bulkDeliveryService.ts @@ -0,0 +1,374 @@ +import httpStatus from 'http-status-codes'; +import { z } from 'zod'; +import env from '../config/env'; +import logger from '../config/logger'; +import { DeliveryStatus, IDelivery } from '../models/Delivery'; +import { DeliveryRepository, deliveryRepository } from '../repositories/DeliveryRepository'; +import { CsvParseError, CsvRow, parseCsv } from '../utils/csvParser'; +import { AppError } from '../utils/AppError'; +import { NotificationService, notificationService } from './notificationService'; + +/** + * Per-row schema for the bulk delivery CSV. + * + * Column names are lowercased by the parser, so they are declared lowercase + * here. Numeric columns arrive as strings and are coerced. + */ +const deliveryRowSchema = z.object({ + trackingnumber: z.string().trim().min(1, 'trackingNumber is required'), + customername: z.string().trim().min(1, 'customerName is required'), + customerphone: z.string().trim().min(1, 'customerPhone is required'), + customeremail: z + .string() + .trim() + .email('customerEmail must be a valid email address') + .optional() + .or(z.literal('').transform(() => undefined)), + pickupaddress: z.string().trim().min(1, 'pickupAddress is required'), + dropoffaddress: z.string().trim().min(1, 'dropoffAddress is required'), + packagedescription: z.string().trim().min(1, 'packageDescription is required'), + packageweight: z.coerce + .number({ message: 'packageWeight must be a number' }) + .positive('packageWeight must be greater than zero'), + deliveryfee: z.coerce + .number({ message: 'deliveryFee must be a number' }) + .nonnegative('deliveryFee cannot be negative'), + escrowamount: z.coerce + .number({ message: 'escrowAmount must be a number' }) + .nonnegative('escrowAmount cannot be negative'), + notes: z + .string() + .trim() + .optional() + .or(z.literal('').transform(() => undefined)), +}); + +/** Columns that must be present in the CSV header. */ +const REQUIRED_COLUMNS = [ + 'trackingnumber', + 'customername', + 'customerphone', + 'pickupaddress', + 'dropoffaddress', + 'packagedescription', + 'packageweight', + 'deliveryfee', + 'escrowamount', +] as const; + +/** A row that could not be imported, with the reason why. */ +export interface BulkRowError { + /** 1-based line number in the uploaded file. */ + line: number; + /** Tracking number from the row, when it could be read. */ + trackingNumber?: string; + /** Column the failure relates to, when attributable to one. */ + field?: string; + message: string; +} + +/** Outcome of a bulk import. */ +export interface BulkImportResult { + /** Data rows present in the file. */ + totalRows: number; + /** Rows written to the database. */ + successCount: number; + /** + * Distinct rows rejected, by validation or by the database. + * + * Counts rows, not errors: one row can appear several times in `errors` + * when more than one of its columns is invalid. + */ + failureCount: number; + /** Tracking numbers of the created deliveries. */ + created: string[]; + errors: BulkRowError[]; +} + +/** + * Batch-creates deliveries from an uploaded CSV. + * + * Partial success is the expected outcome, not an error: a single bad row must + * not discard the rest of a merchant's upload. Valid rows are inserted with an + * unordered `insertMany` so the driver continues past individual failures, and + * every rejected row is reported with its original line number. + */ +export class BulkDeliveryService { + constructor( + private readonly deliveries: DeliveryRepository = deliveryRepository, + private readonly notifications: NotificationService = notificationService, + ) {} + + /** + * Parse, validate and insert deliveries from CSV content. + * + * @param csvContent - Raw CSV text from the uploaded file. + * @param createdBy - Id of the authenticated user performing the import. + * @throws {AppError} 400 — the file itself is unusable (unparseable, empty, + * missing required columns, or over the row limit). + */ + async importFromCsv(csvContent: string, createdBy: string): Promise { + const parsed = this.parse(csvContent); + this.assertRequiredColumns(parsed.headers); + + const { valid, errors } = this.validateRows(parsed.rows); + + // Reject in-file duplicates before touching the database — two rows with + // the same tracking number would otherwise race, and which one won would + // depend on insertion order. + const deduped = this.rejectDuplicateTrackingNumbers(valid, errors); + + // One query resolves every collision with existing records, instead of an + // existence check per row. + const existing = await this.deliveries.findExistingTrackingNumbers( + deduped.map((row) => row.data.trackingnumber), + ); + + const insertable = deduped.filter((row) => { + if (existing.has(row.data.trackingnumber)) { + errors.push({ + line: row.lineNumber, + trackingNumber: row.data.trackingnumber, + field: 'trackingNumber', + message: 'A delivery with this tracking number already exists', + }); + return false; + } + return true; + }); + + const created = await this.insert(insertable, createdBy, errors); + + // A single row can raise several errors (one per invalid column), so the + // failure count is the number of distinct rejected lines — not the length + // of the error list. Otherwise a one-row file with three bad fields would + // report "3 of 1 rows failed". + const failedLines = new Set(errors.map((error) => error.line)); + + logger.info( + `[BulkDeliveryService] Import finished — user=${createdBy} ` + + `rows=${parsed.rows.length} created=${created.length} failed=${failedLines.size}`, + ); + + return { + totalRows: parsed.rows.length, + successCount: created.length, + failureCount: failedLines.size, + created: created.map((delivery) => delivery.trackingNumber ?? String(delivery._id)), + // Line order makes the report easy to reconcile against the source file. + errors: errors.sort((a, b) => a.line - b.line), + }; + } + + /** Parse CSV text, translating parser failures into 400s. */ + private parse(csvContent: string): ReturnType { + try { + return parseCsv(csvContent, env.BULK_UPLOAD_MAX_ROWS); + } catch (error) { + if (error instanceof CsvParseError) { + throw new AppError(error.message, httpStatus.BAD_REQUEST); + } + throw error; + } + } + + /** Fail fast when the header row is missing columns the import needs. */ + private assertRequiredColumns(headers: string[]): void { + const missing = REQUIRED_COLUMNS.filter((column) => !headers.includes(column)); + + if (missing.length > 0) { + throw new AppError( + `CSV is missing required column(s): ${missing.join(', ')}`, + httpStatus.BAD_REQUEST, + ); + } + } + + /** Validate every row, collecting failures rather than aborting. */ + private validateRows(rows: CsvRow[]): { + valid: Array<{ lineNumber: number; data: z.infer }>; + errors: BulkRowError[]; + } { + const valid: Array<{ lineNumber: number; data: z.infer }> = []; + const errors: BulkRowError[] = []; + + for (const row of rows) { + const result = deliveryRowSchema.safeParse(row.values); + + if (result.success) { + valid.push({ lineNumber: row.lineNumber, data: result.data }); + continue; + } + + // Report every field problem on the row so a merchant can fix the whole + // line in one pass instead of resubmitting to find the next error. + result.error.issues.forEach((issue) => { + errors.push({ + line: row.lineNumber, + trackingNumber: row.values.trackingnumber || undefined, + field: issue.path.join('.') || undefined, + message: issue.message, + }); + }); + } + + return { valid, errors }; + } + + /** Drop rows whose tracking number repeats earlier in the same file. */ + private rejectDuplicateTrackingNumbers( + rows: Array<{ lineNumber: number; data: z.infer }>, + errors: BulkRowError[], + ): Array<{ lineNumber: number; data: z.infer }> { + const seen = new Map(); + const unique: typeof rows = []; + + for (const row of rows) { + const trackingNumber = row.data.trackingnumber; + const firstSeen = seen.get(trackingNumber); + + if (firstSeen !== undefined) { + errors.push({ + line: row.lineNumber, + trackingNumber, + field: 'trackingNumber', + message: `Duplicate tracking number within the file (first seen on line ${firstSeen})`, + }); + continue; + } + + seen.set(trackingNumber, row.lineNumber); + unique.push(row); + } + + return unique; + } + + /** + * Insert the validated rows, tolerating per-document failures. + * + * `insertMany` with `ordered: false` continues past errors; on partial + * failure Mongoose raises a `MongoBulkWriteError` carrying both the inserted + * documents and the per-index write errors, which are mapped back to source + * lines here. + */ + private async insert( + rows: Array<{ lineNumber: number; data: z.infer }>, + createdBy: string, + errors: BulkRowError[], + ): Promise { + if (rows.length === 0) return []; + + const documents = rows.map((row) => this.toDeliveryDocument(row.data, createdBy)); + + try { + const inserted = await this.deliveries.createMany(documents, { ordered: false }); + await this.notifyCreated(inserted); + return inserted; + } catch (error) { + const inserted = this.extractInsertedDocuments(error); + const writeErrors = this.extractWriteErrors(error); + + if (writeErrors.length === 0) { + // Not a partial-failure bulk error — the whole write failed. + throw error; + } + + writeErrors.forEach(({ index, message }) => { + const row = rows[index]; + errors.push({ + line: row?.lineNumber ?? 0, + trackingNumber: row?.data.trackingnumber, + message: this.humaniseWriteError(message), + }); + }); + + await this.notifyCreated(inserted); + return inserted; + } + } + + /** + * Fire creation notifications for imported deliveries. + * + * Notification failures are swallowed by the notification service itself; + * this extra guard keeps an unexpected throw from failing an import whose + * rows are already committed. + */ + private async notifyCreated(deliveries: IDelivery[]): Promise { + if (deliveries.length === 0) return; + + await Promise.all( + deliveries.map(async (delivery) => { + try { + await this.notifications.notifyDeliveryTransition(delivery, DeliveryStatus.PENDING); + } catch (error) { + logger.error( + `[BulkDeliveryService] Notification failed for delivery=${String(delivery._id)}`, + error, + ); + } + }), + ); + } + + /** Map a validated CSV row onto the Delivery document shape. */ + private toDeliveryDocument( + row: z.infer, + createdBy: string, + ): Partial { + return { + trackingNumber: row.trackingnumber, + userId: createdBy, + status: DeliveryStatus.PENDING, + customer: { + name: row.customername, + phone: row.customerphone, + ...(row.customeremail ? { email: row.customeremail } : {}), + }, + pickup: { address: row.pickupaddress }, + dropoff: { address: row.dropoffaddress }, + package: { + description: row.packagedescription, + weight: row.packageweight, + }, + deliveryFee: row.deliveryfee, + escrowAmount: row.escrowamount, + ...(row.notes ? { notes: row.notes } : {}), + } as Partial; + } + + /** Pull successfully inserted documents out of a partial bulk-write failure. */ + private extractInsertedDocuments(error: unknown): IDelivery[] { + const candidate = error as { insertedDocs?: IDelivery[] }; + return Array.isArray(candidate?.insertedDocs) ? candidate.insertedDocs : []; + } + + /** Pull per-document write errors out of a partial bulk-write failure. */ + private extractWriteErrors(error: unknown): Array<{ index: number; message: string }> { + const candidate = error as { + writeErrors?: Array<{ + index?: number; + err?: { index?: number; errmsg?: string }; + errmsg?: string; + }>; + }; + + if (!Array.isArray(candidate?.writeErrors)) return []; + + return candidate.writeErrors.map((writeError) => ({ + index: writeError.index ?? writeError.err?.index ?? 0, + message: writeError.errmsg ?? writeError.err?.errmsg ?? 'Database write failed', + })); + } + + /** Turn a raw driver error message into something a merchant can act on. */ + private humaniseWriteError(message: string): string { + if (message.includes('E11000')) { + return 'A delivery with this tracking number already exists'; + } + return message; + } +} + +export const bulkDeliveryService = new BulkDeliveryService(); diff --git a/src/services/delivery.service.ts b/src/services/delivery.service.ts index 40c3995..e5b8259 100644 --- a/src/services/delivery.service.ts +++ b/src/services/delivery.service.ts @@ -4,6 +4,8 @@ import Delivery, { IDelivery, DeliveryStatus, ILocation, IPackage } from '../mod import Escrow, { EscrowLockStatus } from '../models/Escrow'; import { AppError } from '../utils/AppError'; import logger from '../config/logger'; +import { deliveryRepository } from '../repositories/DeliveryRepository'; +import { notificationService } from './notificationService'; export interface CreateDeliveryInput { trackingNumber: string; @@ -52,6 +54,26 @@ export interface PaginatedResult { totalPages: number; } +/** + * Legal delivery status transitions. + * + * Encoded as a map rather than checked inline so the state machine is + * inspectable in one place and covered directly by tests. Terminal states map + * to an empty list: nothing follows a completed or cancelled delivery. + */ +const ALLOWED_TRANSITIONS: Record = { + [DeliveryStatus.PENDING]: [ + DeliveryStatus.FUNDED, + DeliveryStatus.ASSIGNED, + DeliveryStatus.CANCELLED, + ], + [DeliveryStatus.FUNDED]: [DeliveryStatus.ASSIGNED, DeliveryStatus.CANCELLED], + [DeliveryStatus.ASSIGNED]: [DeliveryStatus.IN_PROGRESS, DeliveryStatus.CANCELLED], + [DeliveryStatus.IN_PROGRESS]: [DeliveryStatus.COMPLETED, DeliveryStatus.CANCELLED], + [DeliveryStatus.COMPLETED]: [], + [DeliveryStatus.CANCELLED]: [], +}; + export class DeliveryService { async create(input: CreateDeliveryInput): Promise { const existing = await Delivery.findOne({ @@ -189,6 +211,72 @@ export class DeliveryService { }; } + /** + * Advance a delivery to a new status and notify the parties involved. + * + * The transition is applied with a conditional update that asserts the + * current status, so two concurrent requests cannot both advance the same + * delivery — the loser matches no document and is rejected with a 409. + * + * Push notifications are dispatched after the write commits, and never + * affect the outcome: a delivery that has moved to `completed` stays + * completed even if the push provider is unreachable. + * + * @throws {AppError} 400 — invalid delivery id, or an illegal transition. + * @throws {AppError} 404 — delivery not found. + * @throws {AppError} 409 — the delivery changed status concurrently. + */ + async updateStatus(id: string, nextStatus: DeliveryStatus): Promise { + if (!Types.ObjectId.isValid(id)) { + throw new AppError('Invalid delivery ID', httpStatus.BAD_REQUEST); + } + + const current = await deliveryRepository.findById(id); + if (!current) { + throw new AppError('Delivery not found', httpStatus.NOT_FOUND); + } + + if (current.status === nextStatus) { + throw new AppError( + `Delivery is already in status '${nextStatus}'.`, + httpStatus.CONFLICT, + ); + } + + const permitted = ALLOWED_TRANSITIONS[current.status] ?? []; + if (!permitted.includes(nextStatus)) { + throw new AppError( + `Cannot transition a delivery from '${current.status}' to '${nextStatus}'.` + + (permitted.length > 0 + ? ` Allowed next states: ${permitted.join(', ')}.` + : ' This is a terminal state.'), + httpStatus.BAD_REQUEST, + ); + } + + const updated = await deliveryRepository.transitionStatus(id, current.status, nextStatus); + + if (!updated) { + // The conditional update matched nothing, so the status changed between + // the read above and the write — a concurrent transition won. + throw new AppError( + 'Delivery status changed concurrently. Retry with the current state.', + httpStatus.CONFLICT, + ); + } + + logger.info( + `[DeliveryService] Status transition — delivery=${id} ` + + `${current.status} -> ${nextStatus}`, + ); + + // Fire-and-forget by design: notification failures are recorded inside the + // notification service and must not roll back a committed transition. + await notificationService.notifyDeliveryTransition(updated, nextStatus); + + return updated; + } + /** * Assign a driver to a delivery, **only if the Soroban escrow contract for * that delivery is fully initialised (locked)**. diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts new file mode 100644 index 0000000..dad3ba8 --- /dev/null +++ b/src/services/notificationService.ts @@ -0,0 +1,342 @@ +import httpStatus from 'http-status-codes'; +import { Types } from 'mongoose'; +import logger from '../config/logger'; +import { AppError } from '../utils/AppError'; +import { DeliveryStatus, IDelivery } from '../models/Delivery'; +import { + IDeviceToken, + INotificationPreference, + NotificationChannel, + NotificationEvent, +} from '../models/NotificationPreference'; +import { INotification, NotificationStatus } from '../models/Notification'; +import { + NotificationPreferenceRepository, + notificationPreferenceRepository as defaultPreferenceRepository, +} from '../repositories/NotificationPreferenceRepository'; +import { + NotificationRepository, + notificationRepository as defaultNotificationRepository, +} from '../repositories/NotificationRepository'; +import { Page } from '../repositories/types'; +import { IPushProvider, PushResult } from './push/pushProvider'; +import { fcmProvider } from './push/fcmProvider'; + +/** + * Maps a delivery status onto the notification event it raises. + * + * Statuses absent from this map are internal bookkeeping and never notify — + * `FUNDED`, for instance, is meaningful to the escrow flow but not to the + * recipient waiting on a package. + */ +const STATUS_EVENTS: Partial> = { + [DeliveryStatus.PENDING]: NotificationEvent.DELIVERY_PENDING, + [DeliveryStatus.ASSIGNED]: NotificationEvent.DELIVERY_ASSIGNED, + [DeliveryStatus.IN_PROGRESS]: NotificationEvent.DELIVERY_IN_PROGRESS, + [DeliveryStatus.COMPLETED]: NotificationEvent.DELIVERY_COMPLETED, + [DeliveryStatus.CANCELLED]: NotificationEvent.DELIVERY_CANCELLED, +}; + +/** Copy shown in the push notification for each event. */ +const EVENT_COPY: Record string }> = { + [NotificationEvent.DELIVERY_PENDING]: { + title: 'Delivery created', + body: (ref) => `Your delivery ${ref} has been created and is awaiting a driver.`, + }, + [NotificationEvent.DELIVERY_ASSIGNED]: { + title: 'Driver assigned', + body: (ref) => `A driver has been assigned to delivery ${ref}.`, + }, + [NotificationEvent.DELIVERY_IN_PROGRESS]: { + title: 'Delivery in progress', + body: (ref) => `Delivery ${ref} is on its way.`, + }, + [NotificationEvent.DELIVERY_COMPLETED]: { + title: 'Delivery completed', + body: (ref) => `Delivery ${ref} has been completed.`, + }, + [NotificationEvent.DELIVERY_CANCELLED]: { + title: 'Delivery cancelled', + body: (ref) => `Delivery ${ref} has been cancelled.`, + }, +}; + +/** Input accepted by {@link NotificationService.registerDevice}. */ +export interface RegisterDeviceInput { + userId: string; + token: string; + platform: IDeviceToken['platform']; +} + +/** Input accepted by {@link NotificationService.updatePreferences}. */ +export interface UpdatePreferencesInput { + pushEnabled?: boolean; + enabledEvents?: NotificationEvent[]; +} + +/** + * Orchestrates push notifications for delivery lifecycle transitions. + * + * Every send attempt is persisted — including opt-out skips and provider + * failures — so the notification history is answerable from the database. + * + * A failure to notify never propagates to the caller: delivery status + * transitions must not roll back because a push provider was unreachable. + * {@link notifyDeliveryTransition} therefore resolves to `null` on failure + * rather than throwing, and records the reason. + */ +export class NotificationService { + constructor( + private readonly preferenceRepository: NotificationPreferenceRepository = defaultPreferenceRepository, + private readonly notificationRepository: NotificationRepository = defaultNotificationRepository, + private readonly pushProvider: IPushProvider = fcmProvider, + ) {} + + /** + * Notify the parties on a delivery that its status changed. + * + * Both the sender and the assigned driver are notified when identifiable. + * + * @returns The recorded notifications, or an empty array when the status has + * no user-facing event. + */ + async notifyDeliveryTransition( + delivery: IDelivery, + status: DeliveryStatus, + ): Promise { + const event = STATUS_EVENTS[status]; + if (!event) { + logger.debug(`[NotificationService] Status '${status}' has no notifiable event; skipping`); + return []; + } + + const recipients = this.resolveRecipients(delivery); + if (recipients.length === 0) { + logger.warn( + `[NotificationService] No identifiable recipients for delivery=${String(delivery._id)}`, + ); + return []; + } + + const reference = delivery.trackingNumber ?? String(delivery._id); + const copy = EVENT_COPY[event]; + + const results = await Promise.all( + recipients.map((userId) => + this.dispatch({ + userId, + event, + title: copy.title, + body: copy.body(reference), + data: { + deliveryId: String(delivery._id), + status, + event, + ...(delivery.trackingNumber ? { trackingNumber: delivery.trackingNumber } : {}), + }, + deliveryId: delivery._id as Types.ObjectId, + }), + ), + ); + + return results.filter((record): record is INotification => record !== null); + } + + /** + * Send one notification to one user and record the outcome. + * + * Errors are caught and recorded rather than rethrown — see the class note + * on why a push failure must not fail the surrounding operation. + */ + private async dispatch(input: { + userId: Types.ObjectId | string; + event: NotificationEvent; + title: string; + body: string; + data: Record; + deliveryId?: Types.ObjectId; + }): Promise { + try { + const preference = await this.preferenceRepository.findOrCreateByUserId(input.userId); + const suppression = this.suppressionReason(preference, input.event); + + if (suppression) { + return this.record(input, NotificationStatus.SKIPPED, { + acceptedCount: 0, + rejectedCount: 0, + invalidTokens: [], + failureReason: suppression, + }); + } + + const tokens = preference.devices.map((device) => device.token); + const result = await this.pushProvider.send({ + tokens, + title: input.title, + body: input.body, + data: input.data, + }); + + if (result.invalidTokens.length > 0) { + const pruned = await this.preferenceRepository.pruneTokens(result.invalidTokens); + logger.info(`[NotificationService] Pruned ${pruned} invalid device token(s)`); + } + + const status = result.acceptedCount > 0 ? NotificationStatus.SENT : NotificationStatus.FAILED; + + return this.record(input, status, result); + } catch (error) { + const reason = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[NotificationService] Dispatch failed — user=${String(input.userId)} ` + + `event=${input.event}: ${reason}`, + ); + + // Best-effort audit record; if this write also fails there is nothing + // further to do without failing the caller's delivery transition. + try { + return await this.record(input, NotificationStatus.FAILED, { + acceptedCount: 0, + rejectedCount: 0, + invalidTokens: [], + failureReason: reason, + }); + } catch { + return null; + } + } + } + + /** + * Why this notification should not be sent, or `undefined` to proceed. + */ + private suppressionReason( + preference: INotificationPreference, + event: NotificationEvent, + ): string | undefined { + if (!preference.pushEnabled) return 'User has disabled push notifications'; + if (!preference.enabledEvents.includes(event)) { + return `User has opted out of '${event}'`; + } + if (preference.devices.length === 0) return 'User has no registered devices'; + return undefined; + } + + /** Persist the outcome of a send attempt. */ + private async record( + input: { + userId: Types.ObjectId | string; + event: NotificationEvent; + title: string; + body: string; + data: Record; + deliveryId?: Types.ObjectId; + }, + status: NotificationStatus, + result: PushResult, + ): Promise { + return this.notificationRepository.create({ + user: new Types.ObjectId(String(input.userId)), + event: input.event, + channel: NotificationChannel.PUSH, + title: input.title, + body: input.body, + data: input.data, + status, + acceptedCount: result.acceptedCount, + rejectedCount: result.rejectedCount, + failureReason: result.failureReason, + delivery: input.deliveryId, + } as Partial); + } + + /** + * Collect the users who should hear about a delivery transition. + * + * `sender` is an ObjectId reference; `driverId` and `userId` are free-form + * strings on the schema, so only well-formed ObjectIds are usable as + * notification targets. Duplicates are removed so a user who is both sender + * and driver is notified once. + */ + private resolveRecipients(delivery: IDelivery): string[] { + const candidates = [delivery.sender, delivery.userId, delivery.driverId] + .filter((value): value is NonNullable => Boolean(value)) + .map((value) => String(value)) + .filter((value) => Types.ObjectId.isValid(value)); + + return [...new Set(candidates)]; + } + + // ── User-facing preference management ───────────────────────────────────── + + /** Return a user's preferences, initialising defaults on first access. */ + async getPreferences(userId: string): Promise { + this.assertValidUserId(userId); + return this.preferenceRepository.findOrCreateByUserId(userId); + } + + /** Apply a partial update to a user's notification preferences. */ + async updatePreferences( + userId: string, + input: UpdatePreferencesInput, + ): Promise { + this.assertValidUserId(userId); + + const preference = await this.preferenceRepository.updateForUser(userId, input); + if (!preference) { + throw new AppError('Notification preferences not found', httpStatus.NOT_FOUND); + } + + logger.info(`[NotificationService] Preferences updated for user=${userId}`); + return preference; + } + + /** Register or refresh a device push token for a user. */ + async registerDevice(input: RegisterDeviceInput): Promise { + this.assertValidUserId(input.userId); + + const preference = await this.preferenceRepository.registerDevice(input.userId, { + token: input.token, + platform: input.platform, + }); + + logger.info( + `[NotificationService] Device registered — user=${input.userId} ` + + `platform=${input.platform}`, + ); + return preference; + } + + /** Remove a device token, e.g. on logout. */ + async unregisterDevice(userId: string, token: string): Promise { + this.assertValidUserId(userId); + + const preference = await this.preferenceRepository.removeDevice(userId, token); + if (!preference) { + throw new AppError('Notification preferences not found', httpStatus.NOT_FOUND); + } + return preference; + } + + /** One page of a user's notification history. */ + async listForUser(userId: string, page = 1, limit = 20): Promise> { + this.assertValidUserId(userId); + return this.notificationRepository.listForUser(userId, page, limit); + } + + /** Whether the configured push provider can currently send. */ + getProviderStatus(): { provider: string; configured: boolean } { + return { + provider: this.pushProvider.name, + configured: this.pushProvider.isConfigured(), + }; + } + + private assertValidUserId(userId: string): void { + if (!Types.ObjectId.isValid(userId)) { + throw new AppError('Invalid user ID', httpStatus.BAD_REQUEST); + } + } +} + +export const notificationService = new NotificationService(); diff --git a/src/services/push/fcmProvider.ts b/src/services/push/fcmProvider.ts new file mode 100644 index 0000000..5f98831 --- /dev/null +++ b/src/services/push/fcmProvider.ts @@ -0,0 +1,252 @@ +import crypto from 'crypto'; +import axios, { AxiosError } from 'axios'; +import logger from '../../config/logger'; +import env from '../../config/env'; +import { IPushProvider, PushMessage, PushResult } from './pushProvider'; + +/** Google OAuth2 token endpoint used to exchange a signed JWT for an access token. */ +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +/** Scope required to call the FCM HTTP v1 send endpoint. */ +const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'; +/** Refresh the access token this many seconds before it actually expires. */ +const TOKEN_REFRESH_SKEW_SECONDS = 60; + +/** + * FCM error codes that mean the token will never be valid again. + * + * Anything else (quota, transient server errors) leaves the token in place so + * a later send can retry it. + */ +const PERMANENT_TOKEN_ERRORS = new Set(['UNREGISTERED', 'INVALID_ARGUMENT', 'SENDER_ID_MISMATCH']); + +interface CachedAccessToken { + token: string; + /** Epoch milliseconds after which the token must be refreshed. */ + expiresAt: number; +} + +/** + * Firebase Cloud Messaging transport, built on the HTTP v1 API. + * + * Authentication follows the service-account flow: a JWT signed with the + * service account's private key is exchanged for a short-lived OAuth2 access + * token, which is cached until shortly before it expires. + * + * FCM v1 sends to one token per request, so a multi-device send fans out and + * the per-token outcomes are aggregated. Failures are isolated per token — one + * uninstalled device does not fail the whole notification. + */ +export class FcmProvider implements IPushProvider { + public readonly name = 'fcm'; + + private cachedToken: CachedAccessToken | null = null; + /** In-flight token request, shared so concurrent sends fetch once. */ + private pendingToken: Promise | null = null; + + constructor( + private readonly projectId: string = env.FCM_PROJECT_ID, + private readonly clientEmail: string = env.FCM_CLIENT_EMAIL, + private readonly privateKey: string = env.FCM_PRIVATE_KEY, + ) {} + + /** + * True only when all three service-account fields are present. + * + * Credentials are optional in development and test, so the service must be + * able to ask before it tries to send. + */ + isConfigured(): boolean { + return Boolean(this.projectId && this.clientEmail && this.privateKey); + } + + async send(message: PushMessage): Promise { + if (!this.isConfigured()) { + return { + acceptedCount: 0, + rejectedCount: message.tokens.length, + invalidTokens: [], + failureReason: 'FCM credentials are not configured', + }; + } + + if (message.tokens.length === 0) { + return { acceptedCount: 0, rejectedCount: 0, invalidTokens: [] }; + } + + let accessToken: string; + try { + accessToken = await this.getAccessToken(); + } catch (error) { + const reason = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[FcmProvider] Failed to obtain access token: ${reason}`); + return { + acceptedCount: 0, + rejectedCount: message.tokens.length, + invalidTokens: [], + failureReason: `FCM authentication failed: ${reason}`, + }; + } + + const endpoint = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`; + + const outcomes = await Promise.all( + message.tokens.map((token) => this.sendToToken(endpoint, accessToken, token, message)), + ); + + const invalidTokens = outcomes + .filter((outcome) => outcome.permanentFailure) + .map((outcome) => outcome.token); + + const acceptedCount = outcomes.filter((outcome) => outcome.accepted).length; + + return { + acceptedCount, + rejectedCount: outcomes.length - acceptedCount, + invalidTokens, + }; + } + + /** Send to a single token, classifying any failure as permanent or transient. */ + private async sendToToken( + endpoint: string, + accessToken: string, + token: string, + message: PushMessage, + ): Promise<{ token: string; accepted: boolean; permanentFailure: boolean }> { + try { + await axios.post( + endpoint, + { + message: { + token, + notification: { title: message.title, body: message.body }, + data: message.data, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + timeout: env.FCM_REQUEST_TIMEOUT_MS, + }, + ); + + return { token, accepted: true, permanentFailure: false }; + } catch (error) { + const errorCode = this.extractErrorCode(error); + const permanentFailure = errorCode !== undefined && PERMANENT_TOKEN_ERRORS.has(errorCode); + + logger.warn( + `[FcmProvider] Send rejected — code=${errorCode ?? 'unknown'} ` + + `permanent=${permanentFailure} token=${this.maskToken(token)}`, + ); + + return { token, accepted: false, permanentFailure }; + } + } + + /** + * Pull FCM's machine-readable error code out of an error response. + * + * The v1 API nests it under `error.details[].errorCode`, falling back to the + * top-level `error.status` for transport-level failures. + */ + private extractErrorCode(error: unknown): string | undefined { + if (!axios.isAxiosError(error)) return undefined; + + const data = ( + error as AxiosError<{ + error?: { + status?: string; + details?: Array<{ errorCode?: string }>; + }; + }> + ).response?.data; + + const detailCode = data?.error?.details?.find((detail) => detail.errorCode)?.errorCode; + return detailCode ?? data?.error?.status; + } + + /** Truncate a registration token so logs never carry a usable credential. */ + private maskToken(token: string): string { + return token.length <= 12 ? '***' : `${token.slice(0, 6)}...${token.slice(-4)}`; + } + + /** + * Return a valid OAuth2 access token, minting one if the cache is cold. + * + * Concurrent callers share a single in-flight request rather than each + * hitting Google's token endpoint. + */ + private async getAccessToken(): Promise { + if (this.cachedToken && Date.now() < this.cachedToken.expiresAt) { + return this.cachedToken.token; + } + + if (this.pendingToken) return this.pendingToken; + + this.pendingToken = this.requestAccessToken().finally(() => { + this.pendingToken = null; + }); + + return this.pendingToken; + } + + /** Exchange a signed service-account JWT for an OAuth2 access token. */ + private async requestAccessToken(): Promise { + const assertion = this.buildSignedJwt(); + + const response = await axios.post<{ access_token: string; expires_in: number }>( + GOOGLE_TOKEN_URL, + new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }).toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + timeout: env.FCM_REQUEST_TIMEOUT_MS, + }, + ); + + const { access_token: token, expires_in: expiresIn } = response.data; + + this.cachedToken = { + token, + expiresAt: Date.now() + (expiresIn - TOKEN_REFRESH_SKEW_SECONDS) * 1000, + }; + + return token; + } + + /** + * Build the RS256-signed JWT that authenticates the service account. + * + * Private keys stored in `.env` carry literal `\n` sequences rather than real + * newlines, so they are normalised before PEM parsing. + */ + private buildSignedJwt(): string { + const issuedAt = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', typ: 'JWT' }; + const claims = { + iss: this.clientEmail, + scope: FCM_SCOPE, + aud: GOOGLE_TOKEN_URL, + iat: issuedAt, + exp: issuedAt + 3600, + }; + + const encode = (value: object): string => + Buffer.from(JSON.stringify(value)).toString('base64url'); + + const signingInput = `${encode(header)}.${encode(claims)}`; + const signature = crypto + .createSign('RSA-SHA256') + .update(signingInput) + .sign(this.privateKey.replace(/\\n/g, '\n'), 'base64url'); + + return `${signingInput}.${signature}`; + } +} + +export const fcmProvider = new FcmProvider(); diff --git a/src/services/push/pushProvider.ts b/src/services/push/pushProvider.ts new file mode 100644 index 0000000..0a08b92 --- /dev/null +++ b/src/services/push/pushProvider.ts @@ -0,0 +1,47 @@ +/** + * Provider-agnostic push transport contract. + * + * The notification service depends only on this interface, so swapping FCM for + * OneSignal (or adding a second provider) is a configuration change rather + * than a rewrite of the business logic. + */ + +/** A push message targeted at one or more device tokens. */ +export interface PushMessage { + tokens: string[]; + title: string; + body: string; + /** + * Key/value payload delivered alongside the notification. + * + * FCM requires every data value to be a string, so the type is narrowed here + * rather than at the call site. + */ + data: Record; +} + +/** Outcome of a single push send, aggregated across the target tokens. */ +export interface PushResult { + acceptedCount: number; + rejectedCount: number; + /** + * Tokens the provider reported as permanently invalid (unregistered or + * malformed). These are pruned from the database by the caller. + */ + invalidTokens: string[]; + /** Present when the send failed outright rather than per-token. */ + failureReason?: string; +} + +export interface IPushProvider { + /** Human-readable provider name, used in logs and health output. */ + readonly name: string; + /** + * Whether the provider holds the credentials it needs to send. + * + * The service checks this before attempting a send so an unconfigured + * environment records an explicit skip instead of a misleading failure. + */ + isConfigured(): boolean; + send(message: PushMessage): Promise; +} diff --git a/src/sockets/chatMessage.service.ts b/src/sockets/chatMessage.service.ts new file mode 100644 index 0000000..4a04574 --- /dev/null +++ b/src/sockets/chatMessage.service.ts @@ -0,0 +1,110 @@ +import { IChatMessage } from '../models/ChatMessage'; +import { + ChatMessageRepository, + chatMessageRepository, +} from '../repositories/ChatMessageRepository'; + +/** Number of messages replayed to a client when it connects. */ +export const RECENT_MESSAGE_LIMIT = 10; + +/** Maximum accepted length of a single chat message. */ +export const MAX_MESSAGE_LENGTH = 2000; + +/** An incoming message payload, before validation. */ +export interface IncomingChatMessage { + content?: unknown; + sender?: unknown; +} + +/** A validated, persistable message. */ +export interface ValidatedChatMessage { + content: string; + sender?: string; +} + +/** Raised when an incoming payload fails validation. */ +export class InvalidChatMessageError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidChatMessageError'; + } +} + +/** + * Chat business logic, isolated from Socket.IO. + * + * Nothing here touches a socket, a namespace, or an event name: the class + * takes plain values and returns plain values, which is what makes it + * testable without standing up a WebSocket server. The socket handler is + * responsible for transport concerns (emitting, error responses, logging). + */ +export class ChatMessageService { + constructor(private readonly messages: ChatMessageRepository = chatMessageRepository) {} + + /** + * The transcript replayed to a newly connected client, oldest first. + * + * The query returns newest-first to use the descending `createdAt` index; + * the reversal to reading order happens here rather than in the handler so + * every caller gets the same ordering. + */ + async getRecentTranscript(limit: number = RECENT_MESSAGE_LIMIT): Promise { + const recent = await this.messages.findRecent(limit); + return recent.reverse(); + } + + /** + * Validate an untrusted payload from a client. + * + * Socket payloads bypass Express middleware entirely, so this is the only + * validation boundary for realtime input — it must not assume any prior + * checking. + * + * @throws {InvalidChatMessageError} If the payload is unusable. + */ + validate(payload: IncomingChatMessage): ValidatedChatMessage { + if (!payload || typeof payload !== 'object') { + throw new InvalidChatMessageError('Message payload must be an object'); + } + + if (typeof payload.content !== 'string') { + throw new InvalidChatMessageError('Message content is required'); + } + + const content = payload.content.trim(); + + if (content === '') { + throw new InvalidChatMessageError('Message content cannot be empty'); + } + + if (content.length > MAX_MESSAGE_LENGTH) { + throw new InvalidChatMessageError( + `Message content cannot exceed ${MAX_MESSAGE_LENGTH} characters`, + ); + } + + if (payload.sender !== undefined && typeof payload.sender !== 'string') { + throw new InvalidChatMessageError('Message sender must be a string'); + } + + const sender = typeof payload.sender === 'string' ? payload.sender.trim() : undefined; + + return { + content, + ...(sender ? { sender } : {}), + }; + } + + /** + * Validate and persist an incoming message. + * + * @returns The stored message, ready to broadcast. + * @throws {InvalidChatMessageError} If the payload is unusable. + */ + async createMessage(payload: IncomingChatMessage): Promise { + const validated = this.validate(payload); + return this.messages.create(validated as Partial); + } +} + +export const chatMessageService = new ChatMessageService(); diff --git a/src/sockets/socketController.ts b/src/sockets/socketController.ts index 817467e..17c3d28 100644 --- a/src/sockets/socketController.ts +++ b/src/sockets/socketController.ts @@ -2,18 +2,23 @@ import { Namespace, Socket } from 'socket.io'; import socketService from './socketService'; import logger from '../config/logger'; +/** + * Wire a connected socket to its handlers. + * + * This layer stays deliberately thin: it registers listeners and forwards to + * the service, which owns validation, persistence and client error responses. + * Keeping the two apart is what allows the chat logic to be tested without a + * running Socket.IO server. + */ const registerSocketHandlers = (socket: Socket, nsp: Namespace): void => { logger.info(`Socket connected: ${socket.id} to namespace ${nsp.name}`); - socketService.handleConnection(socket, nsp); + void socketService.handleConnection(socket, nsp); - socket.on('message', async (payload) => { - try { - await socketService.handleIncomingMessage(nsp, payload); - } catch (err) { - logger.error('Socket message handler error', err); - socket.emit('error', { message: 'Failed to handle message' }); - } + socket.on('message', (payload) => { + // The service reports failures to the originating socket itself, so no + // rejection can escape here. + void socketService.handleIncomingMessage(nsp, payload, socket); }); socket.on('disconnect', (reason) => { diff --git a/src/sockets/socketService.ts b/src/sockets/socketService.ts index af39ac1..8e5153a 100644 --- a/src/sockets/socketService.ts +++ b/src/sockets/socketService.ts @@ -1,42 +1,73 @@ -import ChatMessage, { IChatMessage } from '../models/ChatMessage'; -import logger from '../config/logger'; import { Namespace, Socket } from 'socket.io'; +import { IChatMessage } from '../models/ChatMessage'; +import logger from '../config/logger'; +import { + ChatMessageService, + IncomingChatMessage, + InvalidChatMessageError, + chatMessageService, +} from './chatMessage.service'; -class SocketService { - public async getRecentMessages(limit = 10): Promise { - return ChatMessage.find() - .sort({ createdAt: -1 }) - .limit(limit) - .lean() - .exec() as unknown as IChatMessage[]; - } +/** + * Transport adapter for chat sockets. + * + * Business logic lives in {@link ChatMessageService}; this class only + * translates between that service and Socket.IO — emitting results, turning + * validation failures into client-visible errors, and logging. + * + * The split keeps the logic unit-testable without a WebSocket server, and + * keeps transport concerns out of the service. + */ +export class SocketService { + constructor(private readonly chat: ChatMessageService = chatMessageService) {} - public async saveMessage(payload: { content: string; sender?: string }): Promise { - return ChatMessage.create({ - content: payload.content, - sender: payload.sender, - }) as unknown as IChatMessage; + /** Recent messages in reading order. Retained for existing callers. */ + public async getRecentMessages(limit?: number): Promise { + return this.chat.getRecentTranscript(limit); } + /** + * Replay the recent transcript to a client that has just connected. + * + * A read failure is reported to that client alone and never rethrown — one + * client's failed backlog must not tear down the connection handler. + */ public async handleConnection(socket: Socket, _nsp: Namespace): Promise { try { - const recent = await this.getRecentMessages(); - socket.emit('recentMessages', recent.reverse()); + const recent = await this.chat.getRecentTranscript(); + socket.emit('recentMessages', recent); } catch (error) { - logger.error('Error fetching recent messages', error); + logger.error('[SocketService] Failed to load recent messages', error); socket.emit('error', { message: 'Failed to load recent messages' }); } } + /** + * Persist an incoming message and broadcast it to the namespace. + * + * Invalid payloads are answered on the originating socket when one is + * supplied, so a client learns why its message was rejected instead of + * failing silently. + * + * @param socket - Originating socket, used to deliver rejection notices. + */ public async handleIncomingMessage( nsp: Namespace, - payload: { content: string; sender?: string }, + payload: IncomingChatMessage, + socket?: Socket, ): Promise { try { - const doc = await this.saveMessage(payload); - nsp.emit('message', doc); + const message = await this.chat.createMessage(payload); + nsp.emit('message', message); } catch (error) { - logger.error('Error saving message', error); + if (error instanceof InvalidChatMessageError) { + logger.warn(`[SocketService] Rejected invalid message: ${error.message}`); + socket?.emit('error', { message: error.message }); + return; + } + + logger.error('[SocketService] Failed to save message', error); + socket?.emit('error', { message: 'Failed to send message' }); } } } diff --git a/src/utils/csvParser.ts b/src/utils/csvParser.ts new file mode 100644 index 0000000..33b4de1 --- /dev/null +++ b/src/utils/csvParser.ts @@ -0,0 +1,192 @@ +/** + * Minimal RFC 4180 CSV parser. + * + * Written in-repo rather than pulled from a package because the bulk import + * needs exact control over two things a general-purpose parser does not give + * for free: per-row error reporting keyed to the original 1-based line number, + * and a hard row cap enforced during parsing so an oversized upload is + * rejected before it is fully materialised in memory. + * + * Supports quoted fields, escaped quotes (`""`), embedded commas and newlines + * inside quotes, and CRLF or LF line endings. + */ + +/** A parsed CSV row, keyed by header name, with its source line number. */ +export interface CsvRow { + /** 1-based line number in the original file, counting the header row. */ + lineNumber: number; + values: Record; +} + +export interface CsvParseResult { + headers: string[]; + rows: CsvRow[]; +} + +/** Raised when the input cannot be parsed as CSV at all. */ +export class CsvParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'CsvParseError'; + } +} + +/** + * Split raw CSV text into records of raw string fields. + * + * Tracks the line number each record started on so downstream errors can point + * at the right line even when a quoted field spans several physical lines. + */ +function tokenize(input: string): Array<{ lineNumber: number; fields: string[] }> { + const records: Array<{ lineNumber: number; fields: string[] }> = []; + + let field = ''; + let fields: string[] = []; + let inQuotes = false; + let line = 1; + let recordStartLine = 1; + /** False until the current record has at least one character or delimiter. */ + let recordHasContent = false; + + const endField = (): void => { + fields.push(field); + field = ''; + }; + + const endRecord = (): void => { + endField(); + // Skip blank lines: a single empty field and no content seen. + if (recordHasContent) { + records.push({ lineNumber: recordStartLine, fields }); + } + fields = []; + recordHasContent = false; + recordStartLine = line; + }; + + for (let i = 0; i < input.length; i += 1) { + const char = input[i]; + + if (inQuotes) { + if (char === '"') { + // A doubled quote inside a quoted field is a literal quote. + if (input[i + 1] === '"') { + field += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + if (char === '\n') line += 1; + field += char; + } + continue; + } + + switch (char) { + case '"': + inQuotes = true; + recordHasContent = true; + break; + + case ',': + recordHasContent = true; + endField(); + break; + + case '\r': + // Swallow CR; the following LF terminates the record. + break; + + case '\n': + line += 1; + endRecord(); + recordStartLine = line; + break; + + default: + if (char.trim() !== '') recordHasContent = true; + field += char; + } + } + + if (inQuotes) { + throw new CsvParseError('Malformed CSV: unterminated quoted field'); + } + + // Flush the final record when the file does not end with a newline. + if (recordHasContent || fields.length > 0) { + endRecord(); + } + + return records; +} + +/** Normalise a header cell: trimmed and lowercased for case-insensitive matching. */ +function normaliseHeader(header: string): string { + return header.trim().toLowerCase(); +} + +/** + * Parse CSV text into header-keyed rows. + * + * @param input - Raw CSV text (a UTF-8 BOM, if present, is stripped). + * @param maxRows - Maximum data rows to accept, excluding the header. + * @throws {CsvParseError} If the file is empty, has no header, has duplicate + * headers, or exceeds `maxRows`. + */ +export function parseCsv(input: string, maxRows: number): CsvParseResult { + // Spreadsheet exports commonly prefix the file with a UTF-8 BOM, which + // would otherwise become part of the first header name. Written as an + // escape rather than a literal so the source stays free of invisible + // characters. + const text = input.replace(/^\uFEFF/, ''); + + if (text.trim() === '') { + throw new CsvParseError('CSV file is empty'); + } + + const records = tokenize(text); + if (records.length === 0) { + throw new CsvParseError('CSV file is empty'); + } + + const headers = records[0].fields.map(normaliseHeader); + + if (headers.some((header) => header === '')) { + throw new CsvParseError('CSV header row contains an empty column name'); + } + + const duplicates = headers.filter((header, index) => headers.indexOf(header) !== index); + if (duplicates.length > 0) { + throw new CsvParseError( + `CSV header row contains duplicate column(s): ${[...new Set(duplicates)].join(', ')}`, + ); + } + + const dataRecords = records.slice(1); + + if (dataRecords.length === 0) { + throw new CsvParseError('CSV file contains a header row but no data rows'); + } + + if (dataRecords.length > maxRows) { + throw new CsvParseError( + `CSV file contains ${dataRecords.length} rows, which exceeds the limit of ${maxRows}`, + ); + } + + const rows: CsvRow[] = dataRecords.map((record) => { + const values: Record = {}; + + headers.forEach((header, index) => { + // Short rows yield empty strings rather than undefined, so required-field + // validation reports "missing" instead of throwing on a property access. + values[header] = (record.fields[index] ?? '').trim(); + }); + + return { lineNumber: record.lineNumber, values }; + }); + + return { headers, rows }; +} diff --git a/tests/bulkDeliveryService.test.ts b/tests/bulkDeliveryService.test.ts new file mode 100644 index 0000000..eb0f5c6 --- /dev/null +++ b/tests/bulkDeliveryService.test.ts @@ -0,0 +1,334 @@ +/** + * Unit tests for BulkDeliveryService. + * + * Runs against a real in-process MongoDB so the unique index on + * trackingNumber, unordered insertMany semantics and partial-failure handling + * are exercised against the actual driver rather than a mock. + */ + +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import Delivery, { DeliveryStatus } from '../src/models/Delivery'; +import NotificationPreference from '../src/models/NotificationPreference'; +import Notification from '../src/models/Notification'; +import { BulkDeliveryService } from '../src/services/bulkDeliveryService'; +import { NotificationService } from '../src/services/notificationService'; +import { IPushProvider, PushResult } from '../src/services/push/pushProvider'; + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +/** Push transport that accepts everything, so imports are not gated on FCM. */ +class NoopPushProvider implements IPushProvider { + public readonly name = 'noop'; + isConfigured(): boolean { + return true; + } + async send(): Promise { + return { acceptedCount: 0, rejectedCount: 0, invalidTokens: [] }; + } +} + +const HEADER = + 'trackingNumber,customerName,customerPhone,customerEmail,pickupAddress,' + + 'dropoffAddress,packageDescription,packageWeight,deliveryFee,escrowAmount,notes'; + +/** Build one valid CSV data row, with optional field overrides. */ +const row = (overrides: Partial> = {}): string => { + const fields = { + trackingNumber: `TRK-${Math.random().toString(36).slice(2, 10).toUpperCase()}`, + customerName: 'Ada Lovelace', + customerPhone: '+2348000000000', + customerEmail: 'ada@example.com', + pickupAddress: '1 Pickup Road', + dropoffAddress: '2 Dropoff Avenue', + packageDescription: 'Documents', + packageWeight: '1.5', + deliveryFee: '1000', + escrowAmount: '5000', + notes: 'Handle with care', + ...overrides, + }; + + return [ + fields.trackingNumber, + fields.customerName, + fields.customerPhone, + fields.customerEmail, + fields.pickupAddress, + fields.dropoffAddress, + fields.packageDescription, + fields.packageWeight, + fields.deliveryFee, + fields.escrowAmount, + fields.notes, + ].join(','); +}; + +/** Assemble a full CSV document from data rows. */ +const csv = (...rows: string[]): string => `${HEADER}\n${rows.join('\n')}\n`; + +describe('BulkDeliveryService', () => { + let mongod: MongoMemoryServer; + let service: BulkDeliveryService; + + const userId = new Types.ObjectId().toHexString(); + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + + const notifications = new NotificationService(undefined, undefined, new NoopPushProvider()); + service = new BulkDeliveryService(undefined, notifications); + }, 60_000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); + }, 30_000); + + afterEach(async () => { + await Promise.all([ + Delivery.deleteMany({}), + NotificationPreference.deleteMany({}), + Notification.deleteMany({}), + ]); + }); + + // ── Happy path ──────────────────────────────────────────────────────────── + + describe('successful import', () => { + it('creates every valid row', async () => { + const result = await service.importFromCsv(csv(row(), row(), row()), userId); + + expect(result.totalRows).toBe(3); + expect(result.successCount).toBe(3); + expect(result.failureCount).toBe(0); + expect(result.errors).toEqual([]); + await expect(Delivery.countDocuments({})).resolves.toBe(3); + }); + + it('persists the mapped fields from the CSV', async () => { + await service.importFromCsv(csv(row({ trackingNumber: 'TRK-MAPPED' })), userId); + + const delivery = await Delivery.findOne({ trackingNumber: 'TRK-MAPPED' }); + + expect(delivery).not.toBeNull(); + expect(delivery?.customer?.name).toBe('Ada Lovelace'); + expect(delivery?.customer?.email).toBe('ada@example.com'); + expect(delivery?.pickup?.address).toBe('1 Pickup Road'); + expect(delivery?.package?.weight).toBe(1.5); + expect(delivery?.deliveryFee).toBe(1000); + expect(delivery?.escrowAmount).toBe(5000); + expect(delivery?.notes).toBe('Handle with care'); + }); + + it('records the importing user and a pending status', async () => { + await service.importFromCsv(csv(row({ trackingNumber: 'TRK-OWNER' })), userId); + + const delivery = await Delivery.findOne({ trackingNumber: 'TRK-OWNER' }); + + expect(delivery?.userId).toBe(userId); + expect(delivery?.status).toBe(DeliveryStatus.PENDING); + }); + + it('accepts rows omitting the optional columns', async () => { + const result = await service.importFromCsv( + csv(row({ customerEmail: '', notes: '' })), + userId, + ); + + expect(result.successCount).toBe(1); + }); + }); + + // ── Partial failure ─────────────────────────────────────────────────────── + + describe('partial failure', () => { + it('imports valid rows and reports invalid ones', async () => { + const result = await service.importFromCsv( + csv(row(), row({ customerName: '' }), row()), + userId, + ); + + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(1); + expect(result.errors[0].message).toMatch(/customerName is required/); + await expect(Delivery.countDocuments({})).resolves.toBe(2); + }); + + it('reports the source line number of a bad row', async () => { + const result = await service.importFromCsv( + csv(row(), row({ packageWeight: 'heavy' })), + userId, + ); + + // Header is line 1, so the second data row is line 3. + expect(result.errors[0].line).toBe(3); + }); + + it('reports every problem on a row in one pass', async () => { + const result = await service.importFromCsv( + csv(row({ customerName: '', packageWeight: 'x' })), + userId, + ); + + expect(result.errors.length).toBeGreaterThanOrEqual(2); + }); + + it('counts failed rows rather than individual errors', async () => { + // One row, three invalid columns: the caller must be told one row + // failed, not three, or "N of M" would exceed the rows in the file. + const result = await service.importFromCsv( + csv(row({ customerName: '', customerPhone: '', packageWeight: 'x' })), + userId, + ); + + expect(result.totalRows).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.errors.length).toBeGreaterThan(1); + expect(result.successCount + result.failureCount).toBeLessThanOrEqual(result.totalRows); + }); + + it('keeps success and failure counts consistent with the row total', async () => { + const result = await service.importFromCsv( + csv(row(), row({ customerName: '', packageWeight: 'x' }), row()), + userId, + ); + + expect(result.totalRows).toBe(3); + expect(result.successCount).toBe(2); + expect(result.failureCount).toBe(1); + expect(result.successCount + result.failureCount).toBe(result.totalRows); + }); + + it('rejects a non-positive package weight', async () => { + const result = await service.importFromCsv(csv(row({ packageWeight: '0' })), userId); + + expect(result.successCount).toBe(0); + expect(result.errors[0].message).toMatch(/greater than zero/); + }); + + it('rejects a negative delivery fee', async () => { + const result = await service.importFromCsv(csv(row({ deliveryFee: '-1' })), userId); + + expect(result.errors[0].message).toMatch(/cannot be negative/); + }); + + it('rejects a malformed email address', async () => { + const result = await service.importFromCsv( + csv(row({ customerEmail: 'not-an-email' })), + userId, + ); + + expect(result.errors[0].message).toMatch(/valid email/); + }); + + it('sorts the error report by line number', async () => { + const result = await service.importFromCsv( + csv(row({ customerName: '' }), row(), row({ packageWeight: 'x' })), + userId, + ); + + const lines = result.errors.map((error) => error.line); + expect(lines).toEqual([...lines].sort((a, b) => a - b)); + }); + }); + + // ── Duplicate handling ──────────────────────────────────────────────────── + + describe('duplicate tracking numbers', () => { + it('rejects a row duplicating an earlier row in the same file', async () => { + const result = await service.importFromCsv( + csv(row({ trackingNumber: 'TRK-DUP' }), row({ trackingNumber: 'TRK-DUP' })), + userId, + ); + + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.errors[0].message).toMatch(/Duplicate tracking number within the file/); + expect(result.errors[0].message).toMatch(/line 2/); + await expect(Delivery.countDocuments({ trackingNumber: 'TRK-DUP' })).resolves.toBe(1); + }); + + it('rejects a row duplicating a delivery already in the database', async () => { + await service.importFromCsv(csv(row({ trackingNumber: 'TRK-EXISTS' })), userId); + + const result = await service.importFromCsv( + csv(row({ trackingNumber: 'TRK-EXISTS' }), row()), + userId, + ); + + expect(result.successCount).toBe(1); + expect(result.failureCount).toBe(1); + expect(result.errors[0].message).toMatch(/already exists/); + expect(result.errors[0].trackingNumber).toBe('TRK-EXISTS'); + }); + }); + + // ── Whole-file rejections ───────────────────────────────────────────────── + + describe('unusable files', () => { + it('rejects a file missing required columns', async () => { + await expect( + service.importFromCsv('trackingNumber,customerName\nTRK-1,Ada\n', userId), + ).rejects.toThrow(/missing required column/i); + }); + + it('names every missing column', async () => { + await expect( + service.importFromCsv('trackingNumber,customerName\nTRK-1,Ada\n', userId), + ).rejects.toThrow(/customerphone/); + }); + + it('rejects an empty file', async () => { + await expect(service.importFromCsv('', userId)).rejects.toThrow(/empty/i); + }); + + it('rejects a header-only file', async () => { + await expect(service.importFromCsv(`${HEADER}\n`, userId)).rejects.toThrow(/no data rows/i); + }); + + it('writes nothing when the file is rejected outright', async () => { + await expect(service.importFromCsv('bad,header\n1,2\n', userId)).rejects.toThrow(); + await expect(Delivery.countDocuments({})).resolves.toBe(0); + }); + }); + + // ── Column handling ─────────────────────────────────────────────────────── + + describe('column handling', () => { + it('accepts headers in any letter case', async () => { + const upper = HEADER.toUpperCase(); + const result = await service.importFromCsv(`${upper}\n${row()}\n`, userId); + + expect(result.successCount).toBe(1); + }); + + it('handles quoted fields containing commas', async () => { + const quoted = row({ pickupAddress: '"12 High Street, Lagos"' }); + const result = await service.importFromCsv(csv(quoted), userId); + + expect(result.successCount).toBe(1); + const delivery = await Delivery.findOne({}); + expect(delivery?.pickup?.address).toBe('12 High Street, Lagos'); + }); + }); + + // ── Notifications ───────────────────────────────────────────────────────── + + describe('notifications', () => { + it('records a creation notification per imported delivery', async () => { + // The importing user is the only identifiable recipient, and userId is + // stored as a free-form string, so no ObjectId recipient resolves here. + // The import must still succeed. + const result = await service.importFromCsv(csv(row(), row()), userId); + + expect(result.successCount).toBe(2); + }); + }); +}); diff --git a/tests/chatMessage.service.test.ts b/tests/chatMessage.service.test.ts new file mode 100644 index 0000000..2e21133 --- /dev/null +++ b/tests/chatMessage.service.test.ts @@ -0,0 +1,270 @@ +/** + * Unit tests for ChatMessageService and the socket transport adapter. + * + * The point of the refactor these cover is that the chat logic no longer needs + * a running Socket.IO server to be tested: ChatMessageService is exercised + * directly against a real in-process MongoDB, and the adapter is checked + * against lightweight socket doubles that only record emitted events. + */ + +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { Namespace, Socket } from 'socket.io'; +import ChatMessage from '../src/models/ChatMessage'; +import { + ChatMessageService, + InvalidChatMessageError, + MAX_MESSAGE_LENGTH, + RECENT_MESSAGE_LIMIT, +} from '../src/sockets/chatMessage.service'; +import { SocketService } from '../src/sockets/socketService'; + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +/** Records every event emitted to a single socket. */ +const createSocketDouble = (): Socket & { emitted: Array<{ event: string; payload: unknown }> } => { + const emitted: Array<{ event: string; payload: unknown }> = []; + return { + id: 'socket-test', + emit: (event: string, payload: unknown) => { + emitted.push({ event, payload }); + return true; + }, + emitted, + } as unknown as Socket & { emitted: Array<{ event: string; payload: unknown }> }; +}; + +/** Records every event broadcast to a namespace. */ +const createNamespaceDouble = (): Namespace & { + emitted: Array<{ event: string; payload: unknown }>; +} => { + const emitted: Array<{ event: string; payload: unknown }> = []; + return { + name: '/test', + emit: (event: string, payload: unknown) => { + emitted.push({ event, payload }); + return true; + }, + emitted, + } as unknown as Namespace & { emitted: Array<{ event: string; payload: unknown }> }; +}; + +describe('ChatMessageService', () => { + let mongod: MongoMemoryServer; + let service: ChatMessageService; + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + service = new ChatMessageService(); + }, 60_000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); + }, 30_000); + + afterEach(async () => { + await ChatMessage.deleteMany({}); + }); + + // ── Validation ──────────────────────────────────────────────────────────── + + describe('validate', () => { + it('accepts a well-formed message', () => { + expect(service.validate({ content: 'hello', sender: 'ada' })).toEqual({ + content: 'hello', + sender: 'ada', + }); + }); + + it('accepts a message with no sender', () => { + expect(service.validate({ content: 'hello' })).toEqual({ content: 'hello' }); + }); + + it('trims surrounding whitespace from the content', () => { + expect(service.validate({ content: ' hello ' }).content).toBe('hello'); + }); + + it('omits a sender that is only whitespace', () => { + expect(service.validate({ content: 'hi', sender: ' ' }).sender).toBeUndefined(); + }); + + it.each([ + ['a null payload', null], + ['an undefined payload', undefined], + ['a string payload', 'hello'], + ['a numeric payload', 42], + ])('rejects %s', (_label, payload) => { + expect(() => service.validate(payload as never)).toThrow(InvalidChatMessageError); + }); + + it('rejects a missing content field', () => { + expect(() => service.validate({})).toThrow(/content is required/i); + }); + + it('rejects non-string content', () => { + expect(() => service.validate({ content: 42 })).toThrow(/content is required/i); + }); + + it('rejects empty or whitespace-only content', () => { + expect(() => service.validate({ content: '' })).toThrow(/cannot be empty/i); + expect(() => service.validate({ content: ' ' })).toThrow(/cannot be empty/i); + }); + + it('rejects content over the length limit', () => { + const tooLong = 'x'.repeat(MAX_MESSAGE_LENGTH + 1); + expect(() => service.validate({ content: tooLong })).toThrow(/cannot exceed/i); + }); + + it('accepts content exactly at the length limit', () => { + const atLimit = 'x'.repeat(MAX_MESSAGE_LENGTH); + expect(service.validate({ content: atLimit }).content).toHaveLength(MAX_MESSAGE_LENGTH); + }); + + it('rejects a non-string sender', () => { + expect(() => service.validate({ content: 'hi', sender: 42 })).toThrow(/sender must be/i); + }); + }); + + // ── Persistence ─────────────────────────────────────────────────────────── + + describe('createMessage', () => { + it('persists a valid message', async () => { + const created = await service.createMessage({ content: 'hello', sender: 'ada' }); + + expect(created.content).toBe('hello'); + expect(created.sender).toBe('ada'); + await expect(ChatMessage.countDocuments({})).resolves.toBe(1); + }); + + it('stores the trimmed content', async () => { + const created = await service.createMessage({ content: ' padded ' }); + expect(created.content).toBe('padded'); + }); + + it('writes nothing when validation fails', async () => { + await expect(service.createMessage({ content: '' })).rejects.toThrow(InvalidChatMessageError); + await expect(ChatMessage.countDocuments({})).resolves.toBe(0); + }); + }); + + // ── Transcript ──────────────────────────────────────────────────────────── + + describe('getRecentTranscript', () => { + it('returns messages oldest first for display', async () => { + await service.createMessage({ content: 'first' }); + await service.createMessage({ content: 'second' }); + await service.createMessage({ content: 'third' }); + + const transcript = await service.getRecentTranscript(); + + expect(transcript.map((message) => message.content)).toEqual(['first', 'second', 'third']); + }); + + it('returns the most recent messages when over the limit', async () => { + for (let i = 0; i < RECENT_MESSAGE_LIMIT + 5; i += 1) { + await service.createMessage({ content: `message-${i}` }); + } + + const transcript = await service.getRecentTranscript(); + + expect(transcript).toHaveLength(RECENT_MESSAGE_LIMIT); + // The oldest five are dropped, so the window starts at message-5. + expect(transcript[0].content).toBe('message-5'); + expect(transcript[transcript.length - 1].content).toBe(`message-${RECENT_MESSAGE_LIMIT + 4}`); + }); + + it('honours an explicit limit', async () => { + await service.createMessage({ content: 'a' }); + await service.createMessage({ content: 'b' }); + await service.createMessage({ content: 'c' }); + + await expect(service.getRecentTranscript(2)).resolves.toHaveLength(2); + }); + + it('returns an empty transcript when there are no messages', async () => { + await expect(service.getRecentTranscript()).resolves.toEqual([]); + }); + }); +}); + +describe('SocketService (transport adapter)', () => { + let mongod: MongoMemoryServer; + let adapter: SocketService; + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + adapter = new SocketService(); + }, 60_000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); + }, 30_000); + + afterEach(async () => { + await ChatMessage.deleteMany({}); + }); + + it('replays the transcript to a connecting client', async () => { + await new ChatMessageService().createMessage({ content: 'earlier' }); + + const socket = createSocketDouble(); + await adapter.handleConnection(socket, createNamespaceDouble()); + + expect(socket.emitted).toHaveLength(1); + expect(socket.emitted[0].event).toBe('recentMessages'); + expect(socket.emitted[0].payload).toHaveLength(1); + }); + + it('broadcasts a valid message to the namespace', async () => { + const nsp = createNamespaceDouble(); + const socket = createSocketDouble(); + + await adapter.handleIncomingMessage(nsp, { content: 'hello' }, socket); + + expect(nsp.emitted).toHaveLength(1); + expect(nsp.emitted[0].event).toBe('message'); + await expect(ChatMessage.countDocuments({})).resolves.toBe(1); + }); + + it('tells the sender why an invalid message was rejected', async () => { + const nsp = createNamespaceDouble(); + const socket = createSocketDouble(); + + await adapter.handleIncomingMessage(nsp, { content: '' }, socket); + + // Nothing is broadcast, and the sender learns the reason rather than + // seeing its message vanish silently. + expect(nsp.emitted).toHaveLength(0); + expect(socket.emitted[0].event).toBe('error'); + expect(socket.emitted[0].payload).toMatchObject({ message: expect.stringMatching(/empty/i) }); + await expect(ChatMessage.countDocuments({})).resolves.toBe(0); + }); + + it('does not throw when no originating socket is supplied', async () => { + const nsp = createNamespaceDouble(); + + await expect(adapter.handleIncomingMessage(nsp, { content: '' })).resolves.toBeUndefined(); + expect(nsp.emitted).toHaveLength(0); + }); + + it('reports a transcript read failure to that client alone', async () => { + const failing = new ChatMessageService({ + findRecent: jest.fn().mockRejectedValue(new Error('database unavailable')), + } as never); + const failingAdapter = new SocketService(failing); + const socket = createSocketDouble(); + + await failingAdapter.handleConnection(socket, createNamespaceDouble()); + + expect(socket.emitted[0].event).toBe('error'); + }); +}); diff --git a/tests/csvParser.test.ts b/tests/csvParser.test.ts new file mode 100644 index 0000000..1d55d3c --- /dev/null +++ b/tests/csvParser.test.ts @@ -0,0 +1,113 @@ +/** + * Unit tests for the RFC 4180 CSV parser used by the bulk delivery import. + * + * Pure functions with no I/O, so these run without a database. + */ + +import { CsvParseError, parseCsv } from '../src/utils/csvParser'; + +const MAX_ROWS = 100; + +describe('parseCsv', () => { + it('parses a simple file into header-keyed rows', () => { + const result = parseCsv('name,age\nAda,36\nGrace,45\n', MAX_ROWS); + + expect(result.headers).toEqual(['name', 'age']); + expect(result.rows).toHaveLength(2); + expect(result.rows[0].values).toEqual({ name: 'Ada', age: '36' }); + expect(result.rows[1].values).toEqual({ name: 'Grace', age: '45' }); + }); + + it('lowercases and trims header names', () => { + const result = parseCsv(' Name , AGE \nAda,36\n', MAX_ROWS); + expect(result.headers).toEqual(['name', 'age']); + }); + + it('reports the source line number for each row', () => { + const result = parseCsv('name\nAda\nGrace\n', MAX_ROWS); + expect(result.rows.map((row) => row.lineNumber)).toEqual([2, 3]); + }); + + it('handles quoted fields containing commas', () => { + const result = parseCsv('name,address\nAda,"12 High St, London"\n', MAX_ROWS); + expect(result.rows[0].values.address).toBe('12 High St, London'); + }); + + it('handles escaped double quotes inside quoted fields', () => { + const result = parseCsv('name,note\nAda,"She said ""hello"""\n', MAX_ROWS); + expect(result.rows[0].values.note).toBe('She said "hello"'); + }); + + it('handles newlines inside quoted fields without splitting the row', () => { + const result = parseCsv('name,note\nAda,"line one\nline two"\n', MAX_ROWS); + + expect(result.rows).toHaveLength(1); + expect(result.rows[0].values.note).toBe('line one\nline two'); + }); + + it('keeps line numbers correct after a multi-line quoted field', () => { + const result = parseCsv('name,note\nAda,"one\ntwo"\nGrace,fine\n', MAX_ROWS); + + expect(result.rows).toHaveLength(2); + // Grace starts on physical line 4 because Ada's note spans lines 2-3. + expect(result.rows[1].lineNumber).toBe(4); + }); + + it('parses CRLF line endings', () => { + const result = parseCsv('name,age\r\nAda,36\r\n', MAX_ROWS); + expect(result.rows[0].values).toEqual({ name: 'Ada', age: '36' }); + }); + + it('parses a final row with no trailing newline', () => { + const result = parseCsv('name\nAda', MAX_ROWS); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].values.name).toBe('Ada'); + }); + + it('skips blank lines between rows', () => { + const result = parseCsv('name\nAda\n\nGrace\n', MAX_ROWS); + expect(result.rows.map((row) => row.values.name)).toEqual(['Ada', 'Grace']); + }); + + it('strips a UTF-8 byte order mark from the first header', () => { + const result = parseCsv('name,age\nAda,36\n', MAX_ROWS); + expect(result.headers).toEqual(['name', 'age']); + }); + + it('pads short rows with empty strings rather than undefined', () => { + const result = parseCsv('name,age,city\nAda,36\n', MAX_ROWS); + expect(result.rows[0].values).toEqual({ name: 'Ada', age: '36', city: '' }); + }); + + it('rejects an empty file', () => { + expect(() => parseCsv('', MAX_ROWS)).toThrow(CsvParseError); + expect(() => parseCsv(' \n ', MAX_ROWS)).toThrow(CsvParseError); + }); + + it('rejects a file with headers but no data rows', () => { + expect(() => parseCsv('name,age\n', MAX_ROWS)).toThrow(/no data rows/i); + }); + + it('rejects duplicate header names', () => { + expect(() => parseCsv('name,name\nAda,Grace\n', MAX_ROWS)).toThrow(/duplicate/i); + }); + + it('rejects an empty header cell', () => { + expect(() => parseCsv('name,,age\nAda,x,36\n', MAX_ROWS)).toThrow(/empty column name/i); + }); + + it('rejects an unterminated quoted field', () => { + expect(() => parseCsv('name\n"unterminated\n', MAX_ROWS)).toThrow(/unterminated/i); + }); + + it('rejects a file exceeding the row limit', () => { + const rows = Array.from({ length: 5 }, (_, i) => `row${i}`).join('\n'); + expect(() => parseCsv(`name\n${rows}\n`, 3)).toThrow(/exceeds the limit of 3/); + }); + + it('accepts a file exactly at the row limit', () => { + const rows = Array.from({ length: 3 }, (_, i) => `row${i}`).join('\n'); + const result = parseCsv(`name\n${rows}\n`, 3); + expect(result.rows).toHaveLength(3); + }); +}); diff --git a/tests/deliveryStatusTransition.test.ts b/tests/deliveryStatusTransition.test.ts new file mode 100644 index 0000000..bc7e5f4 --- /dev/null +++ b/tests/deliveryStatusTransition.test.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for DeliveryService.updateStatus — the delivery state machine + * and the notification trigger it fires. + * + * Runs against a real in-process MongoDB. The push transport is stubbed at the + * notification-service boundary because it is an external HTTP dependency; + * everything below it (preferences, the audit log, the conditional update) is + * exercised for real. + */ + +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import Delivery, { DeliveryStatus, IDelivery } from '../src/models/Delivery'; +import Notification, { NotificationStatus } from '../src/models/Notification'; +import NotificationPreference, { NotificationEvent } from '../src/models/NotificationPreference'; +import { DeliveryService } from '../src/services/delivery.service'; +import { notificationService } from '../src/services/notificationService'; + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +describe('DeliveryService.updateStatus', () => { + let mongod: MongoMemoryServer; + let service: DeliveryService; + let notifySpy: jest.SpyInstance; + + const senderId = new Types.ObjectId(); + + /** Create a delivery in the given status, owned by `senderId`. */ + const createDelivery = async (status: DeliveryStatus): Promise => + Delivery.create({ + trackingNumber: `TRK-${new Types.ObjectId().toHexString().slice(-8)}`, + status, + sender: senderId, + customer: { name: 'Ada', phone: '+2348000000000' }, + pickup: { address: 'A' }, + dropoff: { address: 'B' }, + package: { description: 'Docs', weight: 1 }, + deliveryFee: 100, + escrowAmount: 500, + }); + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + service = new DeliveryService(); + }, 60_000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); + }, 30_000); + + beforeEach(() => { + // The transport itself is covered in notificationService.test.ts; here we + // only care that the transition fires it with the right arguments. + notifySpy = jest.spyOn(notificationService, 'notifyDeliveryTransition').mockResolvedValue([]); + }); + + afterEach(async () => { + notifySpy.mockRestore(); + await Promise.all([ + Delivery.deleteMany({}), + Notification.deleteMany({}), + NotificationPreference.deleteMany({}), + ]); + }); + + // ── Permitted transitions ───────────────────────────────────────────────── + + describe('permitted transitions', () => { + it.each([ + [DeliveryStatus.PENDING, DeliveryStatus.FUNDED], + [DeliveryStatus.PENDING, DeliveryStatus.ASSIGNED], + [DeliveryStatus.PENDING, DeliveryStatus.CANCELLED], + [DeliveryStatus.FUNDED, DeliveryStatus.ASSIGNED], + [DeliveryStatus.ASSIGNED, DeliveryStatus.IN_PROGRESS], + [DeliveryStatus.IN_PROGRESS, DeliveryStatus.COMPLETED], + [DeliveryStatus.IN_PROGRESS, DeliveryStatus.CANCELLED], + ])('allows %s -> %s', async (from, to) => { + const delivery = await createDelivery(from); + const updated = await service.updateStatus(String(delivery._id), to); + + expect(updated.status).toBe(to); + }); + + it('walks the full pending -> in progress -> completed path', async () => { + const delivery = await createDelivery(DeliveryStatus.PENDING); + const id = String(delivery._id); + + await service.updateStatus(id, DeliveryStatus.ASSIGNED); + await service.updateStatus(id, DeliveryStatus.IN_PROGRESS); + const completed = await service.updateStatus(id, DeliveryStatus.COMPLETED); + + expect(completed.status).toBe(DeliveryStatus.COMPLETED); + expect(notifySpy).toHaveBeenCalledTimes(3); + }); + }); + + // ── Rejected transitions ────────────────────────────────────────────────── + + describe('rejected transitions', () => { + it('rejects skipping a state', async () => { + const delivery = await createDelivery(DeliveryStatus.PENDING); + + await expect( + service.updateStatus(String(delivery._id), DeliveryStatus.COMPLETED), + ).rejects.toThrow(/Cannot transition a delivery from 'pending' to 'completed'/); + }); + + it('rejects moving out of a terminal state', async () => { + const delivery = await createDelivery(DeliveryStatus.COMPLETED); + + await expect( + service.updateStatus(String(delivery._id), DeliveryStatus.IN_PROGRESS), + ).rejects.toThrow(/terminal state/); + }); + + it('rejects a no-op transition to the current status', async () => { + const delivery = await createDelivery(DeliveryStatus.PENDING); + + await expect( + service.updateStatus(String(delivery._id), DeliveryStatus.PENDING), + ).rejects.toThrow(/already in status/); + }); + + it('rejects a malformed delivery id', async () => { + await expect( + service.updateStatus('not-an-object-id', DeliveryStatus.COMPLETED), + ).rejects.toThrow(/Invalid delivery ID/); + }); + + it('rejects an unknown delivery', async () => { + await expect( + service.updateStatus(new Types.ObjectId().toHexString(), DeliveryStatus.COMPLETED), + ).rejects.toThrow(/not found/); + }); + + it('does not notify when the transition is rejected', async () => { + const delivery = await createDelivery(DeliveryStatus.PENDING); + + await expect( + service.updateStatus(String(delivery._id), DeliveryStatus.COMPLETED), + ).rejects.toThrow(); + + expect(notifySpy).not.toHaveBeenCalled(); + }); + + it('leaves the stored status unchanged when rejected', async () => { + const delivery = await createDelivery(DeliveryStatus.PENDING); + + await expect( + service.updateStatus(String(delivery._id), DeliveryStatus.COMPLETED), + ).rejects.toThrow(); + + const unchanged = await Delivery.findById(delivery._id); + expect(unchanged?.status).toBe(DeliveryStatus.PENDING); + }); + }); + + // ── Concurrency ─────────────────────────────────────────────────────────── + + describe('concurrent transitions', () => { + it('lets only one of two identical transitions succeed', async () => { + const delivery = await createDelivery(DeliveryStatus.ASSIGNED); + const id = String(delivery._id); + + const results = await Promise.allSettled([ + service.updateStatus(id, DeliveryStatus.IN_PROGRESS), + service.updateStatus(id, DeliveryStatus.IN_PROGRESS), + ]); + + const fulfilled = results.filter((result) => result.status === 'fulfilled'); + expect(fulfilled).toHaveLength(1); + // The loser is rejected rather than silently overwriting the winner. + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + }); + + it('notifies once per successful transition, not per attempt', async () => { + const delivery = await createDelivery(DeliveryStatus.ASSIGNED); + const id = String(delivery._id); + + await Promise.allSettled([ + service.updateStatus(id, DeliveryStatus.IN_PROGRESS), + service.updateStatus(id, DeliveryStatus.IN_PROGRESS), + ]); + + expect(notifySpy).toHaveBeenCalledTimes(1); + }); + }); + + // ── Notification integration ────────────────────────────────────────────── + + describe('notification trigger', () => { + it('notifies with the delivery and its new status', async () => { + const delivery = await createDelivery(DeliveryStatus.ASSIGNED); + await service.updateStatus(String(delivery._id), DeliveryStatus.IN_PROGRESS); + + expect(notifySpy).toHaveBeenCalledWith( + expect.objectContaining({ status: DeliveryStatus.IN_PROGRESS }), + DeliveryStatus.IN_PROGRESS, + ); + }); + + it('commits the transition even when notification dispatch fails', async () => { + notifySpy.mockRejectedValueOnce(new Error('push provider unreachable')); + const delivery = await createDelivery(DeliveryStatus.ASSIGNED); + + // The rejection propagates, but the status write has already committed: + // a delivery must never revert because a push failed. + await expect( + service.updateStatus(String(delivery._id), DeliveryStatus.IN_PROGRESS), + ).rejects.toThrow(/push provider unreachable/); + + const stored = await Delivery.findById(delivery._id); + expect(stored?.status).toBe(DeliveryStatus.IN_PROGRESS); + }); + + it('writes an audit record end to end through the real service', async () => { + notifySpy.mockRestore(); + + await NotificationPreference.create({ + user: senderId, + pushEnabled: true, + enabledEvents: Object.values(NotificationEvent), + devices: [], + }); + + const delivery = await createDelivery(DeliveryStatus.ASSIGNED); + await service.updateStatus(String(delivery._id), DeliveryStatus.IN_PROGRESS); + + const records = await Notification.find({ user: senderId }); + + expect(records).toHaveLength(1); + expect(records[0].event).toBe(NotificationEvent.DELIVERY_IN_PROGRESS); + // No devices registered, so the send is suppressed rather than attempted. + expect(records[0].status).toBe(NotificationStatus.SKIPPED); + }); + }); +}); diff --git a/tests/fcmProvider.test.ts b/tests/fcmProvider.test.ts new file mode 100644 index 0000000..a4f7d40 --- /dev/null +++ b/tests/fcmProvider.test.ts @@ -0,0 +1,377 @@ +/** + * Unit tests for FcmProvider. + * + * FCM is an external HTTP service, so axios is mocked here — but everything on + * our side of the boundary is real: the RS256 service-account JWT is genuinely + * signed with a generated key pair and verified in-test, and the error + * classification, token caching and fan-out logic run unmodified. + */ + +import crypto from 'crypto'; +import axios from 'axios'; +import { FcmProvider } from '../src/services/push/fcmProvider'; + +jest.mock('axios'); +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +const mockedAxios = axios as jest.Mocked; + +/** A real RSA key pair, so JWT signatures can be verified rather than assumed. */ +const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +const PROJECT_ID = 'test-project'; +const CLIENT_EMAIL = 'svc@test-project.iam.gserviceaccount.com'; + +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** Build a provider wired to the generated key pair. */ +const createProvider = (key: string = privateKey): FcmProvider => + new FcmProvider(PROJECT_ID, CLIENT_EMAIL, key); + +/** Shape an axios error the way the FCM v1 API reports a per-token failure. */ +const fcmError = (errorCode: string, status = 'INVALID_ARGUMENT'): unknown => { + const error = new Error('Request failed') as Error & { + isAxiosError: boolean; + response: { data: unknown }; + }; + error.isAxiosError = true; + error.response = { + data: { error: { status, details: [{ errorCode }] } }, + }; + return error; +}; + +/** Route token-endpoint calls to an access token and sends to `sendImpl`. */ +const stubTransport = (sendImpl: () => Promise): void => { + mockedAxios.post.mockImplementation(async (url: string) => { + if (url === TOKEN_URL) { + return { data: { access_token: 'access-token-abc', expires_in: 3600 } }; + } + return sendImpl(); + }); +}; + +describe('FcmProvider', () => { + beforeEach(() => { + jest.clearAllMocks(); + // isAxiosError is a real function on the module; the mock must keep it. + (mockedAxios.isAxiosError as unknown as jest.Mock) = jest.fn( + (error: { isAxiosError?: boolean }) => Boolean(error?.isAxiosError), + ); + }); + + // ── Configuration ───────────────────────────────────────────────────────── + + describe('isConfigured', () => { + it('is configured when all three credentials are present', () => { + expect(createProvider().isConfigured()).toBe(true); + }); + + it.each([ + ['project id', ['', CLIENT_EMAIL, privateKey]], + ['client email', [PROJECT_ID, '', privateKey]], + ['private key', [PROJECT_ID, CLIENT_EMAIL, '']], + ])('is not configured without a %s', (_label, args) => { + const [project, email, key] = args as [string, string, string]; + expect(new FcmProvider(project, email, key).isConfigured()).toBe(false); + }); + + it('reports a failure rather than sending when unconfigured', async () => { + const provider = new FcmProvider('', '', ''); + const result = await provider.send({ + tokens: ['t1', 't2'], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result.acceptedCount).toBe(0); + expect(result.rejectedCount).toBe(2); + expect(result.failureReason).toMatch(/not configured/i); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + }); + + // ── Sending ─────────────────────────────────────────────────────────────── + + describe('send', () => { + it('returns immediately when there are no target tokens', async () => { + const result = await createProvider().send({ + tokens: [], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result).toEqual({ acceptedCount: 0, rejectedCount: 0, invalidTokens: [] }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('sends one request per token and counts acceptances', async () => { + stubTransport(async () => ({ data: { name: 'projects/test/messages/1' } })); + + const result = await createProvider().send({ + tokens: ['t1', 't2', 't3'], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result.acceptedCount).toBe(3); + expect(result.rejectedCount).toBe(0); + // One token exchange plus one send per token. + expect(mockedAxios.post).toHaveBeenCalledTimes(4); + }); + + it('posts to the project-scoped v1 send endpoint', async () => { + stubTransport(async () => ({ data: {} })); + + await createProvider().send({ tokens: ['t1'], title: 'T', body: 'B', data: {} }); + + const sendCall = mockedAxios.post.mock.calls.find(([url]) => url !== TOKEN_URL); + expect(sendCall?.[0]).toBe( + `https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send`, + ); + }); + + it('sends the notification and data payload in the v1 message shape', async () => { + stubTransport(async () => ({ data: {} })); + + await createProvider().send({ + tokens: ['t1'], + title: 'Delivery completed', + body: 'Your parcel arrived', + data: { deliveryId: 'abc', status: 'completed' }, + }); + + const sendCall = mockedAxios.post.mock.calls.find(([url]) => url !== TOKEN_URL); + expect(sendCall?.[1]).toEqual({ + message: { + token: 't1', + notification: { title: 'Delivery completed', body: 'Your parcel arrived' }, + data: { deliveryId: 'abc', status: 'completed' }, + }, + }); + }); + + it('authorises the send with the access token from the token endpoint', async () => { + stubTransport(async () => ({ data: {} })); + + await createProvider().send({ tokens: ['t1'], title: 'T', body: 'B', data: {} }); + + const sendCall = mockedAxios.post.mock.calls.find(([url]) => url !== TOKEN_URL); + const config = sendCall?.[2] as { headers: Record }; + expect(config.headers.Authorization).toBe('Bearer access-token-abc'); + }); + + it('isolates a single token failure from the rest of the batch', async () => { + let call = 0; + stubTransport(async () => { + call += 1; + if (call === 2) throw fcmError('INTERNAL'); + return { data: {} }; + }); + + const result = await createProvider().send({ + tokens: ['t1', 't2', 't3'], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result.acceptedCount).toBe(2); + expect(result.rejectedCount).toBe(1); + }); + }); + + // ── Token classification ────────────────────────────────────────────────── + + describe('invalid token classification', () => { + it.each(['UNREGISTERED', 'INVALID_ARGUMENT', 'SENDER_ID_MISMATCH'])( + 'marks %s as a permanently invalid token', + async (errorCode) => { + stubTransport(async () => { + throw fcmError(errorCode); + }); + + const result = await createProvider().send({ + tokens: ['dead-token'], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result.invalidTokens).toEqual(['dead-token']); + }, + ); + + it.each(['INTERNAL', 'UNAVAILABLE', 'QUOTA_EXCEEDED'])( + 'does not prune a token after a transient %s failure', + async (errorCode) => { + stubTransport(async () => { + throw fcmError(errorCode, errorCode); + }); + + const result = await createProvider().send({ + tokens: ['good-token'], + title: 'T', + body: 'B', + data: {}, + }); + + // A retryable failure must not cost the user their registration. + expect(result.invalidTokens).toEqual([]); + expect(result.rejectedCount).toBe(1); + }, + ); + + it('does not prune a token on an unclassifiable failure', async () => { + stubTransport(async () => { + throw new Error('socket hang up'); + }); + + const result = await createProvider().send({ + tokens: ['good-token'], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result.invalidTokens).toEqual([]); + }); + }); + + // ── Authentication ──────────────────────────────────────────────────────── + + describe('service-account authentication', () => { + /** Split a JWT and decode its header and claims. */ + const decodeJwt = (jwt: string) => { + const [header, claims, signature] = jwt.split('.'); + return { + header: JSON.parse(Buffer.from(header, 'base64url').toString()), + claims: JSON.parse(Buffer.from(claims, 'base64url').toString()), + signingInput: `${header}.${claims}`, + signature, + }; + }; + + /** Perform one send and return the JWT presented to the token endpoint. */ + const captureAssertion = async (): Promise => { + stubTransport(async () => ({ data: {} })); + await createProvider().send({ tokens: ['t1'], title: 'T', body: 'B', data: {} }); + + const tokenCall = mockedAxios.post.mock.calls.find(([url]) => url === TOKEN_URL); + return new URLSearchParams(tokenCall?.[1] as string).get('assertion') as string; + }; + + it('signs the assertion with the service-account private key', async () => { + const { signingInput, signature } = decodeJwt(await captureAssertion()); + + const verified = crypto + .createVerify('RSA-SHA256') + .update(signingInput) + .verify(publicKey, Buffer.from(signature, 'base64url')); + + expect(verified).toBe(true); + }); + + it('declares RS256 in the JWT header', async () => { + expect(decodeJwt(await captureAssertion()).header).toEqual({ alg: 'RS256', typ: 'JWT' }); + }); + + it('requests the firebase.messaging scope for the service account', async () => { + const { claims } = decodeJwt(await captureAssertion()); + + expect(claims.iss).toBe(CLIENT_EMAIL); + expect(claims.aud).toBe(TOKEN_URL); + expect(claims.scope).toBe('https://www.googleapis.com/auth/firebase.messaging'); + }); + + it('issues a JWT that expires within an hour', async () => { + const { claims } = decodeJwt(await captureAssertion()); + expect(claims.exp - claims.iat).toBe(3600); + }); + + it('normalises escaped newlines in a key read from the environment', async () => { + // .env files carry PEM keys with literal \n sequences rather than real + // newlines; without normalisation the key would fail to parse. + const escaped = privateKey.replace(/\n/g, '\\n'); + stubTransport(async () => ({ data: {} })); + + const result = await createProvider(escaped).send({ + tokens: ['t1'], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result.acceptedCount).toBe(1); + }); + + it('reuses a cached access token across sends', async () => { + stubTransport(async () => ({ data: {} })); + const provider = createProvider(); + + await provider.send({ tokens: ['t1'], title: 'T', body: 'B', data: {} }); + await provider.send({ tokens: ['t2'], title: 'T', body: 'B', data: {} }); + + const tokenCalls = mockedAxios.post.mock.calls.filter(([url]) => url === TOKEN_URL); + expect(tokenCalls).toHaveLength(1); + }); + + it('fetches the access token once for concurrent sends', async () => { + stubTransport(async () => ({ data: {} })); + const provider = createProvider(); + + await Promise.all([ + provider.send({ tokens: ['t1'], title: 'T', body: 'B', data: {} }), + provider.send({ tokens: ['t2'], title: 'T', body: 'B', data: {} }), + provider.send({ tokens: ['t3'], title: 'T', body: 'B', data: {} }), + ]); + + const tokenCalls = mockedAxios.post.mock.calls.filter(([url]) => url === TOKEN_URL); + expect(tokenCalls).toHaveLength(1); + }); + + it('reports an authentication failure without throwing', async () => { + mockedAxios.post.mockImplementation(async (url: string) => { + if (url === TOKEN_URL) throw new Error('invalid_grant'); + return { data: {} }; + }); + + const result = await createProvider().send({ + tokens: ['t1', 't2'], + title: 'T', + body: 'B', + data: {}, + }); + + expect(result.acceptedCount).toBe(0); + expect(result.rejectedCount).toBe(2); + expect(result.failureReason).toMatch(/authentication failed/i); + // The tokens are still valid; only the credentials failed. + expect(result.invalidTokens).toEqual([]); + }); + + it('does not send when authentication fails', async () => { + mockedAxios.post.mockImplementation(async (url: string) => { + if (url === TOKEN_URL) throw new Error('invalid_grant'); + return { data: {} }; + }); + + await createProvider().send({ tokens: ['t1'], title: 'T', body: 'B', data: {} }); + + const sendCalls = mockedAxios.post.mock.calls.filter(([url]) => url !== TOKEN_URL); + expect(sendCalls).toHaveLength(0); + }); + }); +}); diff --git a/tests/notificationService.test.ts b/tests/notificationService.test.ts new file mode 100644 index 0000000..0a12bde --- /dev/null +++ b/tests/notificationService.test.ts @@ -0,0 +1,410 @@ +/** + * Unit tests for NotificationService. + * + * Runs against a real in-process MongoDB so preference upserts, token + * ownership transfer and the audit log are exercised for real. The push + * transport is the one substituted component: it is an external HTTP service, + * so a recording stub implementing IPushProvider stands in for it. + */ + +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import Delivery, { DeliveryStatus, IDelivery } from '../src/models/Delivery'; +import NotificationPreference, { NotificationEvent } from '../src/models/NotificationPreference'; +import Notification, { NotificationStatus } from '../src/models/Notification'; +import { NotificationService } from '../src/services/notificationService'; +import { IPushProvider, PushMessage, PushResult } from '../src/services/push/pushProvider'; + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +/** + * Recording push transport. + * + * Captures what would have been sent and returns a configurable result, so + * tests can assert on payload contents and simulate provider failures. + */ +class StubPushProvider implements IPushProvider { + public readonly name = 'stub'; + public readonly sent: PushMessage[] = []; + + constructor( + private result: PushResult = { acceptedCount: 1, rejectedCount: 0, invalidTokens: [] }, + private configured = true, + ) {} + + isConfigured(): boolean { + return this.configured; + } + + async send(message: PushMessage): Promise { + this.sent.push(message); + // Mirror the real provider: one accepted token per target unless the test + // has pinned an explicit result. + return { ...this.result, acceptedCount: this.result.acceptedCount * message.tokens.length }; + } + + setResult(result: PushResult): void { + this.result = result; + } +} + +/** A push transport that always throws, for failure-path coverage. */ +class ThrowingPushProvider implements IPushProvider { + public readonly name = 'throwing'; + isConfigured(): boolean { + return true; + } + async send(): Promise { + throw new Error('provider exploded'); + } +} + +describe('NotificationService', () => { + let mongod: MongoMemoryServer; + let provider: StubPushProvider; + let service: NotificationService; + + const userId = new Types.ObjectId().toHexString(); + + /** Create a delivery owned by `userId`, in the given status. */ + const createDelivery = async (status = DeliveryStatus.PENDING): Promise => + Delivery.create({ + trackingNumber: `TRK-${new Types.ObjectId().toHexString().slice(-8)}`, + status, + sender: new Types.ObjectId(userId), + customer: { name: 'Ada', phone: '+2348000000000' }, + pickup: { address: 'A' }, + dropoff: { address: 'B' }, + package: { description: 'Docs', weight: 1 }, + deliveryFee: 100, + escrowAmount: 500, + }); + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + }, 60_000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); + }, 30_000); + + beforeEach(() => { + provider = new StubPushProvider(); + service = new NotificationService(undefined, undefined, provider); + }); + + afterEach(async () => { + await Promise.all([ + NotificationPreference.deleteMany({}), + Notification.deleteMany({}), + Delivery.deleteMany({}), + ]); + }); + + // ── Preferences ─────────────────────────────────────────────────────────── + + describe('getPreferences', () => { + it('creates default preferences on first access', async () => { + const preference = await service.getPreferences(userId); + + expect(preference.pushEnabled).toBe(true); + expect(preference.enabledEvents).toEqual( + expect.arrayContaining(Object.values(NotificationEvent)), + ); + expect(preference.devices).toHaveLength(0); + }); + + it('returns the same document on repeated access', async () => { + const first = await service.getPreferences(userId); + const second = await service.getPreferences(userId); + + expect(String(first._id)).toBe(String(second._id)); + await expect(NotificationPreference.countDocuments({})).resolves.toBe(1); + }); + + it('does not create duplicates under concurrent first access', async () => { + await Promise.all([ + service.getPreferences(userId), + service.getPreferences(userId), + service.getPreferences(userId), + ]); + + await expect(NotificationPreference.countDocuments({ user: userId })).resolves.toBe(1); + }); + + it('rejects a malformed user id', async () => { + await expect(service.getPreferences('not-an-object-id')).rejects.toThrow(/Invalid user ID/); + }); + }); + + describe('updatePreferences', () => { + it('disables push notifications', async () => { + const updated = await service.updatePreferences(userId, { pushEnabled: false }); + expect(updated.pushEnabled).toBe(false); + }); + + it('narrows the subscribed event list', async () => { + const updated = await service.updatePreferences(userId, { + enabledEvents: [NotificationEvent.DELIVERY_COMPLETED], + }); + + expect(updated.enabledEvents).toEqual([NotificationEvent.DELIVERY_COMPLETED]); + }); + + it('creates the document when none exists yet', async () => { + await expect(NotificationPreference.countDocuments({})).resolves.toBe(0); + await service.updatePreferences(userId, { pushEnabled: false }); + await expect(NotificationPreference.countDocuments({})).resolves.toBe(1); + }); + }); + + // ── Device registration ─────────────────────────────────────────────────── + + describe('registerDevice', () => { + it('registers a device token', async () => { + const preference = await service.registerDevice({ + userId, + token: 'token-1', + platform: 'android', + }); + + expect(preference.devices).toHaveLength(1); + expect(preference.devices[0].token).toBe('token-1'); + expect(preference.devices[0].platform).toBe('android'); + }); + + it('refreshes rather than duplicates an existing token', async () => { + await service.registerDevice({ userId, token: 'token-1', platform: 'android' }); + const preference = await service.registerDevice({ + userId, + token: 'token-1', + platform: 'ios', + }); + + expect(preference.devices).toHaveLength(1); + expect(preference.devices[0].platform).toBe('ios'); + }); + + it('detaches a token from its previous owner', async () => { + const otherUserId = new Types.ObjectId().toHexString(); + + await service.registerDevice({ userId: otherUserId, token: 'shared', platform: 'ios' }); + await service.registerDevice({ userId, token: 'shared', platform: 'ios' }); + + const previousOwner = await NotificationPreference.findOne({ user: otherUserId }); + const newOwner = await NotificationPreference.findOne({ user: userId }); + + // Without the detach, the previous owner would keep receiving pushes + // meant for whoever now holds the device. + expect(previousOwner?.devices).toHaveLength(0); + expect(newOwner?.devices).toHaveLength(1); + }); + + it('unregisters a device token', async () => { + await service.registerDevice({ userId, token: 'token-1', platform: 'web' }); + const preference = await service.unregisterDevice(userId, 'token-1'); + + expect(preference.devices).toHaveLength(0); + }); + }); + + // ── Delivery transition notifications ───────────────────────────────────── + + describe('notifyDeliveryTransition', () => { + beforeEach(async () => { + await service.registerDevice({ userId, token: 'token-1', platform: 'android' }); + }); + + it.each([ + [DeliveryStatus.PENDING, NotificationEvent.DELIVERY_PENDING], + [DeliveryStatus.IN_PROGRESS, NotificationEvent.DELIVERY_IN_PROGRESS], + [DeliveryStatus.COMPLETED, NotificationEvent.DELIVERY_COMPLETED], + ])('sends a notification for the %s transition', async (status, expectedEvent) => { + const delivery = await createDelivery(); + const records = await service.notifyDeliveryTransition(delivery, status); + + expect(records).toHaveLength(1); + expect(records[0].event).toBe(expectedEvent); + expect(records[0].status).toBe(NotificationStatus.SENT); + expect(provider.sent).toHaveLength(1); + expect(provider.sent[0].tokens).toEqual(['token-1']); + }); + + it('includes the delivery id and status in the push payload', async () => { + const delivery = await createDelivery(); + await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + expect(provider.sent[0].data).toMatchObject({ + deliveryId: String(delivery._id), + status: DeliveryStatus.COMPLETED, + trackingNumber: delivery.trackingNumber, + }); + }); + + it('does not notify for a status with no user-facing event', async () => { + const delivery = await createDelivery(); + const records = await service.notifyDeliveryTransition(delivery, DeliveryStatus.FUNDED); + + expect(records).toEqual([]); + expect(provider.sent).toHaveLength(0); + }); + + it('records a skip when the user has disabled push', async () => { + await service.updatePreferences(userId, { pushEnabled: false }); + const delivery = await createDelivery(); + + const records = await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + expect(records[0].status).toBe(NotificationStatus.SKIPPED); + expect(records[0].failureReason).toMatch(/disabled push/i); + expect(provider.sent).toHaveLength(0); + }); + + it('records a skip when the user has opted out of that event', async () => { + await service.updatePreferences(userId, { + enabledEvents: [NotificationEvent.DELIVERY_PENDING], + }); + const delivery = await createDelivery(); + + const records = await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + expect(records[0].status).toBe(NotificationStatus.SKIPPED); + expect(records[0].failureReason).toMatch(/opted out/i); + expect(provider.sent).toHaveLength(0); + }); + + it('records a skip when the user has no registered devices', async () => { + await service.unregisterDevice(userId, 'token-1'); + const delivery = await createDelivery(); + + const records = await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + expect(records[0].status).toBe(NotificationStatus.SKIPPED); + expect(records[0].failureReason).toMatch(/no registered devices/i); + }); + + it('prunes tokens the provider reports as permanently invalid', async () => { + provider.setResult({ + acceptedCount: 0, + rejectedCount: 1, + invalidTokens: ['token-1'], + }); + const delivery = await createDelivery(); + + await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + const preference = await NotificationPreference.findOne({ user: userId }); + expect(preference?.devices).toHaveLength(0); + }); + + it('records a failure when the provider throws, without rethrowing', async () => { + const throwingService = new NotificationService( + undefined, + undefined, + new ThrowingPushProvider(), + ); + const delivery = await createDelivery(); + + const records = await throwingService.notifyDeliveryTransition( + delivery, + DeliveryStatus.COMPLETED, + ); + + expect(records[0].status).toBe(NotificationStatus.FAILED); + expect(records[0].failureReason).toMatch(/provider exploded/); + }); + + it('notifies both the sender and the driver, without duplicates', async () => { + const driverId = new Types.ObjectId().toHexString(); + await service.registerDevice({ userId: driverId, token: 'driver-token', platform: 'ios' }); + + const delivery = await createDelivery(); + delivery.driverId = driverId; + await delivery.save(); + + const records = await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + expect(records).toHaveLength(2); + expect(new Set(records.map((record) => String(record.user)))).toEqual( + new Set([userId, driverId]), + ); + }); + + it('notifies a user once when they are both sender and driver', async () => { + const delivery = await createDelivery(); + delivery.driverId = userId; + await delivery.save(); + + const records = await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + expect(records).toHaveLength(1); + }); + + it('ignores non-ObjectId driver identifiers', async () => { + const delivery = await createDelivery(); + delivery.driverId = 'legacy-driver-slug'; + await delivery.save(); + + const records = await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + // Only the sender is notifiable; the free-form driver id is not a user. + expect(records).toHaveLength(1); + expect(String(records[0].user)).toBe(userId); + }); + }); + + // ── History ─────────────────────────────────────────────────────────────── + + describe('listForUser', () => { + it('returns notifications newest first', async () => { + await service.registerDevice({ userId, token: 'token-1', platform: 'android' }); + const delivery = await createDelivery(); + + await service.notifyDeliveryTransition(delivery, DeliveryStatus.PENDING); + await service.notifyDeliveryTransition(delivery, DeliveryStatus.IN_PROGRESS); + await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + const page = await service.listForUser(userId, 1, 10); + + expect(page.total).toBe(3); + expect(page.data[0].event).toBe(NotificationEvent.DELIVERY_COMPLETED); + }); + + it('paginates the history', async () => { + await service.registerDevice({ userId, token: 'token-1', platform: 'android' }); + const delivery = await createDelivery(); + + await service.notifyDeliveryTransition(delivery, DeliveryStatus.PENDING); + await service.notifyDeliveryTransition(delivery, DeliveryStatus.COMPLETED); + + const page = await service.listForUser(userId, 1, 1); + + expect(page.data).toHaveLength(1); + expect(page.total).toBe(2); + expect(page.totalPages).toBe(2); + }); + }); + + describe('getProviderStatus', () => { + it('reports the provider name and whether it is configured', () => { + expect(service.getProviderStatus()).toEqual({ provider: 'stub', configured: true }); + }); + + it('reports an unconfigured provider', () => { + const unconfigured = new NotificationService( + undefined, + undefined, + new StubPushProvider(undefined, false), + ); + + expect(unconfigured.getProviderStatus().configured).toBe(false); + }); + }); +}); diff --git a/tests/repositories.test.ts b/tests/repositories.test.ts new file mode 100644 index 0000000..7401045 --- /dev/null +++ b/tests/repositories.test.ts @@ -0,0 +1,444 @@ +/** + * Unit tests for the repository layer. + * + * Runs against a real in-process MongoDB (mongodb-memory-server) rather than + * mocking Mongoose, so index constraints, validators and the conditional + * update semantics the repositories rely on are genuinely exercised. + */ + +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import Delivery, { DeliveryStatus } from '../src/models/Delivery'; +import User from '../src/models/User'; +import Escrow, { EscrowLockStatus } from '../src/models/Escrow'; +import { UserRole, UserStatus } from '../src/interfaces/IUser'; +import { DeliveryRepository } from '../src/repositories/DeliveryRepository'; +import { UserRepository } from '../src/repositories/UserRepository'; +import { EscrowRepository } from '../src/repositories/EscrowRepository'; + +jest.mock('../src/config/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +})); + +/** Build a valid delivery payload, overridable per test. */ +const deliveryPayload = (overrides: Record = {}) => ({ + trackingNumber: `TRK-${new Types.ObjectId().toHexString().slice(-8)}`, + status: DeliveryStatus.PENDING, + customer: { name: 'Ada Lovelace', phone: '+2348000000000' }, + pickup: { address: '1 Pickup Road' }, + dropoff: { address: '2 Dropoff Avenue' }, + package: { description: 'Documents', weight: 1.5 }, + deliveryFee: 1000, + escrowAmount: 5000, + ...overrides, +}); + +/** Build a valid user payload, overridable per test. */ +const userPayload = (overrides: Record = {}) => ({ + email: `user-${new Types.ObjectId().toHexString()}@example.com`, + password: 'sup3rSecretPassword', + firstName: 'Ada', + lastName: 'Lovelace', + role: UserRole.USER, + ...overrides, +}); + +describe('Repository layer', () => { + let mongod: MongoMemoryServer; + + beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + }, 60_000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); + }, 30_000); + + afterEach(async () => { + await Promise.all([Delivery.deleteMany({}), User.deleteMany({}), Escrow.deleteMany({})]); + }); + + // ── BaseRepository behaviour, exercised through DeliveryRepository ───────── + + describe('BaseRepository', () => { + const repository = new DeliveryRepository(); + + it('creates and retrieves a document by id', async () => { + const created = await repository.create(deliveryPayload()); + const found = await repository.findById(String(created._id)); + + expect(found).not.toBeNull(); + expect(found?.trackingNumber).toBe(created.trackingNumber); + }); + + it('returns null for a malformed id instead of throwing a CastError', async () => { + await expect(repository.findById('not-an-object-id')).resolves.toBeNull(); + }); + + it('returns false when deleting a malformed id', async () => { + await expect(repository.deleteById('not-an-object-id')).resolves.toBe(false); + }); + + it('returns null when updating a malformed id', async () => { + await expect( + repository.updateById('not-an-object-id', { $set: { deliveryFee: 1 } }), + ).resolves.toBeNull(); + }); + + it('creates many documents in one call', async () => { + const created = await repository.createMany([ + deliveryPayload(), + deliveryPayload(), + deliveryPayload(), + ]); + + expect(created).toHaveLength(3); + await expect(repository.count({})).resolves.toBe(3); + }); + + it('returns an empty array when createMany is given no documents', async () => { + await expect(repository.createMany([])).resolves.toEqual([]); + }); + + it('clamps pagination to sane bounds', async () => { + await repository.createMany([deliveryPayload(), deliveryPayload()]); + + // Page 0 and a negative limit would otherwise produce a negative skip. + const page = await repository.paginate({}, 0, -5); + + expect(page.page).toBe(1); + expect(page.limit).toBeGreaterThan(0); + expect(page.total).toBe(2); + }); + + it('caps the page size at 100 to prevent unbounded reads', async () => { + const page = await repository.paginate({}, 1, 5000); + expect(page.limit).toBe(100); + }); + + it('reports existence correctly', async () => { + const created = await repository.create(deliveryPayload()); + + await expect(repository.exists({ trackingNumber: created.trackingNumber })).resolves.toBe( + true, + ); + await expect(repository.exists({ trackingNumber: 'MISSING' })).resolves.toBe(false); + }); + + it('deletes a document by id', async () => { + const created = await repository.create(deliveryPayload()); + + await expect(repository.deleteById(String(created._id))).resolves.toBe(true); + await expect(repository.findById(String(created._id))).resolves.toBeNull(); + }); + }); + + // ── DeliveryRepository ──────────────────────────────────────────────────── + + describe('DeliveryRepository', () => { + const repository = new DeliveryRepository(); + + it('finds a delivery by tracking number', async () => { + const created = await repository.create(deliveryPayload({ trackingNumber: 'TRK-FIND' })); + const found = await repository.findByTrackingNumber('TRK-FIND'); + + expect(String(found?._id)).toBe(String(created._id)); + }); + + it('resolves existing tracking numbers in a single query', async () => { + await repository.create(deliveryPayload({ trackingNumber: 'TRK-A' })); + await repository.create(deliveryPayload({ trackingNumber: 'TRK-B' })); + + const existing = await repository.findExistingTrackingNumbers([ + 'TRK-A', + 'TRK-B', + 'TRK-MISSING', + ]); + + expect(existing.has('TRK-A')).toBe(true); + expect(existing.has('TRK-B')).toBe(true); + expect(existing.has('TRK-MISSING')).toBe(false); + expect(existing.size).toBe(2); + }); + + it('returns an empty set when asked about no tracking numbers', async () => { + await expect(repository.findExistingTrackingNumbers([])).resolves.toEqual(new Set()); + }); + + it('filters by status', async () => { + await repository.create(deliveryPayload({ status: DeliveryStatus.PENDING })); + await repository.create(deliveryPayload({ status: DeliveryStatus.COMPLETED })); + + const page = await repository.listPaginated({ status: DeliveryStatus.COMPLETED }, 1, 10); + + expect(page.total).toBe(1); + expect(page.data[0].status).toBe(DeliveryStatus.COMPLETED); + }); + + it('treats regex metacharacters in search as literal text', async () => { + await repository.create(deliveryPayload({ trackingNumber: 'TRK-A.B' })); + await repository.create(deliveryPayload({ trackingNumber: 'TRK-AXB' })); + + // An unescaped '.' would match both; escaped, it matches only the literal. + const page = await repository.listPaginated({ search: 'TRK-A.B' }, 1, 10); + + expect(page.total).toBe(1); + expect(page.data[0].trackingNumber).toBe('TRK-A.B'); + }); + + it('finds deliveries assigned to a driver', async () => { + const driverId = new Types.ObjectId().toHexString(); + await repository.create(deliveryPayload({ driverId })); + await repository.create(deliveryPayload()); + + const found = await repository.findByDriver(driverId); + + expect(found).toHaveLength(1); + expect(found[0].driverId).toBe(driverId); + }); + + describe('transitionStatus', () => { + it('advances a delivery when it is in the expected state', async () => { + const created = await repository.create( + deliveryPayload({ status: DeliveryStatus.ASSIGNED }), + ); + + const updated = await repository.transitionStatus( + String(created._id), + DeliveryStatus.ASSIGNED, + DeliveryStatus.IN_PROGRESS, + ); + + expect(updated?.status).toBe(DeliveryStatus.IN_PROGRESS); + }); + + it('refuses to advance from an unexpected state', async () => { + const created = await repository.create( + deliveryPayload({ status: DeliveryStatus.PENDING }), + ); + + const updated = await repository.transitionStatus( + String(created._id), + DeliveryStatus.IN_PROGRESS, + DeliveryStatus.COMPLETED, + ); + + expect(updated).toBeNull(); + const unchanged = await repository.findById(String(created._id)); + expect(unchanged?.status).toBe(DeliveryStatus.PENDING); + }); + + it('lets only one of two concurrent transitions win', async () => { + const created = await repository.create( + deliveryPayload({ status: DeliveryStatus.ASSIGNED }), + ); + + const [first, second] = await Promise.all([ + repository.transitionStatus( + String(created._id), + DeliveryStatus.ASSIGNED, + DeliveryStatus.IN_PROGRESS, + ), + repository.transitionStatus( + String(created._id), + DeliveryStatus.ASSIGNED, + DeliveryStatus.IN_PROGRESS, + ), + ]); + + // Exactly one update matched the document; the other found nothing. + expect([first, second].filter((result) => result !== null)).toHaveLength(1); + }); + + it('accepts a list of permitted source states', async () => { + const created = await repository.create(deliveryPayload({ status: DeliveryStatus.FUNDED })); + + const updated = await repository.transitionStatus( + String(created._id), + [DeliveryStatus.PENDING, DeliveryStatus.FUNDED], + DeliveryStatus.ASSIGNED, + ); + + expect(updated?.status).toBe(DeliveryStatus.ASSIGNED); + }); + }); + }); + + // ── UserRepository ──────────────────────────────────────────────────────── + + describe('UserRepository', () => { + const repository = new UserRepository(); + + it('omits the password hash from ordinary reads', async () => { + const created = await repository.create(userPayload({ email: 'ada@example.com' })); + const found = await repository.findByEmail('ada@example.com'); + + expect(String(found?._id)).toBe(String(created._id)); + expect(found?.password).toBeUndefined(); + }); + + it('includes the password hash only when explicitly requested', async () => { + await repository.create(userPayload({ email: 'grace@example.com' })); + const found = await repository.findByEmailWithPassword('grace@example.com'); + + expect(found?.password).toBeDefined(); + // Stored as a bcrypt hash, never as the plaintext. + expect(found?.password).not.toBe('sup3rSecretPassword'); + }); + + it('normalises email casing and whitespace on lookup', async () => { + await repository.create(userPayload({ email: 'mixed@example.com' })); + + await expect(repository.findByEmail(' MIXED@example.com ')).resolves.not.toBeNull(); + await expect(repository.emailExists('MiXeD@ExAmPlE.com')).resolves.toBe(true); + }); + + it('resolves several users by id, ignoring malformed ids', async () => { + const first = await repository.create(userPayload()); + const second = await repository.create(userPayload()); + + const found = await repository.findByIds([ + String(first._id), + String(second._id), + 'not-an-object-id', + ]); + + expect(found).toHaveLength(2); + }); + + it('returns an empty array when every id is malformed', async () => { + await expect(repository.findByIds(['bad', 'worse'])).resolves.toEqual([]); + }); + + it('suspends and reactivates an account', async () => { + const created = await repository.create(userPayload()); + + const suspended = await repository.suspend(String(created._id), 'Policy violation'); + expect(suspended?.status).toBe(UserStatus.SUSPENDED); + expect(suspended?.suspendedReason).toBe('Policy violation'); + expect(suspended?.isActive).toBe(false); + + const reactivated = await repository.reactivate(String(created._id)); + expect(reactivated?.status).toBe(UserStatus.ACTIVE); + expect(reactivated?.isActive).toBe(true); + expect(reactivated?.suspendedReason).toBeUndefined(); + }); + + it('finds users by role', async () => { + await repository.create(userPayload({ role: UserRole.DRIVER })); + await repository.create(userPayload({ role: UserRole.USER })); + + const drivers = await repository.findByRole(UserRole.DRIVER); + + expect(drivers).toHaveLength(1); + expect(drivers[0].role).toBe(UserRole.DRIVER); + }); + }); + + // ── EscrowRepository ────────────────────────────────────────────────────── + + describe('EscrowRepository', () => { + const repository = new EscrowRepository(); + const deliveries = new DeliveryRepository(); + + /** Create an escrow attached to a freshly created delivery. */ + const createEscrow = async (overrides: Record = {}) => { + const delivery = await deliveries.create(deliveryPayload()); + return repository.create({ + delivery: delivery._id as Types.ObjectId, + contractId: `C${new Types.ObjectId().toHexString()}`, + amount: 5000, + asset: 'USDC', + lockStatus: EscrowLockStatus.PENDING, + transactions: [], + ...overrides, + }); + }; + + it('finds an escrow by its delivery', async () => { + const escrow = await createEscrow(); + const found = await repository.findByDeliveryId(String(escrow.delivery)); + + expect(String(found?._id)).toBe(String(escrow._id)); + }); + + it('returns null for a malformed delivery id', async () => { + await expect(repository.findByDeliveryId('not-an-object-id')).resolves.toBeNull(); + }); + + it('detects an already-recorded transaction hash', async () => { + const escrow = await createEscrow(); + await repository.appendTransaction(String(escrow._id), { + hash: 'hash-1', + type: 'fund', + recordedAt: new Date(), + }); + + await expect(repository.transactionHashExists('hash-1')).resolves.toBe(true); + await expect(repository.transactionHashExists('hash-unknown')).resolves.toBe(false); + }); + + it('transitions lock status and stamps the lifecycle timestamp', async () => { + const escrow = await createEscrow({ lockStatus: EscrowLockStatus.PENDING }); + + const locked = await repository.transitionLockStatus( + String(escrow._id), + [EscrowLockStatus.PENDING], + EscrowLockStatus.LOCKED, + 'lockedAt', + ); + + expect(locked?.lockStatus).toBe(EscrowLockStatus.LOCKED); + expect(locked?.lockedAt).toBeInstanceOf(Date); + }); + + it('refuses a transition from an unexpected lock status', async () => { + const escrow = await createEscrow({ lockStatus: EscrowLockStatus.RELEASED }); + + const result = await repository.transitionLockStatus( + String(escrow._id), + [EscrowLockStatus.LOCKED], + EscrowLockStatus.RELEASED, + 'releasedAt', + ); + + expect(result).toBeNull(); + }); + + it('lets only one of two concurrent releases succeed', async () => { + const escrow = await createEscrow({ lockStatus: EscrowLockStatus.LOCKED }); + + const [first, second] = await Promise.all([ + repository.transitionLockStatus( + String(escrow._id), + [EscrowLockStatus.LOCKED], + EscrowLockStatus.RELEASED, + 'releasedAt', + ), + repository.transitionLockStatus( + String(escrow._id), + [EscrowLockStatus.LOCKED], + EscrowLockStatus.RELEASED, + 'releasedAt', + ), + ]); + + expect([first, second].filter((result) => result !== null)).toHaveLength(1); + }); + + it('finds escrows by lock status', async () => { + await createEscrow({ lockStatus: EscrowLockStatus.LOCKED }); + await createEscrow({ lockStatus: EscrowLockStatus.PENDING }); + + const locked = await repository.findByLockStatus(EscrowLockStatus.LOCKED); + + expect(locked).toHaveLength(1); + expect(locked[0].lockStatus).toBe(EscrowLockStatus.LOCKED); + }); + }); +});