Skip to content

Commit 8702946

Browse files
committed
Implement Advice
1 parent 2063708 commit 8702946

File tree

6 files changed

+171
-27
lines changed

6 files changed

+171
-27
lines changed

src/controllers/faqController.ts

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,89 @@
11
import { Request, Response } from 'express';
22
import Faq from '../models/faqsModel.js';
33
import { asyncHandler } from '../utils/asyncHandler.js';
4-
import { sendSuccess, sendCreated, sendNotFound } from '../utils/apiResponses.js';
4+
import { sendSuccess, sendCreated, sendNotFound, sendBadRequest, sendPaginatedSuccess } from '../utils/apiResponses.js';
5+
import { validateFaq } from '../schemas/faqSchema.js';
6+
import { ROLE_PREFIXES, ROLES } from '../constants/roles.js';
57

6-
// @desc Get all FAQs
8+
// @desc Get all FAQs with optional filtering and pagination
79
// @route GET /api/faqs
810
// @access Private
911
export const getFaqs = asyncHandler(async (req: Request, res: Response) => {
10-
const faqs = await Faq.find().sort('SortPosition').lean();
11-
return sendSuccess(res, faqs);
12+
const {
13+
location,
14+
search,
15+
page = 1,
16+
limit = 10,
17+
sortBy = 'DocumentModifiedDate',
18+
sortOrder = 'desc'
19+
} = req.query;
20+
21+
const query: any = {};
22+
const conditions: any[] = [];
23+
24+
// Get user auth claims for role-based filtering
25+
const requestingUserAuthClaims = req.user?.AuthClaims || [];
26+
// const userId = req.user?._id;
27+
28+
// Role-based filtering
29+
const isSuperAdmin = requestingUserAuthClaims.includes(ROLES.SUPER_ADMIN);
30+
const isVolunteerAdmin = requestingUserAuthClaims.includes(ROLES.VOLUNTEER_ADMIN);
31+
const isCityAdmin = requestingUserAuthClaims.includes(ROLES.CITY_ADMIN);
32+
33+
// CityAdmin: only see organisations from their cities
34+
if (isCityAdmin && !isSuperAdmin && !isVolunteerAdmin) {
35+
const cityAdminLocations = requestingUserAuthClaims
36+
.filter(claim => claim.startsWith(ROLE_PREFIXES.CITY_ADMIN_FOR))
37+
.map(claim => claim.replace(ROLE_PREFIXES.CITY_ADMIN_FOR, ''));
38+
39+
if (cityAdminLocations.length > 0) {
40+
conditions.push({ LocationKey: { $in: cityAdminLocations } });
41+
}
42+
}
43+
44+
// Apply search filter - search by Title or Body
45+
if (search && typeof search === 'string') {
46+
const searchTerm = search.trim();
47+
conditions.push({
48+
$or: [
49+
{ Title: { $regex: searchTerm, $options: 'i' } },
50+
{ Body: { $regex: searchTerm, $options: 'i' } }
51+
]
52+
});
53+
}
54+
55+
// Apply location filter
56+
if (location && typeof location === 'string') {
57+
conditions.push({ LocationKey: location });
58+
}
59+
60+
// Combine all conditions with AND logic
61+
if (conditions.length > 0) {
62+
query.$and = conditions;
63+
}
64+
65+
// Pagination
66+
const skip = (Number(page) - 1) * Number(limit);
67+
68+
// Sort options
69+
const sortOptions: any = {};
70+
sortOptions[sortBy as string] = sortOrder === 'desc' ? -1 : 1;
71+
72+
const faqs = await Faq.find(query)
73+
.sort(sortOptions)
74+
.skip(skip)
75+
.limit(Number(limit))
76+
.lean();
77+
78+
// Get total count using the same query
79+
const total = await Faq.countDocuments(query);
80+
81+
return sendPaginatedSuccess(res, faqs, {
82+
page: Number(page),
83+
limit: Number(limit),
84+
total,
85+
pages: Math.ceil(total / Number(limit))
86+
});
1287
});
1388

1489
// @desc Get single FAQ by ID
@@ -22,27 +97,66 @@ export const getFaqById = asyncHandler(async (req: Request, res: Response) => {
2297
return sendSuccess(res, faq);
2398
});
2499

