Skip to content

Commit 918a6af

Browse files
authored
Merge pull request #168 from Adeolu01/feat/combined-repository-notifications-bulk-sockets
feat: repository pattern, push notifications, bulk CSV import, socket decoupling
2 parents 61dddc1 + e5b3f01 commit 918a6af

33 files changed

Lines changed: 5142 additions & 31 deletions

src/config/env.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,25 @@ interface EnvConfig {
3737
SOROBAN_RPC_RETRY_MAX_MS: number;
3838
/** Maximum attempts to retry a transaction that fails with tx_bad_seq. Default: 3 */
3939
STELLAR_BAD_SEQ_MAX_RETRIES: number;
40+
41+
// ── Push notifications (Firebase Cloud Messaging) ───────────────────────────
42+
/**
43+
* Firebase project id. Push sending is disabled when this (or either
44+
* credential below) is blank, so local development runs without Firebase.
45+
*/
46+
FCM_PROJECT_ID: string;
47+
/** Service-account client email used to mint OAuth2 access tokens. */
48+
FCM_CLIENT_EMAIL: string;
49+
/** Service-account private key (PEM; literal `\n` sequences are normalised). */
50+
FCM_PRIVATE_KEY: string;
51+
/** Timeout (ms) for FCM and Google token endpoint requests. Default: 10000 */
52+
FCM_REQUEST_TIMEOUT_MS: number;
53+
54+
// ── Bulk delivery CSV import ────────────────────────────────────────────────
55+
/** Maximum accepted upload size (bytes) for the bulk CSV endpoint. Default: 5MB */
56+
BULK_UPLOAD_MAX_BYTES: number;
57+
/** Maximum data rows accepted in a single bulk upload. Default: 1000 */
58+
BULK_UPLOAD_MAX_ROWS: number;
4059
}
4160

