From abe34816f4010b4a2fea86a4f6203d7e4cb729dd Mon Sep 17 00:00:00 2001 From: Wayne Ngo Date: Fri, 26 Dec 2025 17:26:08 -0800 Subject: [PATCH 1/4] Add PermissionRequest schema and API for LED sign access requests --- .../models/PermissionRequest.js | 30 ++++++ .../routes/PermissionRequest.js | 94 ++++++++++++++++++ .../util/permissionRequestTypes.js | 6 ++ test/api/PermissionRequest.js | 96 +++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 api/main_endpoints/models/PermissionRequest.js create mode 100644 api/main_endpoints/routes/PermissionRequest.js create mode 100644 api/main_endpoints/util/permissionRequestTypes.js create mode 100644 test/api/PermissionRequest.js diff --git a/api/main_endpoints/models/PermissionRequest.js b/api/main_endpoints/models/PermissionRequest.js new file mode 100644 index 000000000..00bb533cb --- /dev/null +++ b/api/main_endpoints/models/PermissionRequest.js @@ -0,0 +1,30 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const PermissionRequestTypes = require('../util/permissionRequestTypes'); + +const PermissionRequestSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + type: { + type: String, + enum: Object.keys(PermissionRequestTypes), + required: true, + }, + deletedAt: { + type: Date, + default: null, + }, + }, + { timestamps: { createdAt: true, updatedAt: false } } +); + +// Compound unique index prevents duplicate active requests per user+type +PermissionRequestSchema.index({ userId: 1, type: 1 }, { unique: true }); + +module.exports = mongoose.model('PermissionRequest', PermissionRequestSchema); + diff --git a/api/main_endpoints/routes/PermissionRequest.js b/api/main_endpoints/routes/PermissionRequest.js new file mode 100644 index 000000000..6db47f48b --- /dev/null +++ b/api/main_endpoints/routes/PermissionRequest.js @@ -0,0 +1,94 @@ +const express = require('express'); +const router = express.Router(); +const PermissionRequest = require('../models/PermissionRequest'); +const { OK, UNAUTHORIZED, SERVER_ERROR, NOT_FOUND, BAD_REQUEST } = require('../../util/constants').STATUS_CODES; +const membershipState = require('../../util/constants.js').MEMBERSHIP_STATE; +const { decodeToken } = require('../util/token-functions.js'); +const logger = require('../../util/logger'); +const PermissionRequestTypes = require('../util/permissionRequestTypes'); + +router.post('/create', async (req, res) => { + const decoded = await decodeToken(req, membershipState.MEMBER); + if (decoded.status !== OK) return res.sendStatus(decoded.status); + + const { type } = req.body; + if (!type || !Object.keys(PermissionRequestTypes).includes(type)) { + return res.status(BAD_REQUEST).send({ error: 'Invalid type' }); + } + + try { + const permissionRequest = await PermissionRequest.create({ + userId: decoded.token._id, + type, + }); + const populated = await PermissionRequest.findById(permissionRequest._id) + .populate('userId', 'firstName lastName email'); + res.status(OK).send(populated); + } catch (error) { + if (error.code === 11000) return res.status(BAD_REQUEST).send({ error: 'Request already exists' }); + logger.error('Failed to create permission request:', error); + res.sendStatus(SERVER_ERROR); + } +}); + +router.get('/getAll', async (req, res) => { + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) return res.sendStatus(decoded.status); + + try { + const requests = await PermissionRequest.find({ deletedAt: null }) + .populate('userId', 'firstName lastName email') + .sort({ createdAt: -1 }); + res.status(OK).send(requests); + } catch (error) { + logger.error('Failed to get permission requests:', error); + res.sendStatus(SERVER_ERROR); + } +}); + +router.get('/get', async (req, res) => { + const decoded = await decodeToken(req, membershipState.MEMBER); + if (decoded.status !== OK) return res.sendStatus(decoded.status); + + try { + const request = await PermissionRequest.findOne({ + userId: decoded.token._id, + type: req.query.type, + deletedAt: null, + }).populate('userId', 'firstName lastName email'); + + if (!request) return res.sendStatus(NOT_FOUND); + res.status(OK).send(request); + } catch (error) { + logger.error('Failed to get permission request:', error); + res.sendStatus(SERVER_ERROR); + } +}); + +router.post('/delete', async (req, res) => { + const decoded = await decodeToken(req, membershipState.MEMBER); + if (decoded.status !== OK) return res.sendStatus(decoded.status); + + const { type } = req.body; + if (!type || !Object.keys(PermissionRequestTypes).includes(type)) { + return res.status(BAD_REQUEST).send({ error: 'Invalid type' }); + } + + try { + const request = await PermissionRequest.findOne({ + userId: decoded.token._id, + type, + deletedAt: null, + }); + if (!request) return res.sendStatus(NOT_FOUND); + request.deletedAt = new Date(); + await request.save(); + res.sendStatus(OK); + } catch (error) { + logger.error('Failed to delete permission request:', error); + res.sendStatus(SERVER_ERROR); + } +}); + +module.exports = router; + diff --git a/api/main_endpoints/util/permissionRequestTypes.js b/api/main_endpoints/util/permissionRequestTypes.js new file mode 100644 index 000000000..e81755573 --- /dev/null +++ b/api/main_endpoints/util/permissionRequestTypes.js @@ -0,0 +1,6 @@ +const PermissionRequestTypes = { + LED_SIGN: 'LED_SIGN', +}; + +module.exports = PermissionRequestTypes; + diff --git a/test/api/PermissionRequest.js b/test/api/PermissionRequest.js new file mode 100644 index 000000000..610a4c2ad --- /dev/null +++ b/test/api/PermissionRequest.js @@ -0,0 +1,96 @@ +process.env.NODE_ENV = 'test'; + +const PermissionRequest = require('../../api/main_endpoints/models/PermissionRequest'); +const chai = require('chai'); +const chaiHttp = require('chai-http'); +const constants = require('../../api/util/constants'); +const { OK, BAD_REQUEST, UNAUTHORIZED, NOT_FOUND } = constants.STATUS_CODES; +const SceApiTester = require('../../test/util/tools/SceApiTester'); +const { + initializeTokenMock, + setTokenStatus, + resetTokenMock, + restoreTokenMock, +} = require('../util/mocks/TokenValidFunctions'); +const mongoose = require('mongoose'); +const PermissionRequestTypes = require('../../api/main_endpoints/util/permissionRequestTypes'); + +let app = null; +let test = null; +const expect = chai.expect; +const tools = require('../util/tools/tools.js'); +chai.should(); +chai.use(chaiHttp); +const token = ''; + +describe('PermissionRequest', () => { + before(done => { + initializeTokenMock(); + app = tools.initializeServer(__dirname + '/../../api/main_endpoints/routes/PermissionRequest.js'); + test = new SceApiTester(app); + tools.emptySchema(PermissionRequest); + done(); + }); + + after(done => { + restoreTokenMock(); + tools.terminateServer(done); + }); + + beforeEach(() => { + setTokenStatus(false); + }); + + afterEach(async () => { + resetTokenMock(); + await PermissionRequest.deleteMany({}); + }); + + describe('/POST create', () => { + it('Should return 401 when token is not sent', async () => { + const res = await test.sendPostRequest('/api/PermissionRequest/create', { type: PermissionRequestTypes.LED_SIGN }); + expect(res).to.have.status(UNAUTHORIZED); + }); + + it('Should create permission request successfully', async () => { + const userId = new mongoose.Types.ObjectId(); + setTokenStatus(true, { _id: userId, email: 'test@test.com', accessLevel: 'MEMBER' }); + const res = await test.sendPostRequestWithToken(token, '/api/PermissionRequest/create', { type: PermissionRequestTypes.LED_SIGN }); + expect(res).to.have.status(OK); + const request = await PermissionRequest.findOne({ userId, type: PermissionRequestTypes.LED_SIGN }); + expect(request).to.exist; + expect(request.type).to.equal(PermissionRequestTypes.LED_SIGN); + }); + }); + + describe('/GET get', () => { + it('Should return 404 when request does not exist', async () => { + const userId = new mongoose.Types.ObjectId(); + setTokenStatus(true, { _id: userId, email: 'test@test.com', accessLevel: 'MEMBER' }); + const res = await test.sendGetRequest('/api/PermissionRequest/get?type=' + PermissionRequestTypes.LED_SIGN); + expect(res).to.have.status(NOT_FOUND); + }); + + it('Should return permission request when it exists', async () => { + const userId = new mongoose.Types.ObjectId(); + setTokenStatus(true, { _id: userId, email: 'test@test.com', accessLevel: 'MEMBER' }); + await new PermissionRequest({ userId, type: PermissionRequestTypes.LED_SIGN }).save(); + const res = await test.sendGetRequest('/api/PermissionRequest/get?type=' + PermissionRequestTypes.LED_SIGN); + expect(res).to.have.status(OK); + expect(res.body.type).to.equal(PermissionRequestTypes.LED_SIGN); + }); + }); + + describe('/POST delete', () => { + it('Should delete permission request successfully', async () => { + const userId = new mongoose.Types.ObjectId(); + setTokenStatus(true, { _id: userId, email: 'test@test.com', accessLevel: 'MEMBER' }); + const request = await new PermissionRequest({ userId, type: PermissionRequestTypes.LED_SIGN }).save(); + const res = await test.sendPostRequestWithToken(token, '/api/PermissionRequest/delete', { type: PermissionRequestTypes.LED_SIGN }); + expect(res).to.have.status(OK); + const deleted = await PermissionRequest.findById(request._id); + expect(deleted.deletedAt).to.not.be.null; + }); + }); +}); + From 52a33f56609de9be21cc8fcdb865a2ef6a559f1c Mon Sep 17 00:00:00 2001 From: Wayne Ngo Date: Fri, 26 Dec 2025 20:23:45 -0800 Subject: [PATCH 2/4] hook error --- test/api/PermissionRequest.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/api/PermissionRequest.js b/test/api/PermissionRequest.js index 610a4c2ad..d3aa7163d 100644 --- a/test/api/PermissionRequest.js +++ b/test/api/PermissionRequest.js @@ -41,9 +41,8 @@ describe('PermissionRequest', () => { setTokenStatus(false); }); - afterEach(async () => { + afterEach(() => { resetTokenMock(); - await PermissionRequest.deleteMany({}); }); describe('/POST create', () => { From 58d0b88046fad8568caa3930b99e78cc20c46c47 Mon Sep 17 00:00:00 2001 From: Wayne Ngo Date: Thu, 1 Jan 2026 22:07:10 -0800 Subject: [PATCH 3/4] feedback changes --- .../models/PermissionRequest.js | 6 +- .../routes/PermissionRequest.js | 84 +++++++++++-------- 2 files changed, 53 insertions(+), 37 deletions(-) diff --git a/api/main_endpoints/models/PermissionRequest.js b/api/main_endpoints/models/PermissionRequest.js index 00bb533cb..4aa8e548d 100644 --- a/api/main_endpoints/models/PermissionRequest.js +++ b/api/main_endpoints/models/PermissionRequest.js @@ -8,11 +8,10 @@ const PermissionRequestSchema = new Schema( type: Schema.Types.ObjectId, ref: 'User', required: true, - index: true, }, type: { type: String, - enum: Object.keys(PermissionRequestTypes), + enum: Object.values(PermissionRequestTypes), required: true, }, deletedAt: { @@ -24,7 +23,8 @@ const PermissionRequestSchema = new Schema( ); // Compound unique index prevents duplicate active requests per user+type -PermissionRequestSchema.index({ userId: 1, type: 1 }, { unique: true }); +PermissionRequestSchema.index({ userId: 1, type: 1 }, { unique: true, + partialFilterExpression: { deletedAt: null } }); module.exports = mongoose.model('PermissionRequest', PermissionRequestSchema); diff --git a/api/main_endpoints/routes/PermissionRequest.js b/api/main_endpoints/routes/PermissionRequest.js index 6db47f48b..480909191 100644 --- a/api/main_endpoints/routes/PermissionRequest.js +++ b/api/main_endpoints/routes/PermissionRequest.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const PermissionRequest = require('../models/PermissionRequest'); -const { OK, UNAUTHORIZED, SERVER_ERROR, NOT_FOUND, BAD_REQUEST } = require('../../util/constants').STATUS_CODES; +const { OK, UNAUTHORIZED, FORBIDDEN, SERVER_ERROR, NOT_FOUND, BAD_REQUEST, CONFLICT } = require('../../util/constants').STATUS_CODES; const membershipState = require('../../util/constants.js').MEMBERSHIP_STATE; const { decodeToken } = require('../util/token-functions.js'); const logger = require('../../util/logger'); @@ -17,28 +17,50 @@ router.post('/create', async (req, res) => { } try { - const permissionRequest = await PermissionRequest.create({ + await PermissionRequest.create({ userId: decoded.token._id, type, }); - const populated = await PermissionRequest.findById(permissionRequest._id) - .populate('userId', 'firstName lastName email'); - res.status(OK).send(populated); + res.sendStatus(OK); } catch (error) { - if (error.code === 11000) return res.status(BAD_REQUEST).send({ error: 'Request already exists' }); + if (error.code === 11000) return res.sendStatus(CONFLICT); logger.error('Failed to create permission request:', error); res.sendStatus(SERVER_ERROR); } }); -router.get('/getAll', async (req, res) => { - const decoded = await decodeToken(req, membershipState.OFFICER); +router.get('/get', async (req, res) => { + const decoded = await decodeToken(req, membershipState.MEMBER); if (decoded.status !== OK) return res.sendStatus(decoded.status); + const { userId: queryUserId, type } = req.query; + const isOfficer = decoded.token.accessLevel >= membershipState.OFFICER; + try { - const requests = await PermissionRequest.find({ deletedAt: null }) + const query = { deletedAt: null }; + + // If no userId provided, return all (officer+ only) + if (!queryUserId) { + if (!isOfficer) { + return res.sendStatus(UNAUTHORIZED); + } + } else { + // If userId provided, check permissions + if (!isOfficer && queryUserId !== decoded.token._id.toString()) { + return res.sendStatus(FORBIDDEN); + } + query.userId = queryUserId; + } + + // Optional type filter + if (type && Object.keys(PermissionRequestTypes).includes(type)) { + query.type = type; + } + + const requests = await PermissionRequest.find(query) .populate('userId', 'firstName lastName email') .sort({ createdAt: -1 }); + res.status(OK).send(requests); } catch (error) { logger.error('Failed to get permission requests:', error); @@ -46,40 +68,34 @@ router.get('/getAll', async (req, res) => { } }); -router.get('/get', async (req, res) => { - const decoded = await decodeToken(req, membershipState.MEMBER); - if (decoded.status !== OK) return res.sendStatus(decoded.status); - - try { - const request = await PermissionRequest.findOne({ - userId: decoded.token._id, - type: req.query.type, - deletedAt: null, - }).populate('userId', 'firstName lastName email'); - - if (!request) return res.sendStatus(NOT_FOUND); - res.status(OK).send(request); - } catch (error) { - logger.error('Failed to get permission request:', error); - res.sendStatus(SERVER_ERROR); - } -}); - router.post('/delete', async (req, res) => { const decoded = await decodeToken(req, membershipState.MEMBER); if (decoded.status !== OK) return res.sendStatus(decoded.status); - const { type } = req.body; + const { type, _id } = req.body; if (!type || !Object.keys(PermissionRequestTypes).includes(type)) { return res.status(BAD_REQUEST).send({ error: 'Invalid type' }); } try { - const request = await PermissionRequest.findOne({ - userId: decoded.token._id, - type, - deletedAt: null, - }); + let request; + + // Officers or admins can delete any request by id + if (decoded.token.accessLevel >= membershipState.OFFICER && _id) { + request = await PermissionRequest.findOne({ + _id, + type, + deletedAt: null, + }); + } else { + // Members can delete their own requests and officers can delete their own requests without id + request = await PermissionRequest.findOne({ + userId: decoded.token._id, + type, + deletedAt: null, + }); + } + if (!request) return res.sendStatus(NOT_FOUND); request.deletedAt = new Date(); await request.save(); From 9a70800697d536dbd8fee483003b46bd89915b06 Mon Sep 17 00:00:00 2001 From: Wayne Ngo Date: Thu, 1 Jan 2026 22:17:22 -0800 Subject: [PATCH 4/4] lint fix --- api/main_endpoints/models/PermissionRequest.js | 3 +-- api/main_endpoints/routes/PermissionRequest.js | 7 +++---- test/api/PermissionRequest.js | 12 +++++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/main_endpoints/models/PermissionRequest.js b/api/main_endpoints/models/PermissionRequest.js index 4aa8e548d..5d42f4362 100644 --- a/api/main_endpoints/models/PermissionRequest.js +++ b/api/main_endpoints/models/PermissionRequest.js @@ -23,8 +23,7 @@ const PermissionRequestSchema = new Schema( ); // Compound unique index prevents duplicate active requests per user+type -PermissionRequestSchema.index({ userId: 1, type: 1 }, { unique: true, - partialFilterExpression: { deletedAt: null } }); +PermissionRequestSchema.index({ userId: 1, type: 1 }, { unique: true, partialFilterExpression: { deletedAt: null }}); module.exports = mongoose.model('PermissionRequest', PermissionRequestSchema); diff --git a/api/main_endpoints/routes/PermissionRequest.js b/api/main_endpoints/routes/PermissionRequest.js index 480909191..b2ad88eb9 100644 --- a/api/main_endpoints/routes/PermissionRequest.js +++ b/api/main_endpoints/routes/PermissionRequest.js @@ -39,20 +39,20 @@ router.get('/get', async (req, res) => { try { const query = { deletedAt: null }; - // If no userId provided, return all (officer+ only) + // If theres no userId, return all for officers and admins if (!queryUserId) { if (!isOfficer) { return res.sendStatus(UNAUTHORIZED); } } else { - // If userId provided, check permissions + // If there is a userId, check their perms if (!isOfficer && queryUserId !== decoded.token._id.toString()) { return res.sendStatus(FORBIDDEN); } query.userId = queryUserId; } - // Optional type filter + // If there is a type, filter by it if (type && Object.keys(PermissionRequestTypes).includes(type)) { query.type = type; } @@ -79,7 +79,6 @@ router.post('/delete', async (req, res) => { try { let request; - // Officers or admins can delete any request by id if (decoded.token.accessLevel >= membershipState.OFFICER && _id) { request = await PermissionRequest.findOne({ diff --git a/test/api/PermissionRequest.js b/test/api/PermissionRequest.js index d3aa7163d..b518b302a 100644 --- a/test/api/PermissionRequest.js +++ b/test/api/PermissionRequest.js @@ -63,20 +63,22 @@ describe('PermissionRequest', () => { }); describe('/GET get', () => { - it('Should return 404 when request does not exist', async () => { + it('Should return empty array when request does not exist', async () => { const userId = new mongoose.Types.ObjectId(); setTokenStatus(true, { _id: userId, email: 'test@test.com', accessLevel: 'MEMBER' }); - const res = await test.sendGetRequest('/api/PermissionRequest/get?type=' + PermissionRequestTypes.LED_SIGN); - expect(res).to.have.status(NOT_FOUND); + const res = await test.sendGetRequest('/api/PermissionRequest/get?userId=' + userId + '&type=' + PermissionRequestTypes.LED_SIGN); + expect(res).to.have.status(OK); + expect(res.body).to.be.an('array').that.is.empty; }); it('Should return permission request when it exists', async () => { const userId = new mongoose.Types.ObjectId(); setTokenStatus(true, { _id: userId, email: 'test@test.com', accessLevel: 'MEMBER' }); await new PermissionRequest({ userId, type: PermissionRequestTypes.LED_SIGN }).save(); - const res = await test.sendGetRequest('/api/PermissionRequest/get?type=' + PermissionRequestTypes.LED_SIGN); + const res = await test.sendGetRequest('/api/PermissionRequest/get?userId=' + userId + '&type=' + PermissionRequestTypes.LED_SIGN); expect(res).to.have.status(OK); - expect(res.body.type).to.equal(PermissionRequestTypes.LED_SIGN); + expect(res.body).to.be.an('array').with.length(1); + expect(res.body[0].type).to.equal(PermissionRequestTypes.LED_SIGN); }); });