Skip to content

Commit abbb023

Browse files
Merge pull request #39 from StreetSupport/feature/3017-create-location-specific-content-management-system
3017 create location specific content management system
2 parents c84f77c + 98a259a commit abbb023

File tree

14 files changed

+1505
-107
lines changed

14 files changed

+1505
-107
lines changed
Lines changed: 167 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,179 @@
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 } from '../utils/apiResponses.js';
4+
import Resource from '../models/resourceModel.js';
5+
import { validateResource } from '../schemas/resourceSchema.js';
6+
import { deleteFile } from '../middleware/uploadMiddleware.js';
47

5-
// Stub CRUD handlers for Resources
8+
// Get all resources with optional search
69
export const getResources = asyncHandler(async (req: Request, res: Response) => {
7-
return sendError(res, 501, 'Not implemented');
8-
});
9-
10-
export const getResourceById = asyncHandler(async (req: Request, res: Response) => {
11-
return sendError(res, 501, 'Not implemented');
12-
});
10+
const { search } = req.query;
11+
12+
const query: any = {};
13+
14+
// Apply search filter across multiple fields
15+
if (search && typeof search === 'string') {
16+
const searchTerm = search.trim();
1317

14-
export const getResourcesByLocation = asyncHandler(async (req: Request, res: Response) => {
15-
return sendError(res, 501, 'Not implemented');
18+
query.$or = [
19+
{ Key: { $regex: searchTerm, $options: 'i' } },
20+
{ Name: { $regex: searchTerm, $options: 'i' } },
21+
{ Header: { $regex: searchTerm, $options: 'i' } },
22+
{ ShortDescription: { $regex: searchTerm, $options: 'i' } },
23+
{ Body: { $regex: searchTerm, $options: 'i' } },
24+
{ 'LinkList.Name': { $regex: searchTerm, $options: 'i' } },
25+
{ 'LinkList.Description': { $regex: searchTerm, $options: 'i' } },
26+
{ 'LinkList.Links.Title': { $regex: searchTerm, $options: 'i' } },
27+
{ 'LinkList.Links.Link': { $regex: searchTerm, $options: 'i' } }
28+
];
29+
}
30+
31+
const resources = await Resource.find(query)
32+
.sort({ DocumentModifiedDate: -1 })
33+
.lean();
34+
35+
return sendSuccess(res, resources);
1636
});
1737