4261
const envSchema = z.object({
@@ -69,6 +88,16 @@ const envSchema = z.object({
6988
SOROBAN_RPC_RETRY_BASE_MS: z.coerce.number().int().min(50).default(250),
7089
SOROBAN_RPC_RETRY_MAX_MS: z.coerce.number().int().min(500).default(8000),
7190
STELLAR_BAD_SEQ_MAX_RETRIES: z.coerce.number().int().min(1).max(10).default(3),
91+
92+
// ── Push notifications (Firebase Cloud Messaging) ───────────────────────────
93+
FCM_PROJECT_ID: z.string().default(''),
94+
FCM_CLIENT_EMAIL: z.string().default(''),
95+
FCM_PRIVATE_KEY: z.string().default(''),
96+
FCM_REQUEST_TIMEOUT_MS: z.coerce.number().int().min(1000).default(10000),
97+
98+
// ── Bulk delivery CSV import ────────────────────────────────────────────────
99+
BULK_UPLOAD_MAX_BYTES: z.coerce.number().int().min(1024).default(5 * 1024 * 1024),
100+
BULK_UPLOAD_MAX_ROWS: z.coerce.number().int().min(1).max(10000).default(1000),
72101
});
73102

74103
let env: EnvConfig;
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { NextFunction, Request, Response } from 'express';
2+
import { StatusCodes } from 'http-status-codes';
3+
import type { IUser } from '../interfaces/IUser';
4+
import { bulkDeliveryService } from '../services/bulkDeliveryService';
5+
import AppError from '../utils/AppError';
6+
import logger from '../config/logger';
7+
8+
/**
9+
* BulkDeliveryController — CSV batch creation of deliveries.
10+
*
11+
* Accepts `multipart/form-data` with a single `file` field. The upload is held
12+
* in memory by multer (see the route) and handed to the service as text; no
13+
* file is written to disk.
14+
*/
15+
16+
/** MIME types browsers and spreadsheet tools use for `.csv`. */
17+
const ACCEPTED_MIME_TYPES = new Set([
18+
'text/csv',
19+
'application/csv',
20+
'text/plain',
21+
'application/vnd.ms-excel',
22+
'application/octet-stream',
23+
]);
24+
25+
/** Whether an uploaded file looks like CSV by MIME type or extension. */
26+
export const isAcceptedCsvUpload = (file: Express.Multer.File): boolean =>
27+
ACCEPTED_MIME_TYPES.has(file.mimetype) || file.originalname.toLowerCase().endsWith('.csv');
28+
29+
// ─── POST /api/v1/deliveries/bulk ──────────────────────────────────────────────
30+
31+
/**
32+
* Batch-create deliveries from an uploaded CSV file.
33+
*
34+
* Responds `201 Created` when every row was imported, and `207 Multi-Status`
35+
* when some rows were rejected — the body always carries the per-row error
36+
* report so the client can correct and resubmit only the failures.
37+
*
38+
* A file that is entirely unusable (unparseable, missing required columns,
39+
* over the row limit) is a `400`, raised by the service.
40+
*
41+
* Errors:
42+
* 400 — no file uploaded, wrong type, or the CSV itself is unusable
43+
* 401 — not authenticated
44+
* 413 — upload exceeds BULK_UPLOAD_MAX_BYTES (raised by multer)
45+
* 422 — the file parsed but no row could be imported
46+
*/
47+
export const bulkCreateDeliveries = async (
48+
req: Request,
49+
res: Response,
50+
next: NextFunction,
51+
): Promise<void> => {
52+
try {
53+
const user = (req as Request & { user?: IUser }).user;
54+
const userId = user?._id ? String(user._id) : undefined;
55+
56+
if (!userId) {
57+
throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED);
58+
}
59+
60+
const file = req.file;
61+
if (!file) {
62+
throw new AppError(
63+
'No CSV file uploaded. Attach the file under the "file" field.',
64+
StatusCodes.BAD_REQUEST,
65+
);
66+
}
67+
68+
if (!isAcceptedCsvUpload(file)) {
69+
throw new AppError(
70+
`Unsupported file type "${file.mimetype}". Upload a .csv file.`,
71+
StatusCodes.BAD_REQUEST,
72+
);
73+
}
74+
75+
const result = await bulkDeliveryService.importFromCsv(file.buffer.toString('utf8'), userId);
76+
77+
logger.info(
78+
`[BulkDeliveryController] Import by user=${userId} — ` +
79+
`created=${result.successCount} failed=${result.failureCount}`,
80+
);
81+
82+
// Nothing imported but the file was well-formed: the content is the
83+
// problem, so 422 rather than 400.
84+
if (result.successCount === 0) {
85+
res.status(StatusCodes.UNPROCESSABLE_ENTITY).json({
86+
status: 'error',
87+
message: 'No deliveries could be created from the uploaded file',
88+
data: result,
89+
});
90+
return;
91+
}
92+
93+
const partial = result.failureCount > 0;
94+
95+
res.status(partial ? StatusCodes.MULTI_STATUS : StatusCodes.CREATED).json({
96+
status: partial ? 'partial' : 'success',
97+
message: partial
98+
? `Imported ${result.successCount} of ${result.totalRows} deliveries`
99+
: `Imported ${result.successCount} deliveries`,
100+
data: result,
101+
});
102+
} catch (error) {
103+
next(error);
104+
}
105+
};
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { NextFunction, Request, Response } from 'express';
2+
import { StatusCodes } from 'http-status-codes';
3+
import { z } from 'zod';
4+
import type { IUser } from '../interfaces/IUser';
5+
import { NotificationEvent } from '../models/NotificationPreference';
6+
import { notificationService } from '../services/notificationService';
7+
import AppError from '../utils/AppError';
8+
9+
/**
10+
* NotificationController — HTTP surface for push notification preferences,
11+
* device registration and notification history.
12+
*
13+
* All routes operate on the authenticated user; none accept a user id from
14+
* the client, so one user can never read or mutate another's preferences.
15+
*/
16+
17+
// ─── Validation schemas ────────────────────────────────────────────────────────
18+
19+
/** Body accepted by `PATCH /api/v1/notifications/preferences`. */
20+
export const updatePreferencesSchema = z
21+
.object({
22+
pushEnabled: z.boolean().optional(),
23+
enabledEvents: z.array(z.nativeEnum(NotificationEvent)).optional(),
24+
})
25+
.refine((value) => value.pushEnabled !== undefined || value.enabledEvents !== undefined, {
26+
message: 'Provide at least one of "pushEnabled" or "enabledEvents"',
27+
});
28+
29+
/** Body accepted by `POST /api/v1/notifications/devices`. */
30+
export const registerDeviceSchema = z.object({
31+
token: z.string().trim().min(1, 'Device token is required').max(4096),
32+
platform: z.enum(['ios', 'android', 'web']),
33+
});
34+
35+
/** Body accepted by `DELETE /api/v1/notifications/devices`. */
36+
export const unregisterDeviceSchema = z.object({
37+
token: z.string().trim().min(1, 'Device token is required').max(4096),
38+
});
39+
40+
/** Query accepted by `GET /api/v1/notifications`. */
41+
export const listNotificationsSchema = z.object({
42+
page: z.coerce.number().int().min(1).default(1),
43+
limit: z.coerce.number().int().min(1).max(100).default(20),
44+
});
45+
46+
// ─── Helpers ───────────────────────────────────────────────────────────────────
47+
48+
/**
49+
* Resolve the authenticated user's id from the request.
50+
*
51+
* `authenticate` attaches the hydrated user document; this narrows it and
52+
* fails closed if the middleware was somehow bypassed.
53+
*/
54+
const requireUserId = (req: Request): string => {
55+
const user = (req as Request & { user?: IUser }).user;
56+
const id = user?._id ? String(user._id) : undefined;
57+
58+
if (!id) {
59+
throw new AppError('Authentication required.', StatusCodes.UNAUTHORIZED);
60+
}
61+
return id;
62+
};
63+
64+
// ─── GET /api/v1/notifications/preferences ─────────────────────────────────────
65+
66+
/**
67+
* Return the authenticated user's notification preferences.
68+
*
69+
* Defaults are created on first access, so this never 404s for a valid user.
70+
*/
71+
export const getPreferences = async (
72+
req: Request,
73+
res: Response,
74+
next: NextFunction,
75+
): Promise<void> => {
76+
try {
77+
const preference = await notificationService.getPreferences(requireUserId(req));
78+
79+
res.status(StatusCodes.OK).json({
80+
status: 'success',
81+
data: {
82+
pushEnabled: preference.pushEnabled,
83+
enabledEvents: preference.enabledEvents,
84+
deviceCount: preference.devices.length,
85+
},
86+
});
87+
} catch (error) {
88+
next(error);
89+
}
90+
};
91+
92+
// ─── PATCH /api/v1/notifications/preferences ───────────────────────────────────
93+
94+
/**
95+
* Update the authenticated user's notification preferences.
96+
*
97+
* Both fields are optional; `validateRequest` rejects an empty body.
98+
*/
99+
export const updatePreferences = async (
100+
req: Request,
101+
res: Response,
102+
next: NextFunction,
103+
): Promise<void> => {
104+
try {
105+
const body = req.body as z.infer<typeof updatePreferencesSchema>;
106+
const preference = await notificationService.updatePreferences(requireUserId(req), body);
107+
108+
res.status(StatusCodes.OK).json({
109+
status: 'success',
110+
message: 'Notification preferences updated',
111+
data: {
112+
pushEnabled: preference.pushEnabled,
113+
enabledEvents: preference.enabledEvents,
114+
deviceCount: preference.devices.length,
115+
},
116+
});
117+
} catch (error) {
118+
next(error);
119+
}
120+
};
121+
122+
// ─── POST /api/v1/notifications/devices ────────────────────────────────────────
123+
124+
/**
125+
* Register (or refresh) a push token for one of the user's devices.
126+
*
127+
* Registering a token already held by another account detaches it from that
128+
* account first — see NotificationPreferenceRepository#registerDevice.
129+
*/
130+
export const registerDevice = async (
131+
req: Request,
132+
res: Response,
133+
next: NextFunction,
134+
): Promise<void> => {
135+
try {
136+
const body = req.body as z.infer<typeof registerDeviceSchema>;
137+
const preference = await notificationService.registerDevice({
138+
userId: requireUserId(req),
139+
token: body.token,
140+
platform: body.platform,
141+
});
142+
143+
res.status(StatusCodes.CREATED).json({
144+
status: 'success',
145+
message: 'Device registered for push notifications',
146+
data: { deviceCount: preference.devices.length },
147+
});
148+
} catch (error) {
149+
next(error);
150+
}
151+
};
152+
153+
// ─── DELETE /api/v1/notifications/devices ──────────────────────────────────────
154+
155+
/** Remove a device push token, e.g. on logout. */
156+
export const unregisterDevice = async (
157+
req: Request,
158+
res: Response,
159+
next: NextFunction,
160+
): Promise<void> => {
161+
try {
162+
const body = req.body as z.infer<typeof unregisterDeviceSchema>;
163+
const preference = await notificationService.unregisterDevice(requireUserId(req), body.token);
164+
165+
res.status(StatusCodes.OK).json({
166+
status: 'success',
167+
message: 'Device unregistered',
168+
data: { deviceCount: preference.devices.length },
169+
});
170+
} catch (error) {
171+
next(error);
172+
}
173+
};
174+
175+
// ─── GET /api/v1/notifications ─────────────────────────────────────────────────
176+
177+
/** Return a page of the authenticated user's notification history. */
178+
export const listNotifications = async (
179+
req: Request,
180+
res: Response,
181+
next: NextFunction,
182+
): Promise<void> => {
183+
try {
184+
const { page, limit } = req.query as unknown as z.infer<typeof listNotificationsSchema>;
185+
const result = await notificationService.listForUser(requireUserId(req), page, limit);
186+
187+
res.status(StatusCodes.OK).json({
188+
status: 'success',
189+
data: result.data,
190+
pagination: {
191+
total: result.total,
192+
page: result.page,
193+
limit: result.limit,
194+
totalPages: result.totalPages,
195+
},
196+
});
197+
} catch (error) {
198+
next(error);
199+
}
200+
};

0 commit comments

Comments
 (0)