Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ interface EnvConfig {
SOROBAN_RPC_RETRY_MAX_MS: number;
/** Maximum attempts to retry a transaction that fails with tx_bad_seq. Default: 3 */
STELLAR_BAD_SEQ_MAX_RETRIES: number;

// ── Push notifications (Firebase Cloud Messaging) ───────────────────────────
/**
* Firebase project id. Push sending is disabled when this (or either
* credential below) is blank, so local development runs without Firebase.
*/
FCM_PROJECT_ID: string;
/** Service-account client email used to mint OAuth2 access tokens. */
FCM_CLIENT_EMAIL: string;
/** Service-account private key (PEM; literal `\n` sequences are normalised). */
FCM_PRIVATE_KEY: string;
/** Timeout (ms) for FCM and Google token endpoint requests. Default: 10000 */
FCM_REQUEST_TIMEOUT_MS: number;

// ── Bulk delivery CSV import ────────────────────────────────────────────────
/** Maximum accepted upload size (bytes) for the bulk CSV endpoint. Default: 5MB */
BULK_UPLOAD_MAX_BYTES: number;
/** Maximum data rows accepted in a single bulk upload. Default: 1000 */
BULK_UPLOAD_MAX_ROWS: number;
}

const envSchema = z.object({
Expand Down Expand Up @@ -69,6 +88,16 @@ const envSchema = z.object({
SOROBAN_RPC_RETRY_BASE_MS: z.coerce.number().int().min(50).default(250),
SOROBAN_RPC_RETRY_MAX_MS: z.coerce.number().int().min(500).default(8000),
STELLAR_BAD_SEQ_MAX_RETRIES: z.coerce.number().int().min(1).max(10).default(3),

// ── Push notifications (Firebase Cloud Messaging) ───────────────────────────
FCM_PROJECT_ID: z.string().default(''),
FCM_CLIENT_EMAIL: z.string().default(''),
FCM_PRIVATE_KEY: z.string().default(''),
FCM_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1000).default(10000),

// ── Bulk delivery CSV import ────────────────────────────────────────────────
BULK_UPLOAD_MAX_BYTES: z.coerce.number().int().min(1024).default(5 * 1024 * 1024),
BULK_UPLOAD_MAX_ROWS: z.coerce.number().int().min(1).max(10000).default(1000),
});

let env: EnvConfig;
Expand Down
105 changes: 105 additions & 0 deletions src/controllers/bulkDeliveryController.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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);
}
};
200 changes: 200 additions & 0 deletions src/controllers/notificationController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { NextFunction, Request, Response } from 'express';
import { StatusCodes } from 'http-status-codes';
import { z } from 'zod';
import type { IUser } from '../interfaces/IUser';
import { NotificationEvent } from '../models/NotificationPreference';
import { notificationService } from '../services/notificationService';
import AppError from '../utils/AppError';

/**
* NotificationController — HTTP surface for push notification preferences,
* device registration and notification history.
*
* All routes operate on the authenticated user; none accept a user id from
* the client, so one user can never read or mutate another's preferences.
*/

// ─── Validation schemas ────────────────────────────────────────────────────────

/** Body accepted by `PATCH /api/v1/notifications/preferences`. */
export const updatePreferencesSchema = z
.object({
pushEnabled: z.boolean().optional(),
enabledEvents: z.array(z.nativeEnum(NotificationEvent)).optional(),
})
.refine((value) => value.pushEnabled !== undefined || value.enabledEvents !== undefined, {
message: 'Provide at least one of "pushEnabled" or "enabledEvents"',
});

/** Body accepted by `POST /api/v1/notifications/devices`. */
export const registerDeviceSchema = z.object({
token: z.string().trim().min(1, 'Device token is required').max(4096),
platform: z.enum(['ios', 'android', 'web']),
});

/** Body accepted by `DELETE /api/v1/notifications/devices`. */
export const unregisterDeviceSchema = z.object({
token: z.string().trim().min(1, 'Device token is required').max(4096),
});

/** Query accepted by `GET /api/v1/notifications`. */
export const listNotificationsSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});

// ─── Helpers ───────────────────────────────────────────────────────────────────

/**
* Resolve the authenticated user's id from the request.
*
* `authenticate` attaches the hydrated user document; this narrows it and
* fails closed if the middleware was somehow bypassed.
*/
const requireUserId = (req: Request): string => {
const user = (req as Request & { user?: IUser }).user;
const id = user?._id ? String(user._id) : undefined;

if (!id) {
throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED);
}
return id;
};

// ─── GET /api/v1/notifications/preferences ─────────────────────────────────────

/**
* Return the authenticated user's notification preferences.
*
* Defaults are created on first access, so this never 404s for a valid user.
*/
export const getPreferences = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
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<void> => {
try {
const body = req.body as z.infer<typeof updatePreferencesSchema>;
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<void> => {
try {
const body = req.body as z.infer<typeof registerDeviceSchema>;
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<void> => {
try {
const body = req.body as z.infer<typeof unregisterDeviceSchema>;
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<void> => {
try {
const { page, limit } = req.query as unknown as z.infer<typeof listNotificationsSchema>;
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);
}
};
Loading