18-
export const createResource = asyncHandler(async (req: Request, res: Response) => {
19-
return sendError(res, 501, 'Not implemented');
38+
// Get single resource by key
39+
export const getResourceByKey = asyncHandler(async (req: Request, res: Response) => {
40+
const { key } = req.params;
41+
42+
const resource = await Resource.findOne({ Key: key }).lean();
43+
44+
if (!resource) {
45+
return sendNotFound(res, 'Resource not found');
46+
}
47+
48+
return sendSuccess(res, resource);
2049
});
2150

51+
// Update existing resource
2252
export const updateResource = asyncHandler(async (req: Request, res: Response) => {
23-
return sendError(res, 501, 'Not implemented');
24-
});
53+
const { key } = req.params;
54+
55+
// Find existing resource
56+
const existingResource = await Resource.findOne({ Key: key });
57+
58+
if (!existingResource) {
59+
return sendNotFound(res, 'Resource not found');
60+
}
61+
62+
// Process file replacements - extract old file URLs for cleanup
63+
const oldFileUrls: string[] = [];
64+
65+
// Collect all existing file URLs from the resource
66+
if (existingResource.LinkList) {
67+
existingResource.LinkList.forEach((linkList) => {
68+
if (linkList.Links && Array.isArray(linkList.Links)) {
69+
linkList.Links.forEach((item) => {
70+
if (item.Link && item.Link.startsWith('http')) {
71+
oldFileUrls.push(item.Link);
72+
}
73+
});
74+
}
75+
});
76+
}
77+
78+
// Process form data and merge uploaded files
79+
const processedData = processResourceFormData(req.body);
80+
81+
// Validate the processed data
82+
const validation = validateResource(processedData);
83+
84+
if (!validation.success) {
85+
const errorMessages = validation.errors?.map(err => err.message).join(', ') || 'Validation failed';
86+
return sendBadRequest(res, `Validation failed: ${errorMessages}`);
87+
}
2588

26-
export const deleteResource = asyncHandler(async (req: Request, res: Response) => {
27-
return sendError(res, 501, 'Not implemented');
89+
if (!validation.data) {
90+
return sendBadRequest(res, 'Validation data is missing');
91+
}
92+
93+
// Update resource
94+
const updateData = {
95+
...validation.data,
96+
DocumentModifiedDate: new Date(),
97+
};
98+
99+
const updatedResource = await Resource.findOneAndUpdate(
100+
{ Key: key },
101+
updateData,
102+
{ new: true, runValidators: true }
103+
);
104+
105+
if (!updatedResource) {
106+
return sendNotFound(res, 'Resource not found');
107+
}
108+
109+
// Clean up old files that are no longer in the updated resource (non-blocking)
110+
if (oldFileUrls.length > 0 && updatedResource.LinkList) {
111+
const newFileUrls = new Set<string>();
112+
updatedResource.LinkList.forEach((linkList) => {
113+
if (linkList.Links && Array.isArray(linkList.Links)) {
114+
linkList.Links.forEach((item) => {
115+
if (item.Link && item.Link.startsWith('http')) {
116+
newFileUrls.add(item.Link);
117+
}
118+
});
119+
}
120+
});
121+
122+
// Only delete files that are no longer referenced
123+
const filesToDelete = oldFileUrls.filter(url => !newFileUrls.has(url));
124+
if (filesToDelete.length > 0) {
125+
Promise.all(filesToDelete.map(url => deleteFile(url)))
126+
.catch(error => console.error('Error cleaning up old files:', error));
127+
}
128+
}
129+
130+
return sendSuccess(res, updatedResource);
28131
});
132+
133+
// Helper function to process form data and merge uploaded files
134+
function processResourceFormData(body: any): any {
135+
const data = { ...body };
136+
137+
// Parse LinkList if it's a string
138+
if (typeof data.LinkList === 'string') {
139+
try {
140+
data.LinkList = JSON.parse(data.LinkList);
141+
} catch (error) {
142+
console.error('Error parsing LinkList:', error);
143+
data.LinkList = [];
144+
}
145+
}
146+
147+
// Process LinkList to merge uploaded file URLs
148+
if (data.LinkList && Array.isArray(data.LinkList)) {
149+
data.LinkList = data.LinkList.map((linkList: any, listIndex: number) => {
150+
if (linkList.Links && Array.isArray(linkList.Links)) {
151+
linkList.Links = linkList.Links.map((item: any, itemIndex: number) => {
152+
const fieldName = `newfile_LinkList_${listIndex}_Links_${itemIndex}`;
153+
const uploadedFileUrl = data[fieldName];
154+
155+
// If file was uploaded for this item, use the uploaded URL
156+
if (uploadedFileUrl) {
157+
return {
158+
...item,
159+
Link: uploadedFileUrl
160+
};
161+
}
162+
163+
return item;
164+
});
165+
}
166+
167+
return linkList;
168+
});
169+
}
170+
171+
// Remove file field entries from data (they're already merged into LinkList)
172+
Object.keys(data).forEach(key => {
173+
if (key.startsWith('newfile_LinkList_')) {
174+
delete data[key];
175+
}
176+
});
177+
178+
return data;
179+
}

src/jobs/disablingOrganisationJob.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function startDisablingJob() {
103103
}
104104
});
105105

106-
console.log('Organisation disabling job scheduled to run daily at midnight (00:00)');
106+
console.log('Organisation disabling job scheduled to run daily at midnight (00:05)');
107107
}
108108