25-
26100
// @desc Create new FAQ
27101
// @route POST /api/faqs
28102
// @access Private
29103
export const createFaq = asyncHandler(async (req: Request, res: Response) => {
30-
const faq = await Faq.create(req.body);
104+
// Validate the request data
105+
const validation = validateFaq(req.body);
106+
107+
if (!validation.success) {
108+
const errorMessages = validation.errors.map(err => err.message).join(', ');
109+
return sendBadRequest(res, `Validation failed: ${errorMessages}`);
110+
}
111+
112+
if (!validation.data) {
113+
return sendBadRequest(res, 'Validation data is missing');
114+
}
115+
116+
// Add system fields
117+
const faqData = {
118+
...validation.data,
119+
CreatedBy: req.user?._id || req.body?.CreatedBy,
120+
DocumentCreationDate: new Date(),
121+
DocumentModifiedDate: new Date(),
122+
};
123+
124+
const faq = await Faq.create(faqData);
31125
return sendCreated(res, faq);
32126
});
33127

34128
// @desc Update FAQ
35129
// @route PUT /api/faqs/:id
36130
// @access Private
37131
export const updateFaq = asyncHandler(async (req: Request, res: Response) => {
132+
// Check if FAQ exists
133+
const existingFaq = await Faq.findById(req.params.id);
134+
if (!existingFaq) {
135+
return sendNotFound(res, 'FAQ not found');
136+
}
137+
138+
// Validate the request data
139+
const validation = validateFaq(req.body);
140+
141+
if (!validation.success) {
142+
const errorMessages = validation.errors.map(err => err.message).join(', ');
143+
return sendBadRequest(res, `Validation failed: ${errorMessages}`);
144+
}
145+
146+
if (!validation.data) {
147+
return sendBadRequest(res, 'Validation data is missing');
148+
}
149+
150+
// Update FAQ with validated data
38151
const faq = await Faq.findByIdAndUpdate(
39-
req.params.id,
40-
req.body,
152+
req.params.id,
153+
{
154+
...validation.data,
155+
DocumentModifiedDate: new Date()
156+
},
41157
{ new: true, runValidators: true }
42158
);
43-
if (!faq) {
44-
return sendNotFound(res, 'FAQ not found');
45-
}
159+
46160
return sendSuccess(res, faq);
47161
});
48162

