Skip to content

Commit 405679a

Browse files
Merge pull request #36 from StreetSupport/feature/3017-create-location-specific-content-management-system
3017 create location specific content management system
2 parents 93a55f1 + 56b9f4d commit 405679a

22 files changed

+886
-267
lines changed

src/controllers/bannerController.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ export const deleteBanner = asyncHandler(async (req: Request, res: Response) =>
213213
}
214214

215215
// Extract all file URLs from the banner before deletion
216-
const fileUrls = extractFileUrls(banner.toObject());
216+
// banner is already a plain object from .lean(), no need for .toObject()
217+
const fileUrls = extractFileUrls(banner);
217218

218219
// Delete the banner from database first
219220
await Banner.findByIdAndDelete(id);

src/controllers/organisationController.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const getOrganisations = asyncHandler(async (req: Request, res: Response)
110110
// @route GET /api/organisations/:key
111111
// @access Private
112112
export const getOrganisationByKey = asyncHandler(async (req: Request, res: Response) => {
113-
const provider = await Organisation.findOne({ Key: req.params.id });
113+
const provider = await Organisation.findOne({ Key: req.params.key });
114114
if (!provider) {
115115
return sendNotFound(res, 'Organisation not found');
116116
}
Lines changed: 258 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,272 @@
11
import { Request, Response } from 'express';
22
import { asyncHandler } from '../utils/asyncHandler.js';
3-
import { sendError } from '../utils/apiResponses.js';
3+
import { sendSuccess, sendBadRequest, sendNotFound, sendPaginatedSuccess } from '../utils/apiResponses.js';
4+
import { validateSwepBanner } from '../schemas/swepBannerSchema.js';
5+
import SwepBanner from '../models/swepModel.js';
6+
import { deleteFile } from '../middleware/uploadMiddleware.js';
47

5-
// Stub CRUD handlers for SWEP Banners
8+
// @desc Get all SWEP banners with optional filtering
9+
// @route GET /api/swep-banners
10+
// @access Private
611
export const getSwepBanners = asyncHandler(async (req: Request, res: Response) => {
7-
return sendError(res, 501, 'Not implemented');
8-
});
12+
const {
13+
location,
14+
locations, // Comma-separated list for CityAdmin or SwepAdmin filtering
15+
isActive,
16+
search,
17+
page = 1,
18+
limit = 10,
19+
sortBy = 'DocumentModifiedDate',
20+
sortOrder = 'desc'
21+
} = req.query;
922

10-
export const getSwepBannerById = asyncHandler(async (req: Request, res: Response) => {
11-
return sendError(res, 501, 'Not implemented');
12-
});
23+
const query: any = {};
24+
const conditions: any[] = [];
25+
26+
// Apply search filter
27+
if (search && typeof search === 'string') {
28+
conditions.push({
29+
$or: [
30+
{ Title: { $regex: search.trim(), $options: 'i' } },
31+
{ ShortMessage: { $regex: search.trim(), $options: 'i' } },
32+
{ Body: { $regex: search.trim(), $options: 'i' } }
33+
]
34+
});
35+
}
36+
37+
// Apply location filter
38+
if (locations && typeof locations === 'string') {
39+
const locationArray = locations.split(',').map(loc => loc.trim()).filter(Boolean);
40+
if (locationArray.length > 0) {
41+
conditions.push({ LocationSlug: { $in: locationArray } });
42+
}
43+
} else if (location && typeof location === 'string') {
44+
conditions.push({ LocationSlug: location });
45+
}
46+
47+
// Apply isActive filter
48+
if (isActive !== undefined && isActive !== 'undefined') {
49+
conditions.push({ IsActive: isActive === 'true' });
50+
}
51+
52+
// Combine all conditions with AND logic
53+
if (conditions.length > 0) {
54+
query.$and = conditions;
55+
}
56+
57+
// Pagination
58+
const skip = (Number(page) - 1) * Number(limit);
59+
60+
// Sort options
61+
const sortOptions: any = {};
62+
sortOptions[sortBy as string] = sortOrder === 'desc' ? -1 : 1;
1363

14-
export const getSwepBannersByLocation = asyncHandler(async (req: Request, res: Response) => {
15-
return sendError(res, 501, 'Not implemented');
64+
const swepBanners = await SwepBanner.find(query)
65+
.sort(sortOptions)
66+
.skip(skip)
67+
.limit(Number(limit))
68+
.lean();
69+
70+
// Get total count using the same query
71+
const total = await SwepBanner.countDocuments(query);
72+
73+
return sendPaginatedSuccess(res, swepBanners, {
74+
page: Number(page),
75+
limit: Number(limit),
76+
total,
77+
pages: Math.ceil(total / Number(limit))
78+
});
1679
});
1780

18-
export const createSwepBanner = asyncHandler(async (req: Request, res: Response) => {
19-
return sendError(res, 501, 'Not implemented');
81+
// @desc Get single SWEP banner by location slug
82+
// @route GET /api/swep-banners/:location
83+
// @access Private
84+
export const getSwepBannerByLocation = asyncHandler(async (req: Request, res: Response) => {
85+
const swepBanner = await SwepBanner.findOne({ LocationSlug: req.params.location });
86+
87+
if (!swepBanner) {
88+
return sendNotFound(res, 'SWEP banner not found for this location');
89+
}
90+
91+
return sendSuccess(res, swepBanner);
2092
});
2193

94+
// @desc Update SWEP banner
95+
// @route PUT /api/swep-banners/:location
96+
// @access Private
2297
export const updateSwepBanner = asyncHandler(async (req: Request, res: Response) => {
23-
return sendError(res, 501, 'Not implemented');
98+
const { location } = req.params;
99+
100+
// Get existing SWEP banner
101+
const existingSwep = await SwepBanner.findOne({ LocationSlug: location }).lean();
102+
if (!existingSwep) {
103+
return sendNotFound(res, 'SWEP banner not found for this location');
104+
}
105+
106+
// Process media fields (existing assets + new uploads) using Banner approach
107+
const processedData = processSwepMediaFields(req);
108+
109+
// Preserve existing date fields and IsActive (not editable in edit form)
110+
processedData.SwepActiveFrom = existingSwep.SwepActiveFrom;
111+
processedData.SwepActiveUntil = existingSwep.SwepActiveUntil;
112+
processedData.IsActive = existingSwep.IsActive;
113+
114+
// Validate the processed data
115+
const validation = validateSwepBanner(processedData);
116+
117+
if (!validation.success) {
118+
// Clean up any newly uploaded files since validation failed
119+
await cleanupSwepUploadedFiles(processedData);
120+
const errorMessages = validation.errors.map(err => err.message).join(', ');
121+
return sendBadRequest(res, `Validation failed: ${errorMessages}`);
122+
}
123+
124+
// Store old banner data for file cleanup
125+
const oldImage = existingSwep.Image;
126+
127+
// Update SWEP banner
128+
const updatedSwep = await SwepBanner.findOneAndUpdate(
129+
{ LocationSlug: location },
130+
{
131+
...validation.data,
132+
Image: validation.data?.Image || null,
133+
DocumentModifiedDate: new Date()
134+
},
135+
{ new: true, runValidators: true }
136+
);
137+
138+
// Clean up old image if it was replaced or removed (empty Image property indicates removal)
139+
if (oldImage) {
140+
if (!updatedSwep?.Image || updatedSwep.Image === null || oldImage !== updatedSwep.Image) {
141+
// Image was removed (empty) or replaced with a new one - delete old image
142+
await cleanupSwepUnusedFiles(oldImage);
143+
console.log(`Cleaned up old SWEP image: ${oldImage}`);
144+
}
145+
}
146+
147+
return sendSuccess(res, updatedSwep);
24148
});
25149

26-
export const deleteSwepBanner = asyncHandler(async (req: Request, res: Response) => {
27-
return sendError(res, 501, 'Not implemented');
150+
// @desc Update SWEP banner activation status with optional date range
151+
// @route PATCH /api/swep-banners/:location/toggle-active
152+
// @access Private
153+
export const toggleSwepBannerActive = asyncHandler(async (req: Request, res: Response) => {
154+
const { location } = req.params;
155+
const { IsActive, SwepActiveFrom, SwepActiveUntil } = req.body;
156+
157+
// Get existing SWEP banner
158+
const existingSwep = await SwepBanner.findOne({ LocationSlug: location });
159+
if (!existingSwep) {
160+
return sendNotFound(res, 'SWEP banner not found for this location');
161+
}
162+
163+
// Prepare update data
164+
let shouldActivateNow = IsActive !== undefined ? IsActive : !existingSwep.IsActive;
165+
166+
// Check if scheduled start date equals today - if so, activate immediately
167+
if (SwepActiveFrom !== undefined && SwepActiveFrom !== null) {
168+
const today = new Date();
169+
today.setHours(0, 0, 0, 0);
170+
171+
const activeFromDate = new Date(SwepActiveFrom);
172+
activeFromDate.setHours(0, 0, 0, 0);
173+
174+
// If start date is today, activate immediately
175+
if (activeFromDate.getTime() === today.getTime()) {
176+
shouldActivateNow = true;
177+
}
178+
}
179+
180+
const updateData: any = {
181+
IsActive: shouldActivateNow,
182+
DocumentModifiedDate: new Date()
183+
};
184+
185+
// Handle date range for scheduled activation
186+
if (SwepActiveFrom !== undefined && SwepActiveFrom !== null) {
187+
updateData.SwepActiveFrom = new Date(SwepActiveFrom);
188+
} else if (updateData.IsActive && !existingSwep.SwepActiveFrom) {
189+
// If activating immediately without dates, set SwepActiveFrom to now
190+
updateData.SwepActiveFrom = new Date();
191+
}
192+
193+
if (SwepActiveUntil !== undefined && SwepActiveUntil !== null) {
194+
updateData.SwepActiveUntil = new Date(SwepActiveUntil);
195+
} else if (!updateData.IsActive && !SwepActiveUntil) {
196+
// If deactivating without explicit date, set SwepActiveUntil to now
197+
updateData.SwepActiveUntil = new Date();
198+
}
199+
200+
// Update SWEP banner
201+
const updatedSwep = await SwepBanner.findOneAndUpdate(
202+
{ LocationSlug: location },
203+
updateData,
204+
{ new: true, runValidators: true }
205+
);
206+
207+
return sendSuccess(res, updatedSwep);
28208
});
209+
210+
// ============================================
211+
// HELPER FUNCTIONS (Banner approach)
212+
// ============================================
213+
214+
// Helper function to process SWEP media fields (existing assets + new files)
215+
function processSwepMediaFields(req: Request): any {
216+
const processedData = { ...req.body, ...req.preValidatedData };
217+
218+
// Process image field
219+
const newFileData = processedData.newfile_image;
220+
const existingData = processedData.existing_image
221+
? JSON.parse(processedData.existing_image)
222+
: null;
223+
const explicitImageValue = processedData.Image; // Check for explicit Image field
224+
225+
if (newFileData) {
226+
// New file uploaded - uploadMiddleware attaches asset with Url property
227+
processedData.Image = newFileData.Url || newFileData.url;
228+
} else if (existingData) {
229+
// No new file, preserve existing image URL
230+
processedData.Image = existingData.url || existingData.Url;
231+
} else if (explicitImageValue === '') {
232+
// User explicitly removed the image by sending empty string
233+
processedData.Image = '';
234+
} else {
235+
// Image removed by user (no explicit value, no new file, no existing data)
236+
processedData.Image = '';
237+
}
238+
239+
// Clean up temporary form data keys
240+
delete processedData.newfile_image;
241+
delete processedData.existing_image;
242+
243+
return processedData;
244+
}
245+
246+
// Helper function to clean up uploaded files when validation fails
247+
async function cleanupSwepUploadedFiles(processedData: any): Promise<void> {
248+
if (processedData.Image && processedData.Image !== '') {
249+
try {
250+
await deleteFile(processedData.Image);
251+
console.log(`Cleaned up uploaded SWEP image after validation failure: ${processedData.Image}`);
252+
} catch (error) {
253+
console.error(`Failed to delete uploaded SWEP image ${processedData.Image}:`, error);
254+
// Don't throw - file cleanup failure shouldn't break the response
255+
}
256+
}
257+
}
258+
259+
// Helper function to clean up unused SWEP image files
260+
async function cleanupSwepUnusedFiles(imageUrl: string): Promise<void> {
261+
try {
262+
// We initialised all banners with common file SWEP.jpg. We should skip removing it till this file will not be used.
263+
if (imageUrl.endsWith('SWEP.jpg')) {
264+
return;
265+
}
266+
await deleteFile(imageUrl);
267+
console.log(`Cleaned up unused SWEP image: ${imageUrl}`);
268+
} catch (error) {
269+
console.error(`Failed to delete SWEP image ${imageUrl}:`, error);
270+
// Don't throw - file cleanup failure shouldn't break the update
271+
}
272+
}

src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import app from './app.js';
22
import connectDB from './config/dbConnection.js';
33
import dotenv from 'dotenv';
4-
import { startVerificationJob } from './jobs/verificationJob.js';
5-
import { startDisablingJob } from './jobs/disablingJob.js';
4+
import { startVerificationJob } from './jobs/verificationOrganisationJob.js';
5+
import { startDisablingJob } from './jobs/disablingOrganisationJob.js';
6+
import { startSwepActivationJob } from './jobs/swepActivationJob.js';
67

78
dotenv.config();
89
connectDB();
@@ -11,6 +12,7 @@ connectDB();
1112
// TODO: think how to restrict this job to run only on production
1213
startVerificationJob();
1314
startDisablingJob();
15+
startSwepActivationJob();
1416

1517
const PORT:any = process.env.PORT;
1618

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { updateRelatedServices } from '../controllers/organisationController.js'
1010
* - Updates all associated services to unpublished state using transactions
1111
*/
1212
export function startDisablingJob() {
13-
// Run daily at midnight (00:00)
14-
cron.schedule('0 0 * * *', async () => {
13+
// Run daily at midnight (00:05)
14+
cron.schedule('5 0 * * *', async () => {
1515
try {
1616
console.log('Running organisation disabling check job...');
1717

0 commit comments

Comments
 (0)