Skip to content

Commit 1aa36ce

Browse files
Merge pull request #42 from StreetSupport/feature/3017-create-location-specific-content-management-system
3017 - create location specific content management system
2 parents 195e044 + 38a97a0 commit 1aa36ce

18 files changed

+1991
-30
lines changed

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import userRoutes from './routes/userRoutes.js';
99
import bannerRoutes from './routes/bannerRoutes.js';
1010
import swepBannerRoutes from './routes/swepBannerRoutes.js';
1111
import resourceRoutes from './routes/resourceRoutes.js';
12+
import locationLogoRoutes from './routes/locationLogoRoutes.js';
1213
import { errorHandler, notFound } from './middleware/errorMiddleware.js';
1314
import checkJwt from './middleware/checkJwt.js';
1415
import './instrument.js';
@@ -38,6 +39,7 @@ app.use('/api/users', userRoutes);
3839
app.use('/api/banners', bannerRoutes);
3940
app.use('/api/swep-banners', swepBannerRoutes);
4041
app.use('/api/resources', resourceRoutes);
42+
app.use('/api/location-logos', locationLogoRoutes);
4143

4244
// The error handler must be registered before any other error middleware and after all controllers
4345
Sentry.setupExpressErrorHandler(app);

src/controllers/bannerController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const getBanners = asyncHandler(async (req: Request, res: Response) => {
1616
isActive,
1717
search,
1818
page = 1,
19-
limit = 10,
19+
limit = 9,
2020
sortBy = 'Priority',
2121
sortOrder = 'desc'
2222
} = req.query;

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 = 9,
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

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { Request, Response } from 'express';
2+
import { asyncHandler } from '../utils/asyncHandler.js';
3+
import { sendSuccess, sendBadRequest, sendNotFound, sendPaginatedSuccess } from '../utils/apiResponses.js';
4+
import { validateLocationLogo } from '../schemas/locationLogoSchema.js';
5+
import LocationLogo from '../models/locationLogosModel.js';
6+
import { deleteFile } from '../middleware/uploadMiddleware.js';
7+
8+
// @desc Get all location logos with optional filtering
9+
// @route GET /api/location-logos
10+
// @access Private
11+
export const getLocationLogos = asyncHandler(async (req: Request, res: Response) => {
12+
const {
13+
location,
14+
locations, // Comma-separated list for location filtering
15+
search,
16+
page = 1,
17+
limit = 9,
18+
sortBy = 'DocumentModifiedDate',
19+
sortOrder = 'desc'
20+
} = req.query;
21+
22+
const query: any = {};
23+
const conditions: any[] = [];
24+
25+
// Apply search filter
26+
if (search && typeof search === 'string') {
27+
conditions.push({
28+
$or: [
29+
{ DisplayName: { $regex: search.trim(), $options: 'i' } }
30+
]
31+
});
32+
}
33+
34+
// Apply location filter
35+
if (locations && typeof locations === 'string') {
36+
const locationArray = locations.split(',').map(loc => loc.trim()).filter(Boolean);
37+
if (locationArray.length > 0) {
38+
conditions.push({ LocationSlug: { $in: locationArray } });
39+
}
40+
} else if (location && typeof location === 'string') {
41+
conditions.push({ LocationSlug: location });
42+
}
43+
44+
// Combine all conditions with AND logic
45+
if (conditions.length > 0) {
46+
query.$and = conditions;
47+
}
48+
49+
// Pagination
50+
const skip = (Number(page) - 1) * Number(limit);
51+
52+
// Sort options
53+
const sortOptions: any = {};
54+
sortOptions[sortBy as string] = sortOrder === 'desc' ? -1 : 1;
55+
56+
const logos = await LocationLogo.find(query)
57+
.sort(sortOptions)
58+
.skip(skip)
59+
.limit(Number(limit))
60+
.lean();
61+
62+
// Get total count using the same query
63+
const total = await LocationLogo.countDocuments(query);
64+
65+
return sendPaginatedSuccess(res, logos, {
66+
page: Number(page),
67+
limit: Number(limit),
68+
total,
69+
pages: Math.ceil(total / Number(limit))
70+
});
71+
});
72+
73+
// @desc Get single location logo by ID
74+
// @route GET /api/location-logos/:id
75+
// @access Private
76+
export const getLocationLogoById = asyncHandler(async (req: Request, res: Response) => {
77+
const logo = await LocationLogo.findById(req.params.id);
78+
79+
if (!logo) {
80+
return sendNotFound(res, 'Location logo not found');
81+
}
82+
83+
return sendSuccess(res, logo);
84+
});
85+
86+
87+
// @desc Create location logo
88+
// @route POST /api/location-logos
89+
// @access Private
90+
export const createLocationLogo = asyncHandler(async (req: Request, res: Response) => {
91+
// Extract uploaded file URL from req
92+
let logoPath = '';
93+
if (req.body.LogoPath) {
94+
logoPath = req.body.LogoPath;
95+
}
96+
97+
const logoData = {
98+
...req.body,
99+
LogoPath: logoPath,
100+
CreatedBy: req.user?.id || 'system'
101+
};
102+
103+
// Validate logo data
104+
const validation = validateLocationLogo(logoData);
105+
if (!validation.success) {
106+
return sendBadRequest(res, 'Validation failed');
107+
}
108+
109+
// Create location logo
110+
const logo = await LocationLogo.create(logoData);
111+
112+
return sendSuccess(res, logo, 'Location logo created successfully');
113+
});
114+
115+
// @desc Update location logo
116+
// @route PUT /api/location-logos/:id
117+
// @access Private
118+
export const updateLocationLogo = asyncHandler(async (req: Request, res: Response) => {
119+
const { id } = req.params;
120+
121+
// Get existing location logo
122+
const existingLogo = await LocationLogo.findById(id);
123+
124+
if (!existingLogo) {
125+
return sendNotFound(res, 'Location logo not found');
126+
}
127+
128+
// Store old logo path for cleanup
129+
const oldLogoPath = existingLogo.LogoPath;
130+
131+
// Extract uploaded file URL from req
132+
let logoPath = existingLogo.LogoPath;
133+
if (req.body.LogoPath && req.body.LogoPath !== existingLogo.LogoPath) {
134+
logoPath = req.body.LogoPath;
135+
}
136+
137+
const logoData = {
138+
...req.body,
139+
LogoPath: logoPath,
140+
DocumentModifiedDate: new Date()
141+
};
142+
143+
// Validate logo data
144+
const validation = validateLocationLogo(logoData);
145+
if (!validation.success) {
146+
return sendBadRequest(res, 'Validation failed');
147+
}
148+
149+
// Update location logo
150+
const updatedLogo = await LocationLogo.findByIdAndUpdate(
151+
id,
152+
logoData,
153+
{ new: true, runValidators: true }
154+
);
155+
156+
// Cleanup old logo file if it was replaced
157+
if (logoPath !== oldLogoPath && oldLogoPath) {
158+
try {
159+
await deleteFile(oldLogoPath);
160+
console.log(`Deleted old logo file: ${oldLogoPath}`);
161+
} catch (error) {
162+
console.error(`Failed to delete old logo file: ${oldLogoPath}`, error);
163+
}
164+
}
165+
166+
return sendSuccess(res, updatedLogo, 'Location logo updated successfully');
167+
});
168+
169+
// @desc Delete location logo
170+
// @route DELETE /api/location-logos/:id
171+
// @access Private
172+
export const deleteLocationLogo = asyncHandler(async (req: Request, res: Response) => {
173+
const { id } = req.params;
174+
175+
const logo = await LocationLogo.findById(id);
176+
177+
if (!logo) {
178+
return sendNotFound(res, 'Location logo not found');
179+
}
180+
181+
// Store logo path for cleanup
182+
const logoPath = logo.LogoPath;
183+
184+
// Delete from database
185+
await LocationLogo.findByIdAndDelete(id);
186+
187+
// Delete logo file from storage
188+
if (logoPath) {
189+
try {
190+
await deleteFile(logoPath);
191+
console.log(`Deleted logo file: ${logoPath}`);
192+
} catch (error) {
193+
console.error(`Failed to delete logo file: ${logoPath}`, error);
194+
}
195+
}
196+
197+
return sendSuccess(res, null, 'Location logo deleted successfully');
198+
});

0 commit comments

Comments
 (0)