From 87591e1f263f17d11d93754ac42fa9907282bf39 Mon Sep 17 00:00:00 2001 From: Adeolu01 Date: Sun, 30 Aug 2026 02:15:21 +0100 Subject: [PATCH 1/2] feat: add repository layer, push notifications, bulk CSV import, socket refactor Implements four related backend changes. The repository layer lands first because the notification and bulk-import features are built on top of it. Repository pattern (#118) Adds src/repositories/ with a generic BaseRepository over Mongoose plus concrete Delivery, User, Escrow, NotificationPreference, Notification and ChatMessage repositories. Services depend on the IRepository contract rather than on models directly. Malformed ObjectId strings resolve to null instead of throwing a CastError, so a bad path parameter surfaces as a 404 rather than a 500. Pagination is clamped to prevent negative skips and unbounded reads. Status transitions assert the expected prior state in the query filter, so two concurrent transitions cannot both succeed. Push notifications (#131) Adds a provider-agnostic push transport (IPushProvider) with a Firebase Cloud Messaging implementation over the HTTP v1 API, authenticated by a service-account JWT exchanged for a cached OAuth2 access token. No new runtime dependency is introduced; the existing axios client and Node's crypto module are used. NotificationService resolves per-user preferences, dispatches to the registered devices and records every attempt, including opt-out skips and provider failures, so the notification history is answerable from the database. Tokens the provider reports as permanently invalid are pruned. Registering a token already held by another account detaches it from the previous owner, so a handed-over device does not keep receiving the old owner's deliveries. DeliveryService.updateStatus encodes the delivery state machine and fires notifications after the write commits. Push failures never roll back a committed status transition. Endpoints, all authenticated and scoped to the calling user: GET /api/v1/notifications GET /api/v1/notifications/preferences PATCH /api/v1/notifications/preferences POST /api/v1/notifications/devices DELETE /api/v1/notifications/devices Push sending is disabled when FCM credentials are absent; sends are then recorded as skipped rather than reported as sent. Bulk CSV import (#132) Adds POST /api/v1/deliveries/bulk accepting multipart/form-data. Includes an in-repo RFC 4180 parser supporting quoted fields, escaped quotes, embedded newlines and CRLF, tracking each row's source line number for error reporting. Partial success is the expected outcome: valid rows are inserted with an unordered insertMany and every rejected row is reported with its original line number and reason. Duplicate tracking numbers are detected both within the file and against existing records, the latter in a single query. Responds 201 when every row imported, 207 on partial success and 422 when the file parsed but no row could be imported. Socket handler decoupling (#117) Extracts chat business logic out of the Socket.IO event listeners into ChatMessageService, which takes and returns plain values and touches no socket, namespace or event name. socketService.ts becomes a thin transport adapter and socketController.ts only registers listeners. Socket payloads bypass Express middleware, so the extracted service is now the validation boundary for realtime input; invalid messages are rejected with a reason sent to the originating socket instead of failing silently. Testing Adds 148 tests across six suites, run against a real in-process MongoDB via mongodb-memory-server rather than mocked Mongoose. Coverage includes the concurrency guarantees of the conditional status updates, CSV edge cases, partial-failure imports, notification opt-out and token-pruning paths, and the delivery state machine. Introduces no new type errors: the repository reports 75 pre-existing tsc errors both before and after this change. --- src/config/env.ts | 29 ++ src/controllers/bulkDeliveryController.ts | 105 ++++ src/controllers/notificationController.ts | 203 ++++++++ src/models/Notification.ts | 82 ++++ src/models/NotificationPreference.ts | 90 ++++ src/repositories/BaseRepository.ts | 184 +++++++ src/repositories/ChatMessageRepository.ts | 22 + src/repositories/DeliveryRepository.ts | 156 ++++++ src/repositories/EscrowRepository.ts | 97 ++++ .../NotificationPreferenceRepository.ts | 158 ++++++ src/repositories/NotificationRepository.ts | 34 ++ src/repositories/UserRepository.ts | 83 ++++ src/repositories/index.ts | 24 + src/repositories/types.ts | 80 ++++ src/routes/bulkDeliveryRoutes.ts | 84 ++++ src/routes/index.ts | 6 + src/routes/notificationRoutes.ts | 63 +++ src/services/bulkDeliveryService.ts | 359 ++++++++++++++ src/services/delivery.service.ts | 88 ++++ src/services/notificationService.ts | 343 +++++++++++++ src/services/push/fcmProvider.ts | 254 ++++++++++ src/services/push/pushProvider.ts | 47 ++ src/sockets/chatMessage.service.ts | 110 +++++ src/sockets/socketController.ts | 21 +- src/sockets/socketService.ts | 75 ++- src/utils/csvParser.ts | 188 ++++++++ tests/bulkDeliveryService.test.ts | 308 ++++++++++++ tests/chatMessage.service.test.ts | 278 +++++++++++ tests/csvParser.test.ts | 113 +++++ tests/deliveryStatusTransition.test.ts | 248 ++++++++++ tests/notificationService.test.ts | 412 ++++++++++++++++ tests/repositories.test.ts | 450 ++++++++++++++++++ 32 files changed, 4764 insertions(+), 30 deletions(-) create mode 100644 src/controllers/bulkDeliveryController.ts create mode 100644 src/controllers/notificationController.ts create mode 100644 src/models/Notification.ts create mode 100644 src/models/NotificationPreference.ts create mode 100644 src/repositories/BaseRepository.ts create mode 100644 src/repositories/ChatMessageRepository.ts create mode 100644 src/repositories/DeliveryRepository.ts create mode 100644 src/repositories/EscrowRepository.ts create mode 100644 src/repositories/NotificationPreferenceRepository.ts create mode 100644 src/repositories/NotificationRepository.ts create mode 100644 src/repositories/UserRepository.ts create mode 100644 src/repositories/index.ts create mode 100644 src/repositories/types.ts create mode 100644 src/routes/bulkDeliveryRoutes.ts create mode 100644 src/routes/notificationRoutes.ts create mode 100644 src/services/bulkDeliveryService.ts create mode 100644 src/services/notificationService.ts create mode 100644 src/services/push/fcmProvider.ts create mode 100644 src/services/push/pushProvider.ts create mode 100644 src/sockets/chatMessage.service.ts create mode 100644 src/utils/csvParser.ts create mode 100644 tests/bulkDeliveryService.test.ts create mode 100644 tests/chatMessage.service.test.ts create mode 100644 tests/csvParser.test.ts create mode 100644 tests/deliveryStatusTransition.test.ts create mode 100644 tests/notificationService.test.ts create mode 100644 tests/repositories.test.ts diff --git a/src/config/env.ts b/src/config/env.ts index 5a23ae4..27a9c6d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -36,6 +36,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({ @@ -67,6 +86,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..b8bcfce --- /dev/null +++ b/src/controllers/notificationController.ts @@ -0,0 +1,203 @@ +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..f782948 --- /dev/null +++ b/src/models/NotificationPreference.ts @@ -0,0 +1,90 @@ +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. + default: () => 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..19688d8 --- /dev/null +++ b/src/repositories/BaseRepository.ts @@ -0,0 +1,184 @@ +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..37fbc3f --- /dev/null +++ b/src/repositories/EscrowRepository.ts @@ -0,0 +1,97 @@ +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..06dcb2b --- /dev/null +++ b/src/repositories/NotificationPreferenceRepository.ts @@ -0,0 +1,158 @@ +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..c13380e --- /dev/null +++ b/src/repositories/types.ts @@ -0,0 +1,80 @@ +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..8955d09 --- /dev/null +++ b/src/routes/bulkDeliveryRoutes.ts @@ -0,0 +1,84 @@ +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 786a7b6..e9d76dd 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -11,10 +11,15 @@ import disputeRoutes from './disputeRoutes'; import eventLogRoutes from './eventLogRoutes'; import profileRoutes from './profileRoutes'; import healthRoutes from './healthRoutes'; +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 +29,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); export default router; diff --git a/src/routes/notificationRoutes.ts b/src/routes/notificationRoutes.ts new file mode 100644 index 0000000..fdfd6e6 --- /dev/null +++ b/src/routes/notificationRoutes.ts @@ -0,0 +1,63 @@ +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..20b1960 --- /dev/null +++ b/src/services/bulkDeliveryService.ts @@ -0,0 +1,359 @@ +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; + /** Rows rejected, either by validation or by the database. */ + 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); + + logger.info( + `[BulkDeliveryService] Import finished — user=${createdBy} ` + + `rows=${parsed.rows.length} created=${created.length} failed=${errors.length}`, + ); + + return { + totalRows: parsed.rows.length, + successCount: created.length, + failureCount: errors.length, + 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..9c44960 --- /dev/null +++ b/src/services/notificationService.ts @@ -0,0 +1,343 @@ +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..3e826a6 --- /dev/null +++ b/src/services/push/fcmProvider.ts @@ -0,0 +1,254 @@ +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..3f285da --- /dev/null +++ b/src/utils/csvParser.ts @@ -0,0 +1,188 @@ +/** + * 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 { + const text = input.replace(/^/, ''); + + 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..dfa9786 --- /dev/null +++ b/tests/bulkDeliveryService.test.ts @@ -0,0 +1,308 @@ +/** + * 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('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..3f2c0b0 --- /dev/null +++ b/tests/chatMessage.service.test.ts @@ -0,0 +1,278 @@ +/** + * 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..efb39b5 --- /dev/null +++ b/tests/deliveryStatusTransition.test.ts @@ -0,0 +1,248 @@ +/** + * 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/notificationService.test.ts b/tests/notificationService.test.ts new file mode 100644 index 0000000..b626be0 --- /dev/null +++ b/tests/notificationService.test.ts @@ -0,0 +1,412 @@ +/** + * 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..0e5f71c --- /dev/null +++ b/tests/repositories.test.ts @@ -0,0 +1,450 @@ +/** + * 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); + }); + }); +}); From 7f982ad952b05e777b53521f294c166a807ca425 Mon Sep 17 00:00:00 2001 From: Adeolu01 Date: Sun, 30 Aug 2026 02:35:57 +0100 Subject: [PATCH 2/2] fix: count failed rows not errors in bulk import, add FCM provider tests Bulk import reported failureCount as the length of the error list, but a single row raises one error per invalid column. A one-row file with three bad fields reported "3 of 1 rows failed", and the controller's summary message could claim more failures than the file had rows. failureCount is now the number of distinct rejected lines. Covered by two regression tests asserting successCount + failureCount never exceeds totalRows. Adds 27 tests for FcmProvider, the one component that cannot be exercised against the live service. axios is mocked at the HTTP boundary; everything on this side of it is real. The service-account JWT is signed with a generated RSA key pair and verified in-test, covering the RS256 header, the firebase.messaging scope, the one-hour expiry and the escaped-newline normalisation that .env-supplied PEM keys need. Also covers the failure classification that decides whether a device registration survives: UNREGISTERED, INVALID_ARGUMENT and SENDER_ID_MISMATCH prune the token, while transient failures such as INTERNAL, UNAVAILABLE and QUOTA_EXCEEDED leave it registered. Access token caching and single-flight fetching under concurrent sends are asserted, as is the guarantee that an authentication failure reports cleanly without pruning any token. Applies prettier formatting to the files added in the previous commit and annotates the notification preference default as a factory so each document gets its own array. Suite total for this branch: 177 tests across 7 files, all passing. --- src/controllers/notificationController.ts | 5 +- src/models/NotificationPreference.ts | 4 +- src/repositories/BaseRepository.ts | 14 +- src/repositories/EscrowRepository.ts | 6 +- .../NotificationPreferenceRepository.ts | 4 +- src/repositories/types.ts | 13 +- src/routes/bulkDeliveryRoutes.ts | 13 +- src/routes/notificationRoutes.ts | 6 +- src/services/bulkDeliveryService.ts | 23 +- src/services/notificationService.ts | 3 +- src/services/push/fcmProvider.ts | 20 +- src/utils/csvParser.ts | 6 +- tests/bulkDeliveryService.test.ts | 26 ++ tests/chatMessage.service.test.ts | 14 +- tests/deliveryStatusTransition.test.ts | 8 +- tests/fcmProvider.test.ts | 377 ++++++++++++++++++ tests/notificationService.test.ts | 4 +- tests/repositories.test.ts | 10 +- 18 files changed, 467 insertions(+), 89 deletions(-) create mode 100644 tests/fcmProvider.test.ts diff --git a/src/controllers/notificationController.ts b/src/controllers/notificationController.ts index b8bcfce..0975235 100644 --- a/src/controllers/notificationController.ts +++ b/src/controllers/notificationController.ts @@ -160,10 +160,7 @@ export const unregisterDevice = async ( ): Promise => { try { const body = req.body as z.infer; - const preference = await notificationService.unregisterDevice( - requireUserId(req), - body.token, - ); + const preference = await notificationService.unregisterDevice(requireUserId(req), body.token); res.status(StatusCodes.OK).json({ status: 'success', diff --git a/src/models/NotificationPreference.ts b/src/models/NotificationPreference.ts index f782948..5dcb929 100644 --- a/src/models/NotificationPreference.ts +++ b/src/models/NotificationPreference.ts @@ -65,7 +65,9 @@ const NotificationPreferenceSchema = new Schema( type: [String], enum: Object.values(NotificationEvent), // New users are opted into every event; they can narrow this afterwards. - default: () => Object.values(NotificationEvent), + // 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: [] }, }, diff --git a/src/repositories/BaseRepository.ts b/src/repositories/BaseRepository.ts index 19688d8..84c935c 100644 --- a/src/repositories/BaseRepository.ts +++ b/src/repositories/BaseRepository.ts @@ -1,12 +1,4 @@ -import { - Document, - FilterQuery, - Model, - Query, - QueryOptions, - Types, - UpdateQuery, -} from 'mongoose'; +import { Document, FilterQuery, Model, Query, QueryOptions, Types, UpdateQuery } from 'mongoose'; import { IRepository, Page, ReadOptions, WriteOptions } from './types'; /** @@ -176,9 +168,7 @@ export abstract class BaseRepository implements IRepository< async deleteById(id: string, options?: WriteOptions): Promise { if (!this.isValidId(id)) return false; - const result = await this.model - .findByIdAndDelete(id, { session: options?.session }) - .exec(); + const result = await this.model.findByIdAndDelete(id, { session: options?.session }).exec(); return result !== null; } } diff --git a/src/repositories/EscrowRepository.ts b/src/repositories/EscrowRepository.ts index 37fbc3f..320e851 100644 --- a/src/repositories/EscrowRepository.ts +++ b/src/repositories/EscrowRepository.ts @@ -86,11 +86,7 @@ export class EscrowRepository extends BaseRepository { const set: Record = { lockStatus: to }; if (timestampField) set[timestampField] = new Date(); - return this.updateOne( - { _id: id, lockStatus: { $in: expectedFrom } }, - { $set: set }, - options, - ); + return this.updateOne({ _id: id, lockStatus: { $in: expectedFrom } }, { $set: set }, options); } } diff --git a/src/repositories/NotificationPreferenceRepository.ts b/src/repositories/NotificationPreferenceRepository.ts index 06dcb2b..a392e79 100644 --- a/src/repositories/NotificationPreferenceRepository.ts +++ b/src/repositories/NotificationPreferenceRepository.ts @@ -34,9 +34,7 @@ export class NotificationPreferenceRepository extends BaseRepository { + async findOrCreateByUserId(userId: string | Types.ObjectId): Promise { const preference = await this.model .findOneAndUpdate( { user: userId }, diff --git a/src/repositories/types.ts b/src/repositories/types.ts index c13380e..dfed4e4 100644 --- a/src/repositories/types.ts +++ b/src/repositories/types.ts @@ -71,10 +71,19 @@ export interface IRepository { 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>; + 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; + 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 index 8955d09..3e3459a 100644 --- a/src/routes/bulkDeliveryRoutes.ts +++ b/src/routes/bulkDeliveryRoutes.ts @@ -37,10 +37,7 @@ const handleUploadErrors = ( 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, - ), + new AppError(`Uploaded file exceeds the ${limitMb}MB limit.`, StatusCodes.REQUEST_TOO_LONG), ); return; } @@ -73,12 +70,6 @@ const handleUploadErrors = ( * deliveryFee, escrowAmount. * Optional columns: customerEmail, notes. */ -router.post( - '/bulk', - authenticate, - upload.single('file'), - handleUploadErrors, - bulkCreateDeliveries, -); +router.post('/bulk', authenticate, upload.single('file'), handleUploadErrors, bulkCreateDeliveries); export default router; diff --git a/src/routes/notificationRoutes.ts b/src/routes/notificationRoutes.ts index fdfd6e6..4b6bed3 100644 --- a/src/routes/notificationRoutes.ts +++ b/src/routes/notificationRoutes.ts @@ -40,11 +40,7 @@ router.get('/preferences', getPreferences); * @desc Enable/disable push and choose which events to receive * @access Private */ -router.patch( - '/preferences', - validateRequest({ body: updatePreferencesSchema }), - updatePreferences, -); +router.patch('/preferences', validateRequest({ body: updatePreferencesSchema }), updatePreferences); /** * @route POST /api/v1/notifications/devices diff --git a/src/services/bulkDeliveryService.ts b/src/services/bulkDeliveryService.ts index 20b1960..c230e90 100644 --- a/src/services/bulkDeliveryService.ts +++ b/src/services/bulkDeliveryService.ts @@ -73,7 +73,12 @@ export interface BulkImportResult { totalRows: number; /** Rows written to the database. */ successCount: number; - /** Rows rejected, either by validation or by the database. */ + /** + * 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[]; @@ -134,15 +139,21 @@ export class BulkDeliveryService { 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=${errors.length}`, + `rows=${parsed.rows.length} created=${created.length} failed=${failedLines.size}`, ); return { totalRows: parsed.rows.length, successCount: created.length, - failureCount: errors.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), @@ -336,7 +347,11 @@ export class BulkDeliveryService { /** 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 }>; + writeErrors?: Array<{ + index?: number; + err?: { index?: number; errmsg?: string }; + errmsg?: string; + }>; }; if (!Array.isArray(candidate?.writeErrors)) return []; diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 9c44960..dad3ba8 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -182,8 +182,7 @@ export class NotificationService { logger.info(`[NotificationService] Pruned ${pruned} invalid device token(s)`); } - const status = - result.acceptedCount > 0 ? NotificationStatus.SENT : NotificationStatus.FAILED; + const status = result.acceptedCount > 0 ? NotificationStatus.SENT : NotificationStatus.FAILED; return this.record(input, status, result); } catch (error) { diff --git a/src/services/push/fcmProvider.ts b/src/services/push/fcmProvider.ts index 3e826a6..5f98831 100644 --- a/src/services/push/fcmProvider.ts +++ b/src/services/push/fcmProvider.ts @@ -17,11 +17,7 @@ const TOKEN_REFRESH_SKEW_SECONDS = 60; * 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', -]); +const PERMANENT_TOKEN_ERRORS = new Set(['UNREGISTERED', 'INVALID_ARGUMENT', 'SENDER_ID_MISMATCH']); interface CachedAccessToken { token: string; @@ -159,12 +155,14 @@ export class FcmProvider implements IPushProvider { 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 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; diff --git a/src/utils/csvParser.ts b/src/utils/csvParser.ts index 3f285da..33b4de1 100644 --- a/src/utils/csvParser.ts +++ b/src/utils/csvParser.ts @@ -136,7 +136,11 @@ function normaliseHeader(header: string): string { * headers, or exceeds `maxRows`. */ export function parseCsv(input: string, maxRows: number): CsvParseResult { - const text = input.replace(/^/, ''); + // 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'); diff --git a/tests/bulkDeliveryService.test.ts b/tests/bulkDeliveryService.test.ts index dfa9786..eb0f5c6 100644 --- a/tests/bulkDeliveryService.test.ts +++ b/tests/bulkDeliveryService.test.ts @@ -180,6 +180,32 @@ describe('BulkDeliveryService', () => { 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); diff --git a/tests/chatMessage.service.test.ts b/tests/chatMessage.service.test.ts index 3f2c0b0..2e21133 100644 --- a/tests/chatMessage.service.test.ts +++ b/tests/chatMessage.service.test.ts @@ -149,9 +149,7 @@ describe('ChatMessageService', () => { }); it('writes nothing when validation fails', async () => { - await expect(service.createMessage({ content: '' })).rejects.toThrow( - InvalidChatMessageError, - ); + await expect(service.createMessage({ content: '' })).rejects.toThrow(InvalidChatMessageError); await expect(ChatMessage.countDocuments({})).resolves.toBe(0); }); }); @@ -166,11 +164,7 @@ describe('ChatMessageService', () => { const transcript = await service.getRecentTranscript(); - expect(transcript.map((message) => message.content)).toEqual([ - 'first', - 'second', - 'third', - ]); + expect(transcript.map((message) => message.content)).toEqual(['first', 'second', 'third']); }); it('returns the most recent messages when over the limit', async () => { @@ -183,9 +177,7 @@ describe('ChatMessageService', () => { 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}`, - ); + expect(transcript[transcript.length - 1].content).toBe(`message-${RECENT_MESSAGE_LIMIT + 4}`); }); it('honours an explicit limit', async () => { diff --git a/tests/deliveryStatusTransition.test.ts b/tests/deliveryStatusTransition.test.ts index efb39b5..bc7e5f4 100644 --- a/tests/deliveryStatusTransition.test.ts +++ b/tests/deliveryStatusTransition.test.ts @@ -12,9 +12,7 @@ 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 NotificationPreference, { NotificationEvent } from '../src/models/NotificationPreference'; import { DeliveryService } from '../src/services/delivery.service'; import { notificationService } from '../src/services/notificationService'; @@ -60,9 +58,7 @@ describe('DeliveryService.updateStatus', () => { 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([]); + notifySpy = jest.spyOn(notificationService, 'notifyDeliveryTransition').mockResolvedValue([]); }); afterEach(async () => { 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 index b626be0..0a12bde 100644 --- a/tests/notificationService.test.ts +++ b/tests/notificationService.test.ts @@ -10,9 +10,7 @@ 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 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'; diff --git a/tests/repositories.test.ts b/tests/repositories.test.ts index 0e5f71c..7401045 100644 --- a/tests/repositories.test.ts +++ b/tests/repositories.test.ts @@ -60,11 +60,7 @@ describe('Repository layer', () => { }, 30_000); afterEach(async () => { - await Promise.all([ - Delivery.deleteMany({}), - User.deleteMany({}), - Escrow.deleteMany({}), - ]); + await Promise.all([Delivery.deleteMany({}), User.deleteMany({}), Escrow.deleteMany({})]); }); // ── BaseRepository behaviour, exercised through DeliveryRepository ───────── @@ -260,9 +256,7 @@ describe('Repository layer', () => { }); it('accepts a list of permitted source states', async () => { - const created = await repository.create( - deliveryPayload({ status: DeliveryStatus.FUNDED }), - ); + const created = await repository.create(deliveryPayload({ status: DeliveryStatus.FUNDED })); const updated = await repository.transitionStatus( String(created._id),