Skip to content

Commit e2bf31a

Browse files
Bagour Deliveryclaude
andcommitted
fix: resolve API mismatches, security issues, and implement Firebase topic subscription
API Fixes: - Add blog stats endpoint (GET /blog/admin/stats) - Fix content upsert path alignment (/content/${key}/upsert) - Fix menus reorder method (PUT→POST) and payload format - Fix contact filters field name (isStarred→starred) Security Fixes: - Add escapeRegex() for email search in careers controller - Add field whitelist in contact updateMessage to prevent mass assignment Firebase Implementation: - Implement FCM topic subscribe/unsubscribe in notification service - Connect notification controller to actual Firebase Admin SDK Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7a114c1 commit e2bf31a

File tree

9 files changed

+178
-10
lines changed

9 files changed

+178
-10
lines changed

backend/src/controllers/blog.controller.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,41 @@ async function invalidateCategoryCache() {
12461246
}
12471247
}
12481248

1249+
/**
1250+
* Get blog statistics (Admin)
1251+
* جلب إحصائيات المدونة (للمسؤول)
1252+
*/
1253+
export const getStats = asyncHandler(async (_req: Request, res: Response) => {
1254+
const [
1255+
total,
1256+
published,
1257+
draft,
1258+
scheduled,
1259+
archived,
1260+
totalViewsResult,
1261+
] = await Promise.all([
1262+
BlogPost.countDocuments(),
1263+
BlogPost.countDocuments({ status: 'published' }),
1264+
BlogPost.countDocuments({ status: 'draft' }),
1265+
BlogPost.countDocuments({ status: 'scheduled' }),
1266+
BlogPost.countDocuments({ status: 'archived' }),
1267+
BlogPost.aggregate([
1268+
{ $group: { _id: null, totalViews: { $sum: '$views' } } },
1269+
]),
1270+
]);
1271+
1272+
const totalViews = totalViewsResult[0]?.totalViews || 0;
1273+
1274+
return successResponse(res, {
1275+
total,
1276+
published,
1277+
draft,
1278+
scheduled,
1279+
archived,
1280+
totalViews,
1281+
});
1282+
});
1283+
12491284
export const blogController = {
12501285
// Categories
12511286
getCategories,
@@ -1266,6 +1301,8 @@ export const blogController = {
12661301
updatePost,
12671302
deletePost,
12681303
bulkUpdateStatus,
1304+
// Stats
1305+
getStats,
12691306
// Saved Posts
12701307
savePost,
12711308
unsavePost,

backend/src/controllers/careers.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ export const getAllApplications = asyncHandler(async (req: Request, res: Respons
516516
}
517517

518518
if (email) {
519-
filter.email = { $regex: email, $options: 'i' };
519+
filter.email = { $regex: escapeRegex(email as string), $options: 'i' };
520520
}
521521

522522
const total = await JobApplication.countDocuments(filter);

backend/src/controllers/contact.controller.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,15 @@ export const getMessageById = asyncHandler(async (req: Request, res: Response) =
201201
*/
202202
export const updateMessage = asyncHandler(async (req: Request, res: Response) => {
203203
const { id } = req.params;
204-
const updateData = req.body;
204+
205+
// Whitelist allowed fields for update to prevent mass assignment
206+
const allowedFields = ['status', 'priority', 'notes', 'isStarred', 'labels', 'assignedTo'];
207+
const updateData: Record<string, unknown> = {};
208+
for (const field of allowedFields) {
209+
if (req.body[field] !== undefined) {
210+
updateData[field] = req.body[field];
211+
}
212+
}
205213

206214
const message = await Contact.findByIdAndUpdate(id, updateData, { new: true });
207215

backend/src/controllers/notification.controller.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,16 @@ export const subscribeToTopic = asyncHandler(async (req: Request, res: Response)
183183
const { topic } = req.params;
184184
const { token } = req.body;
185185

186-
// This would use Firebase Admin SDK to subscribe to topic
187-
// For now, just acknowledge the request
186+
if (!token) {
187+
throw new ApiError(400, 'TOKEN_REQUIRED', 'Device token is required');
188+
}
189+
190+
const result = await notificationService.subscribeToTopic(token, topic);
191+
192+
if (!result.success) {
193+
throw new ApiError(500, 'SUBSCRIPTION_FAILED', result.error || 'Failed to subscribe to topic');
194+
}
195+
188196
sendSuccess(res, { message: `Subscribed to topic: ${topic}`, topic, token });
189197
});
190198

@@ -197,7 +205,16 @@ export const unsubscribeFromTopic = asyncHandler(async (req: Request, res: Respo
197205
const { topic } = req.params;
198206
const { token } = req.body;
199207

200-
// This would use Firebase Admin SDK to unsubscribe from topic
208+
if (!token) {
209+
throw new ApiError(400, 'TOKEN_REQUIRED', 'Device token is required');
210+
}
211+
212+
const result = await notificationService.unsubscribeFromTopic(token, topic);
213+
214+
if (!result.success) {
215+
throw new ApiError(500, 'UNSUBSCRIPTION_FAILED', result.error || 'Failed to unsubscribe from topic');
216+
}
217+
201218
sendSuccess(res, { message: `Unsubscribed from topic: ${topic}`, topic, token });
202219
});
203220

backend/src/routes/blog.routes.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,42 @@ router.delete(
838838
* 403:
839839
* description: Forbidden - Insufficient permissions
840840
*/
841+
/**
842+
* @swagger
843+
* /blog/admin/stats:
844+
* get:
845+
* summary: Get blog statistics
846+
* description: Retrieves blog statistics including post counts by status and total views (Admin only)
847+
* tags: [Blog Admin]
848+
* security:
849+
* - bearerAuth: []
850+
* responses:
851+
* 200:
852+
* description: Blog statistics
853+
* content:
854+
* application/json:
855+
* schema:
856+
* type: object
857+
* properties:
858+
* total:
859+
* type: number
860+
* published:
861+
* type: number
862+
* draft:
863+
* type: number
864+
* scheduled:
865+
* type: number
866+
* archived:
867+
* type: number
868+
* totalViews:
869+
* type: number
870+
* 401:
871+
* description: Unauthorized
872+
* 403:
873+
* description: Forbidden - Insufficient permissions
874+
*/
875+
router.get('/admin/stats', authenticate, authorize('blog:read'), blogController.getStats);
876+
841877
router.get('/admin/posts', authenticate, authorize('blog:read'), blogController.getAllPosts);
842878

843879
/**

backend/src/services/notification.service.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import * as admin from 'firebase-admin';
77
import { getMessaging } from '../config/firebase';
8-
import { logger, emitToUser, emitToAdmins } from '../config';
8+
import { logger, emitToUser } from '../config';
99
import { Notification, INotification } from '../models/Notification';
1010
import { User } from '../models';
1111
import { DeviceToken } from '../models/DeviceToken';
@@ -411,6 +411,72 @@ export async function deleteOldNotifications(daysOld: number = 30): Promise<numb
411411
return result.deletedCount;
412412
}
413413

414+
/**
415+
* Subscribe a device token to an FCM topic
416+
* اشتراك جهاز في موضوع FCM
417+
*/
418+
export async function subscribeToTopic(
419+
token: string,
420+
topic: string
421+
): Promise<{ success: boolean; error?: string }> {
422+
const messaging = getMessaging();
423+
424+
if (!messaging) {
425+
logger.warn('FCM not initialized - cannot subscribe to topic');
426+
return { success: false, error: 'FCM not initialized' };
427+
}
428+
429+
try {
430+
const response = await messaging.subscribeToTopic([token], topic);
431+
432+
if (response.failureCount > 0) {
433+
const errorMsg = response.errors?.[0]?.error?.message || 'Unknown error';
434+
logger.warn(`Failed to subscribe token to topic ${topic}: ${errorMsg}`);
435+
return { success: false, error: errorMsg };
436+
}
437+
438+
logger.info(`Token subscribed to topic: ${topic}`);
439+
return { success: true };
440+
} catch (error) {
441+
const errorMsg = (error as Error).message;
442+
logger.error(`Error subscribing to topic ${topic}:`, error);
443+
return { success: false, error: errorMsg };
444+
}
445+
}
446+
447+
/**
448+
* Unsubscribe a device token from an FCM topic
449+
* إلغاء اشتراك جهاز من موضوع FCM
450+
*/
451+
export async function unsubscribeFromTopic(
452+
token: string,
453+
topic: string
454+
): Promise<{ success: boolean; error?: string }> {
455+
const messaging = getMessaging();
456+
457+
if (!messaging) {
458+
logger.warn('FCM not initialized - cannot unsubscribe from topic');
459+
return { success: false, error: 'FCM not initialized' };
460+
}
461+
462+
try {
463+
const response = await messaging.unsubscribeFromTopic([token], topic);
464+
465+
if (response.failureCount > 0) {
466+
const errorMsg = response.errors?.[0]?.error?.message || 'Unknown error';
467+
logger.warn(`Failed to unsubscribe token from topic ${topic}: ${errorMsg}`);
468+
return { success: false, error: errorMsg };
469+
}
470+
471+
logger.info(`Token unsubscribed from topic: ${topic}`);
472+
return { success: true };
473+
} catch (error) {
474+
const errorMsg = (error as Error).message;
475+
logger.error(`Error unsubscribing from topic ${topic}:`, error);
476+
return { success: false, error: errorMsg };
477+
}
478+
}
479+
414480
export default {
415481
sendPushNotification,
416482
sendNotification,
@@ -423,4 +489,6 @@ export default {
423489
markAllAsRead,
424490
getUnreadCount,
425491
deleteOldNotifications,
492+
subscribeToTopic,
493+
unsubscribeFromTopic,
426494
};

frontend/src/services/admin/contact.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface ContactFilters {
5454
limit?: number;
5555
status?: ContactStatus;
5656
priority?: ContactPriority;
57-
isStarred?: boolean;
57+
starred?: boolean; // Backend expects 'starred', not 'isStarred'
5858
search?: string;
5959
sort?: string;
6060
startDate?: string;

frontend/src/services/admin/content.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export async function updateContent(key: string, data: UpdateContentData): Promi
134134
*/
135135
export async function upsertContent(key: string, data: CreateContentData): Promise<ContentItem> {
136136
const response = await apiClient.put<{ content: ContentItem }>(
137-
`${CONTENT_ENDPOINT}/upsert/${key}`,
137+
`${CONTENT_ENDPOINT}/${key}/upsert`,
138138
data
139139
);
140140
return response.data?.content as ContentItem;

frontend/src/services/admin/menus.service.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,10 @@ export async function removeMenuItem(menuId: string, itemId: string): Promise<Me
182182
* Reorder menu items
183183
*/
184184
export async function reorderMenuItems(menuId: string, itemIds: string[]): Promise<Menu> {
185-
const response = await apiClient.put<{ menu: Menu }>(`${MENUS_ENDPOINT}/${menuId}/reorder`, {
186-
itemIds,
185+
// Transform itemIds array to items array with { id, order } format expected by backend
186+
const items = itemIds.map((id, index) => ({ id, order: index }));
187+
const response = await apiClient.post<{ menu: Menu }>(`${MENUS_ENDPOINT}/${menuId}/reorder`, {
188+
items,
187189
});
188190
return response.data?.menu as Menu;
189191
}

0 commit comments

Comments
 (0)