diff --git a/api/main_endpoints/models/PermissionRequest.js b/api/main_endpoints/models/PermissionRequest.js new file mode 100644 index 000000000..5d42f4362 --- /dev/null +++ b/api/main_endpoints/models/PermissionRequest.js @@ -0,0 +1,29 @@ +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, + }, + type: { + type: String, + enum: Object.values(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, 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 new file mode 100644 index 000000000..b2ad88eb9 --- /dev/null +++ b/api/main_endpoints/routes/PermissionRequest.js @@ -0,0 +1,109 @@ +const express = require('express'); +const router = express.Router(); +const PermissionRequest = require('../models/PermissionRequest'); +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'); +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 { + await PermissionRequest.create({ + userId: decoded.token._id, + type, + }); + res.sendStatus(OK); + } catch (error) { + if (error.code === 11000) return res.sendStatus(CONFLICT); + logger.error('Failed to create permission request:', 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); + + const { userId: queryUserId, type } = req.query; + const isOfficer = decoded.token.accessLevel >= membershipState.OFFICER; + + try { + const query = { deletedAt: null }; + + // If theres no userId, return all for officers and admins + if (!queryUserId) { + if (!isOfficer) { + return res.sendStatus(UNAUTHORIZED); + } + } else { + // If there is a userId, check their perms + if (!isOfficer && queryUserId !== decoded.token._id.toString()) { + return res.sendStatus(FORBIDDEN); + } + query.userId = queryUserId; + } + + // If there is a type, filter by it + 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); + 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, _id } = req.body; + if (!type || !Object.keys(PermissionRequestTypes).includes(type)) { + return res.status(BAD_REQUEST).send({ error: 'Invalid type' }); + } + + try { + 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(); + 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..b518b302a --- /dev/null +++ b/test/api/PermissionRequest.js @@ -0,0 +1,97 @@ +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(() => { + resetTokenMock(); + }); + + 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 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?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?userId=' + userId + '&type=' + PermissionRequestTypes.LED_SIGN); + expect(res).to.have.status(OK); + expect(res.body).to.be.an('array').with.length(1); + expect(res.body[0].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; + }); + }); +}); +