109109
/**

src/jobs/verificationOrganisationJob.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ export function startVerificationJob() {
108108
}
109109
});
110110

111-
console.log('Verification job scheduled to run daily at 9:00 AM');
111+
console.log('Organisation verification job scheduled to run daily at 9:00 AM');
112112
}
113113

114114
/**

src/middleware/authMiddleware.ts

Lines changed: 2 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,54 +1557,15 @@ export const swepBannersGetAuth = [
15571557
];
15581558

15591559
/**
1560-
* Middleware for resource access control with location validation
1560+
* Middleware for resource access control
15611561
*/
15621562
export const requireResourceAccess = asyncHandler(async (req: Request, res: Response, next: NextFunction) => {
15631563
if (ensureAuthenticated(req, res)) return;
15641564

15651565
const userAuthClaims = req.user?.AuthClaims || [];
15661566

15671567
// SuperAdmin global rule
1568-
if (handleSuperAdminAccess(userAuthClaims)) { return next(); }
1569-
1570-
// Check if user is a CityAdmin
1571-
if (!userAuthClaims.includes(ROLES.CITY_ADMIN)) {
1572-
return sendForbidden(res);
1573-
}
1574-
1575-
// For operations on specific resources, check LocationId access
1576-
const resourceId = req.params.id;
1577-
if (resourceId && (req.method === HTTP_METHODS.GET || req.method === HTTP_METHODS.PUT || req.method === HTTP_METHODS.DELETE)) {
1578-
try{
1579-
// TODO: When Resource model is created with LocationId field, validate against user's CityAdminFor claims
1580-
// For now, allow any CityAdmin to access
1581-
// const resource = await Resource.findById(resourceId).lean();
1582-
1583-
// // For location-based access, check the LocationSlug
1584-
// const locations = (banner?.LocationSlug || '').split(',').map(l => l.trim()).filter(Boolean);
1585-
1586-
// if (validateCityAdminLocationsAccess(userAuthClaims, locations, res)) {
1587-
// return; // Access denied, response already sent
1588-
// }
1589-
1590-
// next();
1591-
}
1592-
catch (error) {
1593-
console.error('Error validating resource access:', error);
1594-
return sendInternalError(res);
1595-
}
1596-
}
1597-
1598-
if (req.body && req.method === HTTP_METHODS.POST) {
1599-
// For location-based access, check the LocationSlug
1600-
const locations = (req.body?.LocationId || '').split(',').map(l => l.trim()).filter(Boolean);
1601-
1602-
if (validateCityAdminLocationsAccess(userAuthClaims, locations, res)) {
1603-
return; // Access denied, response already sent
1604-
}
1605-
1606-
return next();
1607-
}
1568+
if (handleSuperAdminAccess(userAuthClaims) || handleVolunteerAdminAccess(userAuthClaims)) { return next(); }
16081569

16091570
return sendForbidden(res);
16101571
});
@@ -1616,37 +1577,3 @@ export const resourcesAuth = [
16161577
authenticate,
16171578
requireResourceAccess
16181579
];
1619-
1620-
/**
1621-
* Middleware for resource location-based access (GET /resources/location/:locationId)
1622-
*/
1623-
export const requireResourceLocationAccess = (req: Request, res: Response, next: NextFunction) => {
1624-
if (ensureAuthenticated(req, res)) return;
1625-
1626-
const userAuthClaims = req.user?.AuthClaims || [];
1627-
1628-
// SuperAdmin global rule
1629-
if (handleSuperAdminAccess(userAuthClaims)) { return next(); }
1630-
1631-
// Check if user is a CityAdmin
1632-
if (!userAuthClaims.includes(ROLES.CITY_ADMIN)) {
1633-
return sendForbidden(res);
1634-
}
1635-
1636-
// For location-based access, check the location and locations param
1637-
const locations = extractLocationsFromQuery(req);
1638-
1639-
if (validateCityAdminLocationsAccess(userAuthClaims, locations, res)) {
1640-
return; // Access denied, response already sent
1641-
}
1642-
1643-
next();
1644-
};
1645-
1646-
/**
1647-
* Combined middleware for resources endpoint by location
1648-
*/
1649-
export const resourcesByLocationAuth = [
1650-
authenticate,
1651-
requireResourceLocationAccess
1652-
];

src/middleware/uploadMiddleware.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dotenv.config();
1515
const AZURE_STORAGE_CONNECTION_STRING = process.env.AZURE_STORAGE_CONNECTION_STRING;
1616
const BANNERS_CONTAINER_NAME = process.env.AZURE_BANNERS_CONTAINER_NAME || 'banners';
1717
const SWEPS_CONTAINER_NAME = process.env.AZURE_SWEPS_CONTAINER_NAME || 'sweps';
18+
const RESOURCES_CONTAINER_NAME = process.env.AZURE_RESOURCES_CONTAINER_NAME || 'resources';
1819

1920
let blobServiceClient: BlobServiceClient | null = null;
2021
if (AZURE_STORAGE_CONNECTION_STRING) {
@@ -279,6 +280,47 @@ export const uploadSwepImage = async (req: Request, res: Response, next: NextFun
279280
});
280281
};
281282

283+
// Resources-specific upload middleware - handles multiple file uploads to resources container
284+
export const uploadResourceFiles = async (req: Request, res: Response, next: NextFunction) => {
285+
// This will handle files with names like: newfile_LinkList_0_List_0, newfile_LinkList_0_List_1, etc.
286+
const uploadAny = upload.any();
287+
288+
uploadAny(req, res, async (err) => {
289+
if (err) {
290+
return sendBadRequest(res, `File upload error: ${err.message}`);
291+
}
292+
293+
try {
294+
const files = req.files as Express.Multer.File[];
295+
296+
if (!files || files.length === 0) {
297+
// No files uploaded, just continue
298+
return next();
299+
}
300+
301+
// Process each uploaded file and attach to request body
302+
for (const file of files) {
303+
// Upload file to resources container
304+
let fileUrl: string;
305+
if (blobServiceClient) {
306+
fileUrl = await uploadToAzure(file, RESOURCES_CONTAINER_NAME);
307+
} else {
308+
fileUrl = saveToLocal(file, RESOURCES_CONTAINER_NAME);
309+
}
310+
311+
// Attach uploaded file URL to request body using the field name
312+
// Field names are like: newfile_LinkList_0_List_0
313+
req.body[file.fieldname] = fileUrl;
314+
}
315+
316+
next();
317+
} catch (error) {
318+
console.error('Resources upload error:', error);
319+
sendInternalError(res, 'File upload failed');
320+
}
321+
});
322+
};
323+
282324
// Single file upload middleware
283325
export const uploadSingle = (fieldName: string) => [
284326
upload.single(fieldName),

0 commit comments

Comments
 (0)