src/middleware/authMiddleware.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ export const requireFaqAccess = asyncHandler(async (req: Request, res: Response,
712712
const userAuthClaims = req.user?.AuthClaims || [];
713713

714714
// SuperAdmin global rule
715-
if (handleSuperAdminAccess(userAuthClaims)) { return next(); }
715+
if (handleSuperAdminAccess(userAuthClaims) || handleVolunteerAdminAccess(userAuthClaims)) { return next(); }
716716

717717
// Check if user is a CityAdmin
718718
if (!userAuthClaims.includes(ROLES.CITY_ADMIN)) {
@@ -730,9 +730,9 @@ export const requireFaqAccess = asyncHandler(async (req: Request, res: Response,
730730

731731
const locationKey = faq.LocationKey;
732732

733-
// If LocationKey is 'general', any CityAdmin can access
733+
// If LocationKey is 'general', only SuperAdmin and VolunteerAdmin can access
734734
if (locationKey === 'general') {
735-
return next();
735+
return sendForbidden(res, 'Access to general advice is restricted to SuperAdmin and VolunteerAdmin');
736736
}
737737

738738
// For location-based access, check the locationKey
@@ -746,9 +746,16 @@ export const requireFaqAccess = asyncHandler(async (req: Request, res: Response,
746746
}
747747

748748
if (req.body && req.method === HTTP_METHODS.POST) {
749+
const locationKey = req.body.LocationKey;
750+
751+
// If LocationKey is 'general', only SuperAdmin and VolunteerAdmin can create
752+
if (locationKey === 'general') {
753+
return sendForbidden(res, 'Creating general advice is restricted to SuperAdmin and VolunteerAdmin');
754+
}
755+
749756
if (userAuthClaims.includes(ROLES.CITY_ADMIN)) {
750757
// Check if user has access to the specific location
751-
const cityAdminClaim = `${ROLE_PREFIXES.CITY_ADMIN_FOR}${req.body.LocationKey}`;
758+
const cityAdminClaim = `${ROLE_PREFIXES.CITY_ADMIN_FOR}${locationKey}`;
752759
if (userAuthClaims.includes(cityAdminClaim)) {
753760
return next();
754761
}
@@ -779,7 +786,7 @@ export const requireFaqLocationAccess = (req: Request, res: Response, next: Next
779786
const userAuthClaims = req.user?.AuthClaims || [];
780787

781788
// SuperAdmin global rule
782-
if (handleSuperAdminAccess(userAuthClaims)) { return next(); }
789+
if (handleSuperAdminAccess(userAuthClaims) || handleVolunteerAdminAccess(userAuthClaims)) { return next(); }
783790

784791
// Check if user is a CityAdmin
785792
if (!userAuthClaims.includes(ROLES.CITY_ADMIN)) {
@@ -789,14 +796,17 @@ export const requireFaqLocationAccess = (req: Request, res: Response, next: Next
789796
// For location-based access, check the location param
790797
const locationId = req.params.location;
791798

792-
// If LocationKey is 'general', any CityAdmin can access
793-
if (locationId === 'general') {
794-
return next();
795-
}
796-
797799
// For location-based access, check the locationId param
798800
const locations = extractLocationsFromQuery(req);
799801

802+
// If LocationKey is 'general', only SuperAdmin and VolunteerAdmin can access
803+
if (locationId === 'general' || locations.includes('general')) {
804+
if (userAuthClaims.includes(ROLES.SUPER_ADMIN) || userAuthClaims.includes(ROLES.VOLUNTEER_ADMIN)) {
805+
return next();
806+
}
807+
return sendForbidden(res, 'Access to general advice is restricted to SuperAdmin and VolunteerAdmin');
808+
}
809+
800810
if (validateCityAdminLocationsAccess(userAuthClaims, locations, res)) {
801811
return; // Access denied, response already sent
802812
}

src/models/faqsModel.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ const faqSchema = new Schema<IFaq>({
2929
SortPosition: {
3030
type: Number,
3131
required: true,
32-
},
33-
Tags: [String]
32+
}
3433
}, { collection: 'FAQs', versionKey: false });
3534

35+
// Create indexes based on MongoDB Atlas specifications
36+
// faqSchema.index({ _id: 1 }, { unique: true }); // UNIQUE index on _id (default)
37+
faqSchema.index({ LocationKey: 1, SortPosition: -1, Title: 1 }); // COMPOUND index for filtering and sorting
38+
3639
const Faq = mongoose.model<IFaq>("FAQs", faqSchema);
3740

3841
export default Faq;

src/routes/faqRoutes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ import {
66
updateFaq,
77
deleteFaq
88
} from '../controllers/faqController.js';
9-
import { faqsAuth } from '../middleware/authMiddleware.js';
9+
import { faqsAuth, faqsByLocationAuth } from '../middleware/authMiddleware.js';
1010

1111
const router = Router();
1212

13-
router.get('/', faqsAuth, getFaqs);
13+
router.get('/', faqsByLocationAuth, getFaqs);
1414
router.get('/:id', faqsAuth, getFaqById);
1515
router.post('/', faqsAuth, createFaq);
1616
router.put('/:id', faqsAuth, updateFaq);

src/schemas/faqSchema.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { z } from 'zod';
2+
import { ValidationResult, createValidationResult } from './validationHelpers.js';
3+
4+
// FAQ validation schema
5+
export const FaqSchema = z.object({
6+
LocationKey: z.string().min(1, 'Location is required'),
7+
Title: z.string().min(1, 'Title is required').max(200, 'Title must not exceed 200 characters'),
8+
Body: z.string().min(1, 'Body content is required'),
9+
SortPosition: z.number().int('Sort position must be an integer').min(1, 'Sort position must be 1 or greater')
10+
});
11+
12+
// Validation function
13+
export function validateFaq(data: unknown): ValidationResult<z.output<typeof FaqSchema>> {
14+
const result = FaqSchema.safeParse(data);
15+
return createValidationResult(result);
16+
}
17+
18+
export type FaqSchemaType = z.infer<typeof FaqSchema>;

src/types/IFaq.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,4 @@ export interface IFaq extends Document {
99
Title: string;
1010
Body: string;
1111
SortPosition: number;
12-
Tags?: string[];
13-
}
12+
}

0 commit comments

Comments
 (0)