diff --git a/.gitignore b/.gitignore index 4ba8a05..7207111 100644 --- a/.gitignore +++ b/.gitignore @@ -89,7 +89,6 @@ out # Nuxt.js build / generate output .nuxt -dist # Gatsby files .cache/ @@ -131,3 +130,4 @@ dist # Miscellaneous .DS_Store +.vercel diff --git a/backend/__tests__/exampleTest.test.ts b/backend/__tests__/exampleTest.test.ts deleted file mode 100644 index ee5dd0a..0000000 --- a/backend/__tests__/exampleTest.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { MongoClient } from "mongodb"; -import { MongoMemoryServer } from "mongodb-memory-server"; -import mongoose from "mongoose"; -import request from "supertest"; - -import app from "src/app"; - -const TEST_DB_NAME = "test"; - -let client: MongoClient, memoryServer: MongoMemoryServer; - -/** - * This hook runs once, before all our tests are run. In this function, we - * set up a Mongo memory server, which emulates MongoDB by storing data in memory, to - * make it faster to run tests using the database. This allows our backend code to interface - * with MongoDB like normal, but all its actions will go to our test database. - */ -beforeAll(async () => { - // Create Mongo memory server - memoryServer = await MongoMemoryServer.create({ - instance: { - dbName: TEST_DB_NAME, - }, - }); - - // Get URI of memory server - const testMongoUri = memoryServer.getUri(); - - /** - * Connect to memory server, so that we can drop data between tests - * in the "afterEach" hook - */ - client = await MongoClient.connect(testMongoUri); - - /** - * Connect Mongoose to memory server, so we can use Mongoose like normal - * in our backend code that we are testing - */ - await mongoose.connect(testMongoUri); -}); - -/** - * This hook runs after each test in our suite. We use this function to drop all data - * from our testing MongoDB database, so that the database will be fresh and empty for each test. - * - * We may also want a "beforeEach" hook in the future, to insert some example data into - * the testing MongoDB database before each of our tests, so that we have some data to run tests on. - */ -afterEach(async () => { - // Drop all data from test database - await client.db(TEST_DB_NAME).dropDatabase(); -}); - -/** - * This hook runs once, after all tests in the suite are done. We use this function - * to close connections to the MongoDB memory database and clean up any other resources. - */ -afterAll(async () => { - await client.close(true); - await memoryServer.stop(); - await mongoose.disconnect(); -}); - -describe("Backend Example Tests", () => { - it("Throws 404 on GET /", async () => { - /** - * Since we don't have any routes yet, our Express server should return - * a "404 Not Found" response on any URL we request - */ - const res = await request(app).get("/"); - expect(res.header["content-type"]).toBe("text/html; charset=utf-8"); - expect(res.statusCode).toBe(404); - }); -}); diff --git a/backend/__tests__/furnitureItemTest.test.ts b/backend/__tests__/furnitureItemTest.test.ts new file mode 100644 index 0000000..28f6067 --- /dev/null +++ b/backend/__tests__/furnitureItemTest.test.ts @@ -0,0 +1,49 @@ +import request from "supertest"; + +import app from "src/app"; +import { mongoMemoryHooks } from "src/tests/testUtils"; +import FurnitureItemModel from "src/models/furnitureItem"; + +describe("Furniture Item Tests", () => { + mongoMemoryHooks(); + + it("GET api/furnitureItems returns all available furniture items", async () => { + // Create a few testing furniture items and ensure they can be retrieved + await FurnitureItemModel.create( + { + category: "Bedroom", + name: "Table", + allowMultiple: false, + categoryIndex: 1, + }, + { + category: "Bedroom", + name: "Rug", + allowMultiple: false, + categoryIndex: 2, + }, + { + category: "Bathroom", + name: "Wash Cloth(s)", + allowMultiple: true, + categoryIndex: 2, + }, + { + category: "Bathroom", + name: "Towel(s)", + allowMultiple: true, + categoryIndex: 1, + }, + ); + + const res = await request(app).get("/api/furnitureItems"); + expect(res.statusCode).toBe(200); + expect(res.body.length).toBe(4); + + // Should be ordered by allowMultiple (desc), then categoryIndex (asc) + expect(res.body[0].name).toBe("Towel(s)"); + expect(res.body[1].name).toBe("Wash Cloth(s)"); + expect(res.body[2].name).toBe("Table"); + expect(res.body[3].name).toBe("Rug"); + }); +}); diff --git a/backend/__tests__/userTest.test.ts b/backend/__tests__/userTest.test.ts new file mode 100644 index 0000000..9d2b672 --- /dev/null +++ b/backend/__tests__/userTest.test.ts @@ -0,0 +1,40 @@ +import request from "supertest"; + +import app from "src/app"; +import { getTestUserToken, mongoMemoryHooks } from "src/tests/testUtils"; +import UserModel, { UserRole } from "src/models/user"; +import { firebaseAuth } from "src/services/firebase"; +import env from "src/util/validateEnv"; + +describe("User Tests", () => { + mongoMemoryHooks(); + + it("GET /api/user/whoami returns 401 when no token is provided", async () => { + const res = await request(app).get("/api/user/whoami"); + expect(res.statusCode).toBe(401); + }); + + it("GET /api/user/whoami returns 401 when an invalid token is provided", async () => { + const res = await request(app) + .get("/api/user/whoami") + .set("Authorization", "Bearer invalidtoken"); + expect(res.statusCode).toBe(401); + }); + + it("GET /api/user/whoami returns current user when a valid token is provided", async () => { + const userInfo = await firebaseAuth.getUserByEmail(env.EMAIL_USER); + const testUser = await UserModel.create({ + role: UserRole.STAFF, + uid: userInfo.uid, + }); + const testToken = await getTestUserToken(); + + const res = await request(app) + .get("/api/user/whoami") + .set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(200); + expect(res.body.uid).toBe(userInfo.uid); + expect(res.body.role).toBe(UserRole.STAFF); + expect(res.body._id).toBe(testUser._id.toString()); + }); +}); diff --git a/backend/__tests__/vsrTest.test.ts b/backend/__tests__/vsrTest.test.ts new file mode 100644 index 0000000..ad85067 --- /dev/null +++ b/backend/__tests__/vsrTest.test.ts @@ -0,0 +1,360 @@ +import request from "supertest"; + +import app from "src/app"; +import { getTestUserInfo, getTestUserToken, mongoMemoryHooks } from "src/tests/testUtils"; +import UserModel, { UserRole } from "src/models/user"; +import VSRModel from "src/models/vsr"; + +describe("VSR Tests", () => { + mongoMemoryHooks(); + + const vsrTestData = { + name: "Test Veteran 1", + gender: "Female", + age: "54", + maritalStatus: "Single", + agesOfBoys: [10], + agesOfGirls: [11, 12], + ethnicity: ["Caucasian"], + employmentStatus: "Employed", + incomeLevel: "$12,500 - $25,000", + sizeOfHome: "House", + streetAddress: "1111 TSE Lane", + city: "La Jolla", + state: "CA", + zipCode: "11245", + phoneNumber: "1112224444", + email: "tsepapdev@gmail.com", + branch: ["Air Force", "Navy"], + conflicts: ["WWII", "Irani Crisis"], + dischargeStatus: "dischargeStatus-1", + serviceConnected: false, + lastRank: "E-5", + militaryID: 9999, + petCompanion: true, + hearFrom: "Family-1", + selectedFurnitureItems: [], + }; + + const createTestVSR = async () => { + const currentDate = new Date(); + + return await VSRModel.create({ + ...vsrTestData, + dateReceived: currentDate, + lastUpdated: currentDate, + + status: "Received", + }); + }; + + const signInAsRole = async (role: UserRole) => { + const testUserInfo = await getTestUserInfo(); + const testUser = await UserModel.create({ + role, + uid: testUserInfo.uid, + }); + + return { testUser, testToken: await getTestUserToken() }; + }; + + it("POST /api/vsr creates a new VSR", async () => { + const res = await request(app) + .post("/api/vsr") + .send(vsrTestData) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(201); + + // Ensure that VSR was actually created in the DB + const allVsrs = await VSRModel.find(); + expect(allVsrs.length).toBe(1); + }); + + it("GET /api/vsr requires user to be signed in", async () => { + const res = await request(app).get("/api/vsr"); + expect(res.statusCode).toBe(401); + }); + + it("GET /api/vsr returns all submitted VSRs to staff", async () => { + await Promise.all(Array(3).fill(null).map(createTestVSR)); + + const { testToken } = await signInAsRole(UserRole.STAFF); + + const res = await request(app).get("/api/vsr").set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(200); + expect(res.body.vsrs.length).toBe(3); + expect(res.body.vsrs[0].name).toBe("Test Veteran 1"); + }); + + it("GET /api/vsr returns all submitted VSRs to admin", async () => { + await Promise.all(Array(3).fill(null).map(createTestVSR)); + + const { testToken } = await signInAsRole(UserRole.STAFF); + + const res = await request(app).get("/api/vsr").set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(200); + expect(res.body.vsrs.length).toBe(3); + expect(res.body.vsrs[0].name).toBe("Test Veteran 1"); + }); + + it("GET /api/vsr/:id requires user to be signed in", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const res = await request(app).get(`/api/vsr/${vsrId}`); + expect(res.statusCode).toBe(401); + }); + + it("GET /api/vsr/:id returns 404 for invalid VSR id", async () => { + const { testToken } = await signInAsRole(UserRole.STAFF); + + // We can use any Object ID here because no VSRs have been created yet + const res = await request(app) + .get("/api/vsr/65bc31561826f0d6ee2c4b21") + .set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(404); + }); + + it("GET /api/vsr/:id returns a single VSR to staff", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const { testToken } = await signInAsRole(UserRole.STAFF); + + const res = await request(app) + .get(`/api/vsr/${vsrId}`) + .set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(200); + expect(res.body.name).toBe("Test Veteran 1"); + expect(res.body.status).toBe("Received"); + expect(res.body._id).toBe(vsrId.toString()); + }); + + it("GET /api/vsr/:id returns a single VSR to admin", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const { testToken } = await signInAsRole(UserRole.ADMIN); + + const res = await request(app) + .get(`/api/vsr/${vsrId}`) + .set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(200); + expect(res.body.name).toBe("Test Veteran 1"); + expect(res.body.status).toBe("Received"); + expect(res.body._id).toBe(vsrId.toString()); + }); + + it("DELETE /api/vsr/:id requires user to be signed in", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const res = await request(app).delete(`/api/vsr/${vsrId}`); + expect(res.statusCode).toBe(401); + + // Ensure that VSR was not deleted from the DB + const allVsrs = await VSRModel.find(); + expect(allVsrs.length).toBe(1); + }); + + it("DELETE /api/vsr/:id returns permission denied to non-admin user", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const { testToken } = await signInAsRole(UserRole.STAFF); + + const res = await request(app) + .delete(`/api/vsr/${vsrId}`) + .set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(403); + + // Ensure that VSR was not deleted from the DB + const allVsrs = await VSRModel.find(); + expect(allVsrs.length).toBe(1); + }); + + it("DELETE /api/vsr/:id returns 404 for invalid VSR id", async () => { + const { testToken } = await signInAsRole(UserRole.ADMIN); + + const res = await request(app) + .delete("/api/vsr/65bc31561826f0d6ee2c4b21") + .set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(404); + }); + + it("DELETE /api/vsr/:id successfully deletes valid VSR", async () => { + await Promise.all(Array(3).fill(null).map(createTestVSR)); + const allVsrs = await VSRModel.find(); + const vsrId = allVsrs[0]._id; + + const { testToken } = await signInAsRole(UserRole.ADMIN); + + const res = await request(app) + .delete(`/api/vsr/${vsrId}`) + .set("Authorization", `Bearer ${testToken}`); + expect(res.statusCode).toBe(204); + + // Ensure that VSR was deleted from the DB + const newAllVsrs = await VSRModel.find(); + expect(newAllVsrs.length).toBe(2); + + const deletedVsr = await VSRModel.findById(vsrId); + expect(deletedVsr).toBeNull(); + }); + + it("PATCH /api/vsr/:id/status requires user to be signed in", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const res = await request(app) + .patch(`/api/vsr/${vsrId}/status`) + .send({ status: "Approved" }) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(401); + + // Ensure that VSR status in DB was not changed + const vsr = await VSRModel.findById(vsrId); + expect(vsr).not.toBeNull(); + expect(vsr!.status).toBe("Received"); + }); + + it("PATCH /api/vsr/:id/status returns 404 for invalid VSR id", async () => { + const { testToken } = await signInAsRole(UserRole.ADMIN); + + const res = await request(app) + .patch("/api/vsr/65bc31561826f0d6ee2c4b21/status") + .send({ status: "Approved" }) + .set("Authorization", `Bearer ${testToken}`) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(404); + }); + + it("PATCH /api/vsr/:id/status throws 400 when status is missing", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const { testToken } = await signInAsRole(UserRole.ADMIN); + + const res = await request(app) + .patch(`/api/vsr/${vsrId}/status`) + .send({}) + .set("Authorization", `Bearer ${testToken}`) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(400); + + // Ensure that VSR status in DB was not changed + const vsr = await VSRModel.findById(vsrId); + expect(vsr).not.toBeNull(); + expect(vsr!.status).toBe("Received"); + }); + + it("PATCH /api/vsr/:id/status successfully updates VSR status", async () => { + await Promise.all(Array(3).fill(null).map(createTestVSR)); + const allVsrs = await VSRModel.find(); + const vsrId = allVsrs[0]._id; + + const { testToken } = await signInAsRole(UserRole.ADMIN); + + const res = await request(app) + .patch(`/api/vsr/${vsrId}/status`) + .send({ status: "Approved" }) + .set("Authorization", `Bearer ${testToken}`) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(200); + + // Ensure that the correct VSR's status was updated + const newAllVsrs = await VSRModel.find(); + expect(newAllVsrs.length).toBe(3); + + for (const vsr of newAllVsrs) { + if (vsr._id.toString() === vsrId.toString()) { + expect(vsr.status).toBe("Approved"); + } else { + expect(vsr.status).toBe("Received"); + } + } + }); + + it("PUT /api/vsr/:id requires user to be signed in", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const res = await request(app) + .put(`/api/vsr/${vsrId}`) + .send({ + ...vsrTestData, + name: "Updated name", + email: "updatedemail@gmail.com", + }) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(401); + + // Ensure that VSR in DB was not updated + const currentVsr = await VSRModel.findById(vsrId); + expect(currentVsr).not.toBeNull(); + expect(currentVsr!.name).toBe("Test Veteran 1"); + expect(currentVsr!.email).toBe("tsepapdev@gmail.com"); + }); + + it("PUT /api/vsr/:id returns 404 for invalid VSR id", async () => { + const { testToken } = await signInAsRole(UserRole.STAFF); + + const res = await request(app) + .put("/api/vsr/65bc31561826f0d6ee2c4b21") + .send({ + ...vsrTestData, + name: "Updated name", + email: "updatedemail@gmail.com", + }) + .set("Authorization", `Bearer ${testToken}`) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(404); + }); + + it("PUT /api/vsr/:id throws 400 when required fields are missing", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const { testToken } = await signInAsRole(UserRole.STAFF); + + const res = await request(app) + .put(`/api/vsr/${vsrId}`) + .send({ + name: "Updated name", + email: "updatedemail@gmail.com", + }) + .set("Authorization", `Bearer ${testToken}`) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(400); + + // Ensure that VSR in DB was not updated + const currentVsr = await VSRModel.findById(vsrId); + expect(currentVsr).not.toBeNull(); + expect(currentVsr!.name).toBe("Test Veteran 1"); + expect(currentVsr!.email).toBe("tsepapdev@gmail.com"); + }); + + it("PUT /api/vsr/:id successfully updates VSR data", async () => { + const testVsr = await createTestVSR(); + const vsrId = testVsr._id; + + const { testToken } = await signInAsRole(UserRole.STAFF); + + const res = await request(app) + .put(`/api/vsr/${vsrId}`) + .send({ + ...vsrTestData, + name: "Updated name", + email: "updatedemail@gmail.com", + }) + .set("Authorization", `Bearer ${testToken}`) + .set("Content-Type", "application/json"); + expect(res.statusCode).toBe(200); + + // Ensure that VSR in DB was updated + const currentVsr = await VSRModel.findById(vsrId); + expect(currentVsr).not.toBeNull(); + expect(currentVsr!.name).toBe("Updated name"); + expect(currentVsr!.email).toBe("updatedemail@gmail.com"); + }); +}); diff --git a/backend/dist/public/pap_logo.png b/backend/dist/public/pap_logo.png new file mode 100644 index 0000000..4a2c8db Binary files /dev/null and b/backend/dist/public/pap_logo.png differ diff --git a/backend/dist/src/app.js b/backend/dist/src/app.js new file mode 100644 index 0000000..60fbd48 --- /dev/null +++ b/backend/dist/src/app.js @@ -0,0 +1,53 @@ +"use strict"; +/** + * Defines server and middleware. + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +require("dotenv/config"); +const cors_1 = __importDefault(require("cors")); +const express_1 = __importDefault(require("express")); +const http_errors_1 = require("http-errors"); +const vsr_1 = __importDefault(require("./routes/vsr")); +const furnitureItem_1 = __importDefault(require("./routes/furnitureItem")); +const user_1 = __importDefault(require("./routes/user")); +const validateEnv_1 = __importDefault(require("./util/validateEnv")); +const app = (0, express_1.default)(); +// initializes Express to accept JSON in the request/response body +app.use(express_1.default.json()); +// sets the "Access-Control-Allow-Origin" header on all responses to allow +// requests from the frontend, which has a different origin - see the following +// pages for more info: +// https://expressjs.com/en/resources/middleware/cors.html +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin +app.use((0, cors_1.default)({ + origin: validateEnv_1.default.FRONTEND_ORIGIN, +})); +// Put routes here (e.g. app.use("/api/example", exampleRoutes); ) +app.use("/api/user", user_1.default); +app.use("/api/vsr", vsr_1.default); +app.use("/api/furnitureItems", furnitureItem_1.default); +/** + * Error handler; all errors thrown by server are handled here. + * Explicit typings required here because TypeScript cannot infer the argument types. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars +app.use((error, req, res, next) => { + // 500 is the "internal server error" error code, this will be our fallback + let statusCode = 500; + let errorMessage = "An error has occurred."; + // check is necessary because anything can be thrown, type is not guaranteed + if ((0, http_errors_1.isHttpError)(error)) { + // error.status is unique to the http error class, it allows us to pass status codes with errors + statusCode = error.status; + errorMessage = error.message; + } + // prefer custom http errors but if they don't exist, fallback to default + else if (error instanceof Error) { + errorMessage = error.message; + } + res.status(statusCode).json({ error: errorMessage }); +}); +exports.default = app; diff --git a/backend/dist/src/controllers/furnitureItem.js b/backend/dist/src/controllers/furnitureItem.js new file mode 100644 index 0000000..a987b0d --- /dev/null +++ b/backend/dist/src/controllers/furnitureItem.js @@ -0,0 +1,34 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getFurnitureItems = void 0; +const furnitureItem_1 = __importDefault(require("../models/furnitureItem")); +/** + * Gets all available furniture items in the database. Does not require authentication. + */ +const getFurnitureItems = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const furnitureItems = yield furnitureItem_1.default.find().sort({ + // First, sort the items by whether they allow multiple (true before false) + allowMultiple: -1, + // Second, sort by category index (ascending) + categoryIndex: 1, + }); + res.status(200).json(furnitureItems); + } + catch (error) { + next(error); + } +}); +exports.getFurnitureItems = getFurnitureItems; diff --git a/backend/dist/src/controllers/user.js b/backend/dist/src/controllers/user.js new file mode 100644 index 0000000..8138ae5 --- /dev/null +++ b/backend/dist/src/controllers/user.js @@ -0,0 +1,36 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getWhoAmI = void 0; +const user_1 = __importDefault(require("../models/user")); +/** + * Retrieves data about the current user (their MongoDB ID, Firebase UID, and role). + * Requires the user to be signed in. + */ +const getWhoAmI = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const { userUid } = req; + const user = yield user_1.default.findOne({ uid: userUid }); + const { _id, uid, role } = user; + res.status(200).send({ + _id, + uid, + role, + }); + } + catch (error) { + next(error); + } +}); +exports.getWhoAmI = getWhoAmI; diff --git a/backend/dist/src/controllers/vsr.js b/backend/dist/src/controllers/vsr.js new file mode 100644 index 0000000..10b0625 --- /dev/null +++ b/backend/dist/src/controllers/vsr.js @@ -0,0 +1,144 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.deleteVSR = exports.updateVSR = exports.updateStatus = exports.createVSR = exports.getVSR = exports.getAllVSRS = void 0; +const express_validator_1 = require("express-validator"); +const http_errors_1 = __importDefault(require("http-errors")); +const vsr_1 = __importDefault(require("../models/vsr")); +const emails_1 = require("../services/emails"); +const validationErrorParser_1 = __importDefault(require("../util/validationErrorParser")); +/** + * Gets all VSRs in the database. Requires the user to be signed in and have + * staff or admin permission. + */ +const getAllVSRS = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const vsrs = yield vsr_1.default.find(); + res.status(200).json({ vsrs }); + } + catch (error) { + next(error); + } +}); +exports.getAllVSRS = getAllVSRS; +/** + * Retrieves a single VSR by its ID. Requires the user to get signed in and have + * staff or admin permission. + */ +const getVSR = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const { id } = req.params; + try { + const vsr = yield vsr_1.default.findById(id); + if (vsr === null) { + throw (0, http_errors_1.default)(404, "VSR not found at id " + id); + } + res.status(200).json(vsr); + } + catch (error) { + next(error); + } +}); +exports.getVSR = getVSR; +/** + * Creates a new VSR in the database, called when a veteran submits the VSR form. + * Does not require authentication. + */ +const createVSR = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + // Extract any errors that were found by the validator + const errors = (0, express_validator_1.validationResult)(req); + try { + // if there are errors, then this function throws an exception + (0, validationErrorParser_1.default)(errors); + // Get the current date as a timestamp for when VSR was submitted + const currentDate = new Date(); + const vsr = yield vsr_1.default.create(Object.assign(Object.assign({}, req.body), { + // Use current date as timestamp for received & updated + dateReceived: currentDate, lastUpdated: currentDate, status: "Received" })); + // Once the VSR is created successfully, send notification & confirmation emails + (0, emails_1.sendVSRNotificationEmailToStaff)(req.body.name, req.body.email, vsr._id.toString()); + (0, emails_1.sendVSRConfirmationEmailToVeteran)(req.body.name, req.body.email); + // 201 means a new resource has been created successfully + // the newly created VSR is sent back to the user + res.status(201).json(vsr); + } + catch (error) { + next(error); + } +}); +exports.createVSR = createVSR; +/** + * Updates a VSR's status, by its ID. Requires the user to be signed in and + * have staff or admin permission. + */ +const updateStatus = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + // extract any errors that were found by the validator + const errors = (0, express_validator_1.validationResult)(req); + const { status } = req.body; + // if there are errors, then this function throws an exception + (0, validationErrorParser_1.default)(errors); + // Get the current date as a timestamp for when VSR was updated + const currentDate = new Date(); + const { id } = req.params; + const vsr = yield vsr_1.default.findByIdAndUpdate(id, { status, lastUpdated: currentDate }, { new: true }); + if (vsr === null) { + throw (0, http_errors_1.default)(404, "VSR not found at id " + id); + } + res.status(200).json(vsr); + } + catch (error) { + next(error); + } +}); +exports.updateStatus = updateStatus; +/** + * Updates a VSR's data, by its ID. Requires the user to be signed in and + * have staff or admin permission. + */ +const updateVSR = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const errors = (0, express_validator_1.validationResult)(req); + try { + const { id } = req.params; + // Get the current date as a timestamp for when VSR was updated + const currentDate = new Date(); + (0, validationErrorParser_1.default)(errors); + const updatedVSR = yield vsr_1.default.findByIdAndUpdate(id, Object.assign(Object.assign({}, req.body), { lastUpdated: currentDate }), { new: true }); + if (updatedVSR === null) { + throw (0, http_errors_1.default)(404, "VSR not found at id " + id); + } + res.status(200).json(updatedVSR); + } + catch (error) { + next(error); + } +}); +exports.updateVSR = updateVSR; +/** + * Deletes a VSR from the database, by its ID. Requires the user to be signed in + * and have admin permission. + */ +const deleteVSR = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + try { + const { id } = req.params; + const deletedVsr = yield vsr_1.default.findByIdAndDelete(id); + if (deletedVsr === null) { + throw (0, http_errors_1.default)(404, "VSR not found at id " + id); + } + return res.status(204).send(); + } + catch (error) { + next(error); + } +}); +exports.deleteVSR = deleteVSR; diff --git a/backend/dist/src/errors/auth.js b/backend/dist/src/errors/auth.js new file mode 100644 index 0000000..f8b975e --- /dev/null +++ b/backend/dist/src/errors/auth.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AuthError = void 0; +const errors_1 = require("../errors/errors"); +const DECODE_ERROR = "Error in decoding the auth token. Make sure the auth token is valid"; +const TOKEN_NOT_IN_HEADER = "Token was not found in header. Be sure to use Bearer syntax"; +const INVALID_AUTH_TOKEN = "Token was invalid."; +const USER_NOT_FOUND = "User not found"; +const NOT_STAFF_OR_ADMIN = "User must be a staff/admin."; +const NOT_ADMIN = "User must be an admin."; +/** + * List of authentication-related errors that may be thrown by our backend. + */ +class AuthError extends errors_1.CustomError { +} +exports.AuthError = AuthError; +AuthError.DECODE_ERROR = new AuthError(0, 401, DECODE_ERROR); +AuthError.TOKEN_NOT_IN_HEADER = new AuthError(1, 401, TOKEN_NOT_IN_HEADER); +AuthError.INVALID_AUTH_TOKEN = new AuthError(2, 401, INVALID_AUTH_TOKEN); +AuthError.USER_NOT_FOUND = new AuthError(3, 401, USER_NOT_FOUND); +AuthError.NOT_STAFF_OR_ADMIN = new AuthError(4, 403, NOT_STAFF_OR_ADMIN); +AuthError.NOT_ADMIN = new AuthError(5, 403, NOT_ADMIN); diff --git a/backend/dist/src/errors/errors.js b/backend/dist/src/errors/errors.js new file mode 100644 index 0000000..b8349a6 --- /dev/null +++ b/backend/dist/src/errors/errors.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CustomError = void 0; +/** + * Class for a custom error object sent from our backend. + */ +class CustomError extends Error { + constructor(code, status, message) { + super(message); + this.code = code; + this.status = status; + this.message = message; + this.context = []; + } + displayMessage(clientFacing) { + if (clientFacing) { + return `${this.message}`; + } + return `Error: Type ${this.constructor.name}, Code ${this.code}, Context: ${this.context.length ? "\n" + this.context.join("\n\n") : null}`; + } +} +exports.CustomError = CustomError; diff --git a/backend/dist/src/errors/internal.js b/backend/dist/src/errors/internal.js new file mode 100644 index 0000000..00c0112 --- /dev/null +++ b/backend/dist/src/errors/internal.js @@ -0,0 +1,12 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.InternalError = void 0; +const errors_1 = require("../errors/errors"); +const NO_SERVICE_ACCOUNT_KEY = "Could not find service account key env variable"; +/** + * List of internal errors that can be thrown by our backend if something goes wrong. + */ +class InternalError extends errors_1.CustomError { +} +exports.InternalError = InternalError; +InternalError.NO_SERVICE_ACCOUNT_KEY = new InternalError(0, 500, NO_SERVICE_ACCOUNT_KEY); diff --git a/backend/dist/src/errors/service.js b/backend/dist/src/errors/service.js new file mode 100644 index 0000000..e4346df --- /dev/null +++ b/backend/dist/src/errors/service.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ServiceError = void 0; +const errors_1 = require("../errors/errors"); +const INVALID_MONGO_ID = "User ID is not a valid MONGO ID"; +const USER_NOT_FOUND = "User not found in mongo database"; +/** + * List of errors that can be thrown by our backend services. + */ +class ServiceError extends errors_1.CustomError { +} +exports.ServiceError = ServiceError; +ServiceError.INVALID_MONGO_ID = new ServiceError(0, 401, INVALID_MONGO_ID); +ServiceError.USER_NOT_FOUND = new ServiceError(1, 401, USER_NOT_FOUND); diff --git a/backend/dist/src/middleware/auth.js b/backend/dist/src/middleware/auth.js new file mode 100644 index 0000000..478b5f0 --- /dev/null +++ b/backend/dist/src/middleware/auth.js @@ -0,0 +1,99 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.requireAdmin = exports.requireStaffOrAdmin = exports.requireSignedIn = void 0; +const auth_1 = require("../services/auth"); +const auth_2 = require("../errors/auth"); +const user_1 = __importStar(require("../models/user")); +/** + * A middleware that requires the user to be signed in and have a valid Firebase token + * in the "Authorization" header + */ +const requireSignedIn = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const authHeader = req.headers.authorization; + // Token shoud be "Bearer: " + const token = authHeader === null || authHeader === void 0 ? void 0 : authHeader.split("Bearer ")[1]; + if (!token) { + return res + .status(auth_2.AuthError.TOKEN_NOT_IN_HEADER.status) + .send(auth_2.AuthError.TOKEN_NOT_IN_HEADER.displayMessage(true)); + } + let userInfo; + try { + userInfo = yield (0, auth_1.decodeAuthToken)(token); + } + catch (e) { + return res + .status(auth_2.AuthError.INVALID_AUTH_TOKEN.status) + .send(auth_2.AuthError.INVALID_AUTH_TOKEN.displayMessage(true)); + } + if (userInfo) { + req.userUid = userInfo.uid; + const user = yield user_1.default.findOne({ uid: userInfo.uid }); + if (!user) { + return res + .status(auth_2.AuthError.USER_NOT_FOUND.status) + .send(auth_2.AuthError.USER_NOT_FOUND.displayMessage(true)); + } + return next(); + } + return res.status(auth_2.AuthError.INVALID_AUTH_TOKEN.status).send(auth_2.AuthError.INVALID_AUTH_TOKEN.message); +}); +exports.requireSignedIn = requireSignedIn; +/** + * A middleware that requires the user to have either the staff or admin role + */ +const requireStaffOrAdmin = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const { userUid } = req; + const user = yield user_1.default.findOne({ uid: userUid }); + if (!user || ![user_1.UserRole.STAFF, user_1.UserRole.ADMIN].includes(user.role)) { + return res + .status(auth_2.AuthError.NOT_STAFF_OR_ADMIN.status) + .send(auth_2.AuthError.NOT_STAFF_OR_ADMIN.displayMessage(true)); + } + return next(); +}); +exports.requireStaffOrAdmin = requireStaffOrAdmin; +/** + * A middleware that requires the user to have the admin role + */ +const requireAdmin = (req, res, next) => __awaiter(void 0, void 0, void 0, function* () { + const { userUid } = req; + const user = yield user_1.default.findOne({ uid: userUid }); + if (!user || user.role !== user_1.UserRole.ADMIN) { + return res.status(auth_2.AuthError.NOT_ADMIN.status).send(auth_2.AuthError.NOT_ADMIN.displayMessage(true)); + } + return next(); +}); +exports.requireAdmin = requireAdmin; diff --git a/backend/dist/src/models/furnitureItem.js b/backend/dist/src/models/furnitureItem.js new file mode 100644 index 0000000..f4847a5 --- /dev/null +++ b/backend/dist/src/models/furnitureItem.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const mongoose_1 = require("mongoose"); +/** + * A model for a furniture item that veterans can request on the VSR form. + */ +const furnitureItemSchema = new mongoose_1.Schema({ + // Category for the item (e.g. bedroom, bathroom) + category: { type: String, required: true }, + // Name of the item (e.g. Rug, Bed) + name: { type: String, required: true }, + // Whether to allow veterans to request multiple of the item (if true) or just 1 (if false) + allowMultiple: { type: Boolean, required: true }, + // Index of the item within its category, used to display items in a deterministic order on the VSR form + categoryIndex: { type: Number, required: true }, +}); +exports.default = (0, mongoose_1.model)("FurnitureItem", furnitureItemSchema); diff --git a/backend/dist/src/models/user.js b/backend/dist/src/models/user.js new file mode 100644 index 0000000..bf0a604 --- /dev/null +++ b/backend/dist/src/models/user.js @@ -0,0 +1,31 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UserRole = void 0; +const mongoose_1 = require("mongoose"); +/** + * A model for a user of our application. + */ +const userSchema = new mongoose_1.Schema({ + // The user's role (either staff or admin) + role: { + type: String, + required: true, + }, + // The user's Firebase UID (used to relate the MongoDB user to the Firebas user) + uid: { + type: String, + required: true, + }, +}); +var UserRole; +(function (UserRole) { + /** + * For some reason we are getting ESLint errors about these vars not being used, + * even though they are used in other files. Disable these ESLint errors. + */ + // eslint-disable-next-line no-unused-vars + UserRole["STAFF"] = "staff"; + // eslint-disable-next-line no-unused-vars + UserRole["ADMIN"] = "admin"; +})(UserRole || (exports.UserRole = UserRole = {})); +exports.default = (0, mongoose_1.model)("User", userSchema); diff --git a/backend/dist/src/models/vsr.js b/backend/dist/src/models/vsr.js new file mode 100644 index 0000000..5177886 --- /dev/null +++ b/backend/dist/src/models/vsr.js @@ -0,0 +1,53 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const mongoose_1 = require("mongoose"); +/** + * A schema for a single furniture item that a veteran can request + */ +const furntitureInputSchema = new mongoose_1.Schema({ + // ID of the furniture item being required (Object ID for an instance of the furniture item model) + furnitureItemId: { type: String, required: true }, + // Quantity being requested by this veteran + quantity: { type: Number, required: true }, +}); +/** + * A model for a VSR (veteran service request), submitted by a veteran to request + * furniture items from PAP. + */ +const vsrSchema = new mongoose_1.Schema({ + /** Page 1 of VSR */ + name: { type: String, required: true }, + gender: { type: String, required: true }, + age: { type: Number, required: true }, + maritalStatus: { type: String, required: true }, + spouseName: { type: String }, + agesOfBoys: { type: [Number] }, + agesOfGirls: { type: [Number] }, + ethnicity: { type: [String], required: true }, + employmentStatus: { type: String, required: true }, + incomeLevel: { type: String, required: true }, + sizeOfHome: { type: String, required: true }, + /** Page 2 of VSR */ + streetAddress: { type: String, required: true }, + city: { type: String, required: true }, + state: { type: String, required: true }, + zipCode: { type: Number, required: true }, + phoneNumber: { type: String, required: true }, + email: { type: String, required: true }, + branch: { type: [String], required: true }, + conflicts: { type: [String], required: true }, + dischargeStatus: { type: String, required: true }, + serviceConnected: { type: Boolean, required: true }, + lastRank: { type: String, required: true }, + militaryID: { type: Number, required: true }, + petCompanion: { type: Boolean, required: true }, + hearFrom: { type: String, required: true }, + /** Page 3 of VSR */ + selectedFurnitureItems: { type: [furntitureInputSchema], required: true }, + additionalItems: { type: String, required: false }, + /** Fields that are created/updated automatically or on staff side */ + dateReceived: { type: Date, required: true }, + lastUpdated: { type: Date, required: true }, + status: { type: String, required: true }, +}); +exports.default = (0, mongoose_1.model)("VSR", vsrSchema); diff --git a/backend/dist/src/routes/furnitureItem.js b/backend/dist/src/routes/furnitureItem.js new file mode 100644 index 0000000..26300a8 --- /dev/null +++ b/backend/dist/src/routes/furnitureItem.js @@ -0,0 +1,33 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const FurnitureItemController = __importStar(require("../controllers/furnitureItem")); +const router = express_1.default.Router(); +router.get("/", FurnitureItemController.getFurnitureItems); +exports.default = router; diff --git a/backend/dist/src/routes/user.js b/backend/dist/src/routes/user.js new file mode 100644 index 0000000..899285e --- /dev/null +++ b/backend/dist/src/routes/user.js @@ -0,0 +1,34 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const auth_1 = require("../middleware/auth"); +const UserController = __importStar(require("../controllers/user")); +const router = express_1.default.Router(); +router.get("/whoami", auth_1.requireSignedIn, UserController.getWhoAmI); +exports.default = router; diff --git a/backend/dist/src/routes/vsr.js b/backend/dist/src/routes/vsr.js new file mode 100644 index 0000000..5acf3f0 --- /dev/null +++ b/backend/dist/src/routes/vsr.js @@ -0,0 +1,40 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const express_1 = __importDefault(require("express")); +const VSRController = __importStar(require("../controllers/vsr")); +const auth_1 = require("../middleware/auth"); +const VSRValidator = __importStar(require("../validators/vsr")); +const router = express_1.default.Router(); +router.get("/", auth_1.requireSignedIn, auth_1.requireStaffOrAdmin, VSRController.getAllVSRS); +router.post("/", VSRValidator.createVSR, VSRController.createVSR); +router.get("/:id", auth_1.requireSignedIn, auth_1.requireStaffOrAdmin, VSRController.getVSR); +router.delete("/:id", auth_1.requireSignedIn, auth_1.requireAdmin, VSRController.deleteVSR); +router.patch("/:id/status", auth_1.requireSignedIn, auth_1.requireStaffOrAdmin, VSRValidator.updateStatus, VSRController.updateStatus); +router.put("/:id", auth_1.requireSignedIn, auth_1.requireStaffOrAdmin, VSRValidator.updateVSR, VSRController.updateVSR); +exports.default = router; diff --git a/backend/dist/src/server.js b/backend/dist/src/server.js new file mode 100644 index 0000000..23c71a1 --- /dev/null +++ b/backend/dist/src/server.js @@ -0,0 +1,24 @@ +"use strict"; +/** + * Initializes mongoose and express. + */ +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +require("module-alias/register"); +const mongoose_1 = __importDefault(require("mongoose")); +const app_1 = __importDefault(require("./app")); +const validateEnv_1 = __importDefault(require("./util/validateEnv")); +const PORT = validateEnv_1.default.PORT; +const MONGODB_URI = validateEnv_1.default.MONGODB_URI; +mongoose_1.default + .connect(MONGODB_URI) + .then(() => { + console.log("Mongoose connected!"); + app_1.default.listen(PORT, () => { + console.log(`Server running on port ${PORT}.`); + }); +}) + .catch(console.error); +module.exports = app_1.default; diff --git a/backend/dist/src/services/auth.js b/backend/dist/src/services/auth.js new file mode 100644 index 0000000..3ecde95 --- /dev/null +++ b/backend/dist/src/services/auth.js @@ -0,0 +1,30 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.decodeAuthToken = void 0; +const firebase_1 = require("../services/firebase"); +const auth_1 = require("../errors/auth"); +/** + * Decodes a Firebase token and returns the user info for the user who the token is for, + * or throws an error if the token is invalid. + */ +function decodeAuthToken(token) { + return __awaiter(this, void 0, void 0, function* () { + try { + const userInfo = yield firebase_1.firebaseAuth.verifyIdToken(token); + return userInfo; + } + catch (e) { + throw auth_1.AuthError.DECODE_ERROR; + } + }); +} +exports.decodeAuthToken = decodeAuthToken; diff --git a/backend/dist/src/services/emails.js b/backend/dist/src/services/emails.js new file mode 100644 index 0000000..0893623 --- /dev/null +++ b/backend/dist/src/services/emails.js @@ -0,0 +1,144 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.sendVSRConfirmationEmailToVeteran = exports.sendVSRNotificationEmailToStaff = void 0; +require("dotenv/config"); +const nodemailer_1 = __importDefault(require("nodemailer")); +const validateEnv_1 = __importDefault(require("../util/validateEnv")); +const trimmedFrontendUrl = validateEnv_1.default.FRONTEND_ORIGIN.replace( +// Trim trailing slash from frontend URL, if there is one +/\/$/gi, ""); +/** + * Sends a notification email to PAP staff when a VSR is submitted. + * Throws an error if the email could not be sent. + * + * @param name Name of veteran who submitted the VSR + * @param email Email address of veteran who submitted the VSR + * @param id ID of the submitted VSR, used to construct a link to the VSR on our web app + */ +const sendVSRNotificationEmailToStaff = (name, email, id) => __awaiter(void 0, void 0, void 0, function* () { + const EMAIL_SUBJECT = "New VSR Submitted"; + const EMAIL_BODY = `A new VSR was submitted by ${name} from ${email}. You can view it at ${trimmedFrontendUrl}/staff/vsr/${id}`; + const transporter = nodemailer_1.default.createTransport({ + service: "gmail", + auth: { + user: validateEnv_1.default.EMAIL_USER, + pass: validateEnv_1.default.EMAIL_APP_PASSWORD, + }, + }); + const mailOptions = { + from: validateEnv_1.default.EMAIL_USER, + to: validateEnv_1.default.EMAIL_NOTIFICATIONS_RECIPIENT, + subject: EMAIL_SUBJECT, + text: EMAIL_BODY, + }; + yield transporter.sendMail(mailOptions); +}); +exports.sendVSRNotificationEmailToStaff = sendVSRNotificationEmailToStaff; +/** + * Sends a confirmation email to the veteran who submitted a VSR. + * Throws an error if the email could not be sent. + * + * @param name Name of veteran who submitted the VSR + * @param email Email address of veteran who submitted the VSR + */ +const sendVSRConfirmationEmailToVeteran = (name, email) => __awaiter(void 0, void 0, void 0, function* () { + const EMAIL_SUBJECT = "VSR Submission Confirmation"; + const EMAIL_HTML = `

Dear ${name},

\ +

Thank you for submitting a veteran service request (VSR).

\ +

\ + We schedule appointments on Tuesdays, Thursday, and Saturdays from 10-3pm. You need to be\ + specific about what day and time you want to make your appointment.\ + \ +

\ + \ +

\ + PLEASE NOTE: We will not be making appointments on THURSDAY, NOVEMBER 23RD (THANKSGIVING) OR\ + SATURDAY, DECEMBER 2nd.\ +

\ + \ +

\ + Respond to this email and let us know what day and time will work and we will put you\ + down.\ +

\ + \ +

\ + You pick up your items on the date and time of your appointment. You need to come prepared on that\ + day.\ +

\ +

We will help you with as much as we can on your list.

\ +

\ + Please remember that items are donated from the community as a way of saying THANK YOU FOR YOUR\ + SERVICE, so we are like a cashless thrift store environment.\ +

\ +

You will need to provide your own transportation for all your items you are requesting.

\ +

\ + \ + Wherever you are going to rent a truck, don't wait to the last minute to rent a truck as there\ + might not be one available.\ +

\ +

We are located in the Anaheim, in Orange County.

\ +

Once we confirm your appointment we will send you the warehouse protocol and address.

\ +

\ + \ + Items in the warehouse have been donated from the General Public and are strictly for Veterans,\ + Active Military and Reservists and their immediate family (wives and school aged children) who\ + live with them. The Veteran/Active Duty/Reservist has to be at appointment and will need to show\ + proof of service, such as a VA Card, DD214 or Active Military card.\ +

\ +

They are not for family members and friends who have not served our Country.

\ +

Thank you for contacting us.

\ +

Volunteer

\ + \ +

veteran@patriotsandpaws.org

\ + Patriots & Paws Logo\ +

\ + Facebook\ + https://www.facebook.com/pages/Patriots-and-Paws/283613748323930\ +

\ +

Twitter @patriotsandpaws

\ +

Instagram patriotsandpaws

\ + `; + const transporter = nodemailer_1.default.createTransport({ + service: "gmail", + auth: { + user: validateEnv_1.default.EMAIL_USER, + pass: validateEnv_1.default.EMAIL_APP_PASSWORD, + }, + }); + const mailOptions = { + from: validateEnv_1.default.EMAIL_USER, + to: email, + subject: EMAIL_SUBJECT, + html: EMAIL_HTML, + attachments: [ + { + filename: "pap_logo.png", + path: `${__dirname}/../../public/pap_logo.png`, + cid: "pap_logo.png", + }, + ], + }; + yield transporter.sendMail(mailOptions); +}); +exports.sendVSRConfirmationEmailToVeteran = sendVSRConfirmationEmailToVeteran; diff --git a/backend/dist/src/services/firebase.js b/backend/dist/src/services/firebase.js new file mode 100644 index 0000000..a56355a --- /dev/null +++ b/backend/dist/src/services/firebase.js @@ -0,0 +1,54 @@ +"use strict"; +/** + * This file contains the configuration for firebase + * It exports a firebase auth object which will allow users + * to access any firebase services. For this project we will use + * firebase to for authentication. + */ +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.firebaseAuth = void 0; +const firebase = __importStar(require("firebase-admin/app")); +const auth_1 = require("firebase-admin/auth"); +const internal_1 = require("../errors/internal"); +const validateEnv_1 = __importDefault(require("../util/validateEnv")); +/** + * Initializes Firebase Auth using the SERVICE_ACCOUNT_KEY environment variable + */ +let serviceAccountKey; +if (!validateEnv_1.default.SERVICE_ACCOUNT_KEY) { + throw internal_1.InternalError.NO_SERVICE_ACCOUNT_KEY; +} +else { + serviceAccountKey = validateEnv_1.default.SERVICE_ACCOUNT_KEY; +} +firebase.initializeApp({ + credential: firebase.cert(serviceAccountKey), +}); +const firebaseAuth = (0, auth_1.getAuth)(); +exports.firebaseAuth = firebaseAuth; diff --git a/backend/dist/src/tests/testUtils.js b/backend/dist/src/tests/testUtils.js new file mode 100644 index 0000000..85c8ba7 --- /dev/null +++ b/backend/dist/src/tests/testUtils.js @@ -0,0 +1,105 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getTestUserToken = exports.getTestUserInfo = exports.mongoMemoryHooks = void 0; +const app_1 = require("firebase/app"); +const auth_1 = require("firebase/auth"); +const mongodb_1 = require("mongodb"); +const mongodb_memory_server_1 = require("mongodb-memory-server"); +const mongoose_1 = __importDefault(require("mongoose")); +const firebase_1 = require("../services/firebase"); +const validateEnv_1 = __importDefault(require("../util/validateEnv")); +const TEST_DB_NAME = "test"; +/** + * A utility function to run some before & after testing hooks to set up and clean up + * a Mongo memory server, used for testing. + */ +const mongoMemoryHooks = () => { + let client, memoryServer; + /** + * This hook runs once, before all our tests are run. In this function, we + * set up a Mongo memory server, which emulates MongoDB by storing data in memory, to + * make it faster to run tests using the database. This allows our backend code to interface + * with MongoDB like normal, but all its actions will go to our test database. + */ + beforeAll(() => __awaiter(void 0, void 0, void 0, function* () { + // Create Mongo memory server + memoryServer = yield mongodb_memory_server_1.MongoMemoryServer.create({ + instance: { + dbName: TEST_DB_NAME, + }, + }); + // Get URI of memory server + const testMongoUri = memoryServer.getUri(); + /** + * Connect to memory server, so that we can drop data between tests + * in the "afterEach" hook + */ + client = yield mongodb_1.MongoClient.connect(testMongoUri); + /** + * Connect Mongoose to memory server, so we can use Mongoose like normal + * in our backend code that we are testing + */ + yield mongoose_1.default.connect(testMongoUri); + })); + /** + * This hook runs after each test in our suite. We use this function to drop all data + * from our testing MongoDB database, so that the database will be fresh and empty for each test. + * + * We may also want a "beforeEach" hook in the future, to insert some example data into + * the testing MongoDB database before each of our tests, so that we have some data to run tests on. + */ + afterEach(() => __awaiter(void 0, void 0, void 0, function* () { + // Drop all data from test database + yield client.db(TEST_DB_NAME).dropDatabase(); + })); + /** + * This hook runs once, after all tests in the suite are done. We use this function + * to close connections to the MongoDB memory database and clean up any other resources. + */ + afterAll(() => __awaiter(void 0, void 0, void 0, function* () { + yield client.close(true); + yield memoryServer.stop(); + yield mongoose_1.default.disconnect(); + })); +}; +exports.mongoMemoryHooks = mongoMemoryHooks; +let _testUserInfo = null; +let _testUserToken = null; +/** + * Gets the userInfo (Firebase object) for the testing user + */ +const getTestUserInfo = () => __awaiter(void 0, void 0, void 0, function* () { + if (_testUserInfo === null) { + _testUserInfo = yield firebase_1.firebaseAuth.getUserByEmail(validateEnv_1.default.EMAIL_USER); + } + return _testUserInfo; +}); +exports.getTestUserInfo = getTestUserInfo; +/** + * Gets a user token for the testing user, which can be used as an Authorization ehader + * on HTTP requests to our backend + */ +const getTestUserToken = () => __awaiter(void 0, void 0, void 0, function* () { + if (_testUserToken === null) { + const testUserInfo = yield (0, exports.getTestUserInfo)(); + const customToken = yield firebase_1.firebaseAuth.createCustomToken(testUserInfo.uid); + const firebaseApp = (0, app_1.initializeApp)(validateEnv_1.default.BACKEND_FIREBASE_SETTINGS); + const auth = (0, auth_1.getAuth)(firebaseApp); + const { user } = yield (0, auth_1.signInWithCustomToken)(auth, customToken); + _testUserToken = yield user.getIdToken(); + } + return _testUserToken; +}); +exports.getTestUserToken = getTestUserToken; diff --git a/backend/dist/src/util/validateEnv.js b/backend/dist/src/util/validateEnv.js new file mode 100644 index 0000000..5d15aee --- /dev/null +++ b/backend/dist/src/util/validateEnv.js @@ -0,0 +1,18 @@ +"use strict"; +/** + * Parses .env parameters and ensures they are of required types and are not missing. + * If any .env parameters are missing, the server will not start and an error will be thrown. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const envalid_1 = require("envalid"); +const validators_1 = require("envalid/dist/validators"); +exports.default = (0, envalid_1.cleanEnv)(process.env, { + PORT: (0, validators_1.port)(), + MONGODB_URI: (0, validators_1.str)(), + FRONTEND_ORIGIN: (0, validators_1.str)(), + EMAIL_USER: (0, validators_1.email)(), + EMAIL_APP_PASSWORD: (0, validators_1.str)(), + EMAIL_NOTIFICATIONS_RECIPIENT: (0, validators_1.email)(), + BACKEND_FIREBASE_SETTINGS: (0, validators_1.json)(), + SERVICE_ACCOUNT_KEY: (0, validators_1.json)(), // Private service account key for backend, stored as a JSON string +}); diff --git a/backend/dist/src/util/validationErrorParser.js b/backend/dist/src/util/validationErrorParser.js new file mode 100644 index 0000000..08e77dd --- /dev/null +++ b/backend/dist/src/util/validationErrorParser.js @@ -0,0 +1,25 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const http_errors_1 = __importDefault(require("http-errors")); +/** + * Parses through errors thrown by validator (if any exist). Error messages are + * added to a string and that string is used as the error message for the HTTP + * error. + * + * @param errors the validation result provided by express validator middleware + */ +const validationErrorParser = (errors) => { + if (!errors.isEmpty()) { + let errorString = ""; + // parse through errors returned by the validator and append them to the error string + for (const error of errors.array()) { + errorString += error.msg + " "; + } + // trim removes the trailing space created in the for loop + throw (0, http_errors_1.default)(400, errorString.trim()); + } +}; +exports.default = validationErrorParser; diff --git a/backend/dist/src/validators/vsr.js b/backend/dist/src/validators/vsr.js new file mode 100644 index 0000000..a7b4be9 --- /dev/null +++ b/backend/dist/src/validators/vsr.js @@ -0,0 +1,196 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.updateVSR = exports.updateStatus = exports.createVSR = void 0; +const express_validator_1 = require("express-validator"); +/** + * Validators for creating and updating VSRs + */ +const makeNameValidator = () => (0, express_validator_1.body)("name") + .exists({ checkFalsy: true }) + .withMessage("Name is required") + .isString() + .withMessage("Name must be a string"); +const makeGenderValidator = () => (0, express_validator_1.body)("gender") + .exists({ checkFalsy: true }) + .withMessage("Gender is required") + .isString() + .withMessage("Gender must be a string"); +const makeAgeValidator = () => (0, express_validator_1.body)("age") + .exists({ checkFalsy: true }) + .withMessage("Age is required") + .isInt({ min: 0 }) + .withMessage("Age must be a positive integer"); +const makeMaritalStatusValidator = () => (0, express_validator_1.body)("maritalStatus") + .exists({ checkFalsy: true }) + .withMessage("Marital Status is required") + .isString() + .withMessage("Marital Status must be a string"); +const makeSpouseNameValidator = () => (0, express_validator_1.body)("spouseName") + .optional({ checkFalsy: true }) + .isString() + .withMessage("Spouse Name must be a string"); +const makeAgesOfBoysValidator = () => (0, express_validator_1.body)("agesOfBoys") + .exists({ checkFalsy: true }) + .isArray() + .withMessage("Ages of Boys must be an array of numbers") + .custom((ages) => ages.every((age) => Number.isInteger(age) && age >= 0)) + .withMessage("Each age in Ages of Boys must be a positive integer"); +const makeAgesOfGirlsValidator = () => (0, express_validator_1.body)("agesOfGirls") + .exists({ checkFalsy: true }) + .isArray() + .withMessage("Ages of Girls must be an array of numbers") + .custom((ages) => ages.every((age) => Number.isInteger(age) && age >= 0)) + .withMessage("Each age in Ages of Girls must be a positive integer"); +const makeEthnicityValidator = () => (0, express_validator_1.body)("ethnicity") + .exists({ checkFalsy: true }) + .withMessage("Ethnicity is required") + .isArray() + .withMessage("Ethnicity must be an array") + .custom((ethnicities) => ethnicities.every((ethnicity) => typeof ethnicity === "string")) + .withMessage("Each ethnicity in Ethnicities must be a string"); +const makeEmploymentStatusValidator = () => (0, express_validator_1.body)("employmentStatus") + .exists({ checkFalsy: true }) + .withMessage("Employment Status is required") + .isString() + .withMessage("Employment Status must be a string"); +const makeIncomeLevelValidator = () => (0, express_validator_1.body)("incomeLevel") + .exists({ checkFalsy: true }) + .withMessage("Income Level is required") + .isString() + .withMessage("Income Level must be a string"); +const makeSizeOfHomeValidator = () => (0, express_validator_1.body)("sizeOfHome") + .exists({ checkFalsy: true }) + .withMessage("Size of Home is required") + .isString() + .withMessage("Size of Home must be a string"); +const makeStreetAddressValidator = () => (0, express_validator_1.body)("streetAddress") + .exists({ checkFalsy: true }) + .withMessage("Address is required") + .isString() + .withMessage("Address must be a string"); +const makeCityValidator = () => (0, express_validator_1.body)("city") + .exists({ checkFalsy: true }) + .withMessage("City is required") + .isString() + .withMessage("City must be a string"); +const makeStateValidator = () => (0, express_validator_1.body)("state") + .exists({ checkFalsy: true }) + .withMessage("State is required") + .isString() + .withMessage("State must be a string"); +const makeZipCodeValidator = () => (0, express_validator_1.body)("zipCode") + .exists({ checkFalsy: true }) + .withMessage("Zip Code is required") + .isInt({ min: 10000 }) + .withMessage("Zip Code must be a 5 digit integer"); +const makePhoneNumberValidator = () => (0, express_validator_1.body)("phoneNumber") + .exists({ checkFalsy: true }) + .withMessage("Phone Number is required") + .isString() + .withMessage("Phone number must be a string"); +const makeEmailValidator = () => (0, express_validator_1.body)("email") + .exists({ checkFalsy: true }) + .withMessage("Email is required") + .isString() + .withMessage("Email must be a string"); +const makeBranchValidator = () => (0, express_validator_1.body)("branch") + .exists({ checkFalsy: true }) + .withMessage("Branch is required") + .isArray() + .withMessage("Branch must be an array") + .custom((branches) => branches.every((branch) => typeof branch == "string")) + .withMessage("Each branch must be a string"); +const makeConflictsValidator = () => (0, express_validator_1.body)("conflicts") + .exists({ checkFalsy: true }) + .withMessage("Conflict(s) is required") + .isArray() + .withMessage("Conflict(s) must be an array") + .custom((conflicts) => conflicts.every((conflict) => typeof conflict === "string")) + .withMessage("Each conflict must be a string"); +const makeDischargeStatusValidator = () => (0, express_validator_1.body)("dischargeStatus") + .exists({ checkFalsy: true }) + .withMessage("Discharge Status is required") + .isString() + .withMessage("Discharge Status must be a string"); +const makeServiceConnectedValidator = () => (0, express_validator_1.body)("serviceConnected") + .exists({ checkFalsy: false }) + .withMessage("Service Connected is required") + .isBoolean() + .withMessage("Service Connected must be a boolean"); +const makeLastRankValidator = () => (0, express_validator_1.body)("lastRank") + .exists({ checkFalsy: true }) + .withMessage("Last rank is required") + .isString() + .withMessage("Last rank must be a string"); +const makeMilitaryIDValidator = () => (0, express_validator_1.body)("militaryID") + .exists({ checkFalsy: true }) + .withMessage("Military ID is required") + .isInt() + .withMessage("Military ID must be an integer"); +const makePetCompanionValidator = () => (0, express_validator_1.body)("petCompanion") + .exists({ checkFalsy: false }) + .withMessage("Pet interest is required") + .isBoolean() + .withMessage("Pet interest must be a boolean"); +const makeHearFromValidator = () => (0, express_validator_1.body)("hearFrom") + .exists({ checkFalsy: true }) + .withMessage("Referral source is required") + .isString() + .withMessage("Referral source must be a string"); +const ALLOWED_STATUSES = [ + "Received", + "Appointment Scheduled", + "Approved", + "Complete", + "Resubmit", + "No-show / Incomplete", + "Archived", +]; +const updateStatusValidator = () => (0, express_validator_1.body)("status") + .exists({ checkFalsy: true }) + .withMessage("Status is required") + .isString() + .withMessage("Status must be a string") + .isIn(ALLOWED_STATUSES) + .withMessage("Status must be one of the allowed options"); +exports.createVSR = [ + makeNameValidator(), + makeGenderValidator(), + makeAgeValidator(), + makeMaritalStatusValidator(), + makeSpouseNameValidator(), + makeAgesOfBoysValidator(), + makeAgesOfGirlsValidator(), + makeEthnicityValidator(), + makeEmploymentStatusValidator(), + makeIncomeLevelValidator(), + makeSizeOfHomeValidator(), + makeStreetAddressValidator(), + makeCityValidator(), + makeStateValidator(), + makeZipCodeValidator(), + makePhoneNumberValidator(), + makeEmailValidator(), + makeBranchValidator(), + makeConflictsValidator(), + makeDischargeStatusValidator(), + makeServiceConnectedValidator(), + makeLastRankValidator(), + makeMilitaryIDValidator(), + makePetCompanionValidator(), + makeHearFromValidator(), +]; +exports.updateStatus = [updateStatusValidator()]; +exports.updateVSR = [ + makeNameValidator(), + makeGenderValidator(), + makeAgeValidator(), + makeMaritalStatusValidator(), + makeSpouseNameValidator(), + makeAgesOfBoysValidator(), + makeAgesOfGirlsValidator(), + makeEthnicityValidator(), + makeEmploymentStatusValidator(), + makeIncomeLevelValidator(), + makeSizeOfHomeValidator(), +]; diff --git a/backend/package-lock.json b/backend/package-lock.json index 14dc746..3e3afb8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,7 +22,8 @@ "mongoose": "^7.4.0", "nodemailer": "^6.9.8", "supertest": "^6.3.3", - "ts-jest": "^29.1.1" + "ts-jest": "^29.1.1", + "tsc-alias": "^1.8.8" }, "devDependencies": { "@types/cors": "^2.8.13", @@ -2622,7 +2623,6 @@ }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2634,7 +2634,6 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2642,7 +2641,6 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4193,7 +4191,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, "engines": { "node": ">=8" } @@ -4483,7 +4480,6 @@ }, "node_modules/binary-extensions": { "version": "2.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4690,7 +4686,6 @@ }, "node_modules/chokidar": { "version": "3.5.3", - "dev": true, "funding": [ { "type": "individual", @@ -4716,7 +4711,6 @@ }, "node_modules/chokidar/node_modules/glob-parent": { "version": "5.1.2", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4790,6 +4784,14 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/commondir": { "version": "1.0.1", "license": "MIT" @@ -5029,7 +5031,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, "dependencies": { "path-type": "^4.0.0" }, @@ -5805,7 +5806,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -5821,7 +5821,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -5867,7 +5866,6 @@ }, "node_modules/fastq": { "version": "1.15.0", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -6340,7 +6338,6 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -6608,7 +6605,6 @@ }, "node_modules/ignore": { "version": "5.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -6739,7 +6735,6 @@ }, "node_modules/is-binary-path": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -6800,7 +6795,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6847,7 +6841,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -7989,7 +7982,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "engines": { "node": ">= 8" } @@ -8250,6 +8242,18 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/mylas": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.13.tgz", + "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "license": "MIT" @@ -8689,7 +8693,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -8773,6 +8776,17 @@ "node": ">=8" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -8934,9 +8948,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "engines": { + "node": ">=12" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", - "dev": true, "funding": [ { "type": "github", @@ -8999,7 +9020,6 @@ }, "node_modules/readdirp": { "version": "3.6.0", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -9134,7 +9154,6 @@ }, "node_modules/reusify": { "version": "1.0.4", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -9157,7 +9176,6 @@ }, "node_modules/run-parallel": { "version": "1.2.0", - "dev": true, "funding": [ { "type": "github", @@ -9916,6 +9934,22 @@ } } }, + "node_modules/tsc-alias": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.8.tgz", + "integrity": "sha512-OYUOd2wl0H858NvABWr/BoSKNERw3N9GTi3rHPK8Iv4O1UyUXIrTTOAZNHsjlVpXFOhpJBVARI1s+rzwLivN3Q==", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", diff --git a/backend/package.json b/backend/package.json index 381d4f8..6055535 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,7 @@ "main": "dist/server.js", "scripts": { "start": "nodemon src/server.ts", + "build": "tsc && tsc-alias && cp -r ./public ./dist/public", "test": "jest", "format": "npm run check-git-hooks && prettier --write .", "lint-fix": "npm run check-git-hooks && (eslint --fix --cache --report-unused-disable-directives . || true) && prettier --write .", @@ -26,7 +27,8 @@ "mongoose": "^7.4.0", "nodemailer": "^6.9.8", "supertest": "^6.3.3", - "ts-jest": "^29.1.1" + "ts-jest": "^29.1.1", + "tsc-alias": "^1.8.8" }, "devDependencies": { "@types/cors": "^2.8.13", diff --git a/backend/public/pap_logo.png b/backend/public/pap_logo.png new file mode 100644 index 0000000..4a2c8db Binary files /dev/null and b/backend/public/pap_logo.png differ diff --git a/backend/src/app.ts b/backend/src/app.ts index eaa6610..d18a108 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,9 +6,9 @@ import "dotenv/config"; import cors from "cors"; import express, { NextFunction, Request, Response } from "express"; import { isHttpError } from "http-errors"; -import vsrRoutes from "../src/routes/vsr"; -import furnitureItemRoutes from "../src/routes/furnitureItem"; -import { userRouter } from "src/routes/users"; +import vsrRoutes from "src/routes/vsr"; +import furnitureItemRoutes from "src/routes/furnitureItem"; +import userRoutes from "src/routes/user"; import env from "src/util/validateEnv"; const app = express(); @@ -28,7 +28,9 @@ app.use( ); // Put routes here (e.g. app.use("/api/example", exampleRoutes); ) -app.use(userRouter); +app.use("/api/user", userRoutes); +app.use("/api/vsr", vsrRoutes); +app.use("/api/furnitureItems", furnitureItemRoutes); /** * Error handler; all errors thrown by server are handled here. @@ -54,7 +56,4 @@ app.use((error: unknown, req: Request, res: Response, next: NextFunction) => { res.status(statusCode).json({ error: errorMessage }); }); -app.use("/api/vsr", vsrRoutes); -app.use("/api/furnitureItems", furnitureItemRoutes); - export default app; diff --git a/backend/src/controllers/furnitureItem.ts b/backend/src/controllers/furnitureItem.ts index 4b50372..947e330 100644 --- a/backend/src/controllers/furnitureItem.ts +++ b/backend/src/controllers/furnitureItem.ts @@ -1,14 +1,19 @@ import { RequestHandler } from "express"; -import createHttpError from "http-errors"; import FurnitureItemModel from "src/models/furnitureItem"; +/** + * Gets all available furniture items in the database. Does not require authentication. + */ export const getFurnitureItems: RequestHandler = async (req, res, next) => { try { - const furnitureItems = await FurnitureItemModel.find(); + const furnitureItems = await FurnitureItemModel.find().sort({ + // First, sort the items by whether they allow multiple (true before false) + allowMultiple: -1, + + // Second, sort by category index (ascending) + categoryIndex: 1, + }); - if (furnitureItems === null) { - throw createHttpError(404, "Furniture items not found"); - } res.status(200).json(furnitureItems); } catch (error) { next(error); diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts new file mode 100644 index 0000000..1ff74a2 --- /dev/null +++ b/backend/src/controllers/user.ts @@ -0,0 +1,22 @@ +import { RequestHandler } from "express"; +import { PAPRequest } from "src/middleware/auth"; +import UserModel from "src/models/user"; + +/** + * Retrieves data about the current user (their MongoDB ID, Firebase UID, and role). + * Requires the user to be signed in. + */ +export const getWhoAmI: RequestHandler = async (req: PAPRequest, res, next) => { + try { + const { userUid } = req; + const user = await UserModel.findOne({ uid: userUid }); + const { _id, uid, role } = user!; + res.status(200).send({ + _id, + uid, + role, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/controllers/vsr.ts b/backend/src/controllers/vsr.ts index db2c37d..62f62ca 100644 --- a/backend/src/controllers/vsr.ts +++ b/backend/src/controllers/vsr.ts @@ -2,8 +2,30 @@ import { RequestHandler } from "express"; import { validationResult } from "express-validator"; import createHttpError from "http-errors"; import VSRModel from "src/models/vsr"; +import { + sendVSRConfirmationEmailToVeteran, + sendVSRNotificationEmailToStaff, +} from "src/services/emails"; import validationErrorParser from "src/util/validationErrorParser"; +/** + * Gets all VSRs in the database. Requires the user to be signed in and have + * staff or admin permission. + */ +export const getAllVSRS: RequestHandler = async (req, res, next) => { + try { + const vsrs = await VSRModel.find(); + + res.status(200).json({ vsrs }); + } catch (error) { + next(error); + } +}; + +/** + * Retrieves a single VSR by its ID. Requires the user to get signed in and have + * staff or admin permission. + */ export const getVSR: RequestHandler = async (req, res, next) => { const { id } = req.params; @@ -19,38 +41,13 @@ export const getVSR: RequestHandler = async (req, res, next) => { } }; +/** + * Creates a new VSR in the database, called when a veteran submits the VSR form. + * Does not require authentication. + */ export const createVSR: RequestHandler = async (req, res, next) => { - // extract any errors that were found by the validator + // Extract any errors that were found by the validator const errors = validationResult(req); - const { - name, - gender, - age, - maritalStatus, - spouseName, - agesOfBoys, - agesOfGirls, - ethnicity, - employmentStatus, - incomeLevel, - sizeOfHome, - streetAddress, - city, - state, - zipCode, - phoneNumber, - email, - branch, - conflicts, - dischargeStatus, - serviceConnected, - lastRank, - militaryID, - petCompanion, - hearFrom, - selectedFurnitureItems, - additionalItems, - } = req.body; try { // if there are errors, then this function throws an exception @@ -60,41 +57,19 @@ export const createVSR: RequestHandler = async (req, res, next) => { const currentDate = new Date(); const vsr = await VSRModel.create({ - name, - gender, - age, - maritalStatus, - spouseName, - agesOfBoys, - agesOfGirls, - ethnicity, - employmentStatus, - incomeLevel, - sizeOfHome, - streetAddress, - city, - state, - zipCode, - phoneNumber, - email, - branch, - conflicts, - dischargeStatus, - serviceConnected, - lastRank, - militaryID, - petCompanion, - hearFrom, + ...req.body, // Use current date as timestamp for received & updated dateReceived: currentDate, lastUpdated: currentDate, status: "Received", - - selectedFurnitureItems, - additionalItems, }); + + // Once the VSR is created successfully, send notification & confirmation emails + sendVSRNotificationEmailToStaff(req.body.name, req.body.email, vsr._id.toString()); + sendVSRConfirmationEmailToVeteran(req.body.name, req.body.email); + // 201 means a new resource has been created successfully // the newly created VSR is sent back to the user res.status(201).json(vsr); @@ -103,6 +78,10 @@ export const createVSR: RequestHandler = async (req, res, next) => { } }; +/** + * Updates a VSR's status, by its ID. Requires the user to be signed in and + * have staff or admin permission. + */ export const updateStatus: RequestHandler = async (req, res, next) => { try { // extract any errors that were found by the validator @@ -112,19 +91,67 @@ export const updateStatus: RequestHandler = async (req, res, next) => { // if there are errors, then this function throws an exception validationErrorParser(errors); + // Get the current date as a timestamp for when VSR was updated + const currentDate = new Date(); + const { id } = req.params; - const vsr = await VSRModel.findByIdAndUpdate(id, { status }, { new: true }); + const vsr = await VSRModel.findByIdAndUpdate( + id, + { status, lastUpdated: currentDate }, + { new: true }, + ); + if (vsr === null) { + throw createHttpError(404, "VSR not found at id " + id); + } res.status(200).json(vsr); } catch (error) { next(error); } }; -export const getAllVSRS: RequestHandler = async (req, res, next) => { +/** + * Updates a VSR's data, by its ID. Requires the user to be signed in and + * have staff or admin permission. + */ +export const updateVSR: RequestHandler = async (req, res, next) => { + const errors = validationResult(req); + try { - const vsrs = await VSRModel.find(); + const { id } = req.params; - res.status(200).json({ vsrs }); + // Get the current date as a timestamp for when VSR was updated + const currentDate = new Date(); + + validationErrorParser(errors); + const updatedVSR = await VSRModel.findByIdAndUpdate( + id, + { + ...req.body, + lastUpdated: currentDate, + }, + { new: true }, + ); + if (updatedVSR === null) { + throw createHttpError(404, "VSR not found at id " + id); + } + res.status(200).json(updatedVSR); + } catch (error) { + next(error); + } +}; + +/** + * Deletes a VSR from the database, by its ID. Requires the user to be signed in + * and have admin permission. + */ +export const deleteVSR: RequestHandler = async (req, res, next) => { + try { + const { id } = req.params; + const deletedVsr = await VSRModel.findByIdAndDelete(id); + if (deletedVsr === null) { + throw createHttpError(404, "VSR not found at id " + id); + } + return res.status(204).send(); } catch (error) { next(error); } diff --git a/backend/src/errors/auth.ts b/backend/src/errors/auth.ts index 6e84e71..aac8794 100644 --- a/backend/src/errors/auth.ts +++ b/backend/src/errors/auth.ts @@ -1,12 +1,20 @@ import { CustomError } from "src/errors/errors"; const DECODE_ERROR = "Error in decoding the auth token. Make sure the auth token is valid"; -const TOKEN_NOT_IN_HEADER = - "Token was not found in the header. Be sure to use Bearer syntax"; -const INVALID_AUTH_TOKEN = "Token was invalid. Be sure to refresh token if needed."; +const TOKEN_NOT_IN_HEADER = "Token was not found in header. Be sure to use Bearer syntax"; +const INVALID_AUTH_TOKEN = "Token was invalid."; +const USER_NOT_FOUND = "User not found"; +const NOT_STAFF_OR_ADMIN = "User must be a staff/admin."; +const NOT_ADMIN = "User must be an admin."; +/** + * List of authentication-related errors that may be thrown by our backend. + */ export class AuthError extends CustomError { static DECODE_ERROR = new AuthError(0, 401, DECODE_ERROR); static TOKEN_NOT_IN_HEADER = new AuthError(1, 401, TOKEN_NOT_IN_HEADER); static INVALID_AUTH_TOKEN = new AuthError(2, 401, INVALID_AUTH_TOKEN); + static USER_NOT_FOUND = new AuthError(3, 401, USER_NOT_FOUND); + static NOT_STAFF_OR_ADMIN = new AuthError(4, 403, NOT_STAFF_OR_ADMIN); + static NOT_ADMIN = new AuthError(5, 403, NOT_ADMIN); } diff --git a/backend/src/errors/errors.ts b/backend/src/errors/errors.ts index 80fb4a6..fc5eb49 100644 --- a/backend/src/errors/errors.ts +++ b/backend/src/errors/errors.ts @@ -1,3 +1,6 @@ +/** + * Class for a custom error object sent from our backend. + */ export class CustomError extends Error { public code: number; public status: number; diff --git a/backend/src/errors/internal.ts b/backend/src/errors/internal.ts index af847a9..50ef94f 100644 --- a/backend/src/errors/internal.ts +++ b/backend/src/errors/internal.ts @@ -2,6 +2,9 @@ import { CustomError } from "src/errors/errors"; const NO_SERVICE_ACCOUNT_KEY = "Could not find service account key env variable"; +/** + * List of internal errors that can be thrown by our backend if something goes wrong. + */ export class InternalError extends CustomError { static NO_SERVICE_ACCOUNT_KEY = new InternalError(0, 500, NO_SERVICE_ACCOUNT_KEY); } diff --git a/backend/src/errors/service.ts b/backend/src/errors/service.ts index f78049b..8a7a1dc 100644 --- a/backend/src/errors/service.ts +++ b/backend/src/errors/service.ts @@ -4,6 +4,9 @@ const INVALID_MONGO_ID = "User ID is not a valid MONGO ID"; const USER_NOT_FOUND = "User not found in mongo database"; +/** + * List of errors that can be thrown by our backend services. + */ export class ServiceError extends CustomError { static INVALID_MONGO_ID = new ServiceError(0, 401, INVALID_MONGO_ID); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 778e869..4836fd6 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,11 +1,24 @@ import { Request, Response, NextFunction } from "express"; import { decodeAuthToken } from "src/services/auth"; import { AuthError } from "src/errors/auth"; +import UserModel, { UserRole } from "src/models/user"; -const verifyAuthToken = async (req: Request, res: Response, next: NextFunction) => { +/** + * Define this custom type for a request to include the "userUid" + * property, which middleware will set and validate + */ +export interface PAPRequest extends Request { + userUid?: string; +} + +/** + * A middleware that requires the user to be signed in and have a valid Firebase token + * in the "Authorization" header + */ +const requireSignedIn = async (req: PAPRequest, res: Response, next: NextFunction) => { const authHeader = req.headers.authorization; - const token = - authHeader && authHeader.split(" ")[0] === "Bearer" ? authHeader.split(" ")[1] : null; + // Token shoud be "Bearer: " + const token = authHeader?.split("Bearer ")[1]; if (!token) { return res .status(AuthError.TOKEN_NOT_IN_HEADER.status) @@ -22,11 +35,43 @@ const verifyAuthToken = async (req: Request, res: Response, next: NextFunction) } if (userInfo) { - req.body.uid = userInfo.uid; + req.userUid = userInfo.uid; + const user = await UserModel.findOne({ uid: userInfo.uid }); + if (!user) { + return res + .status(AuthError.USER_NOT_FOUND.status) + .send(AuthError.USER_NOT_FOUND.displayMessage(true)); + } return next(); } return res.status(AuthError.INVALID_AUTH_TOKEN.status).send(AuthError.INVALID_AUTH_TOKEN.message); }; -export { verifyAuthToken }; +/** + * A middleware that requires the user to have either the staff or admin role + */ +const requireStaffOrAdmin = async (req: PAPRequest, res: Response, next: NextFunction) => { + const { userUid } = req; + const user = await UserModel.findOne({ uid: userUid }); + if (!user || ![UserRole.STAFF, UserRole.ADMIN].includes(user.role as UserRole)) { + return res + .status(AuthError.NOT_STAFF_OR_ADMIN.status) + .send(AuthError.NOT_STAFF_OR_ADMIN.displayMessage(true)); + } + return next(); +}; + +/** + * A middleware that requires the user to have the admin role + */ +const requireAdmin = async (req: PAPRequest, res: Response, next: NextFunction) => { + const { userUid } = req; + const user = await UserModel.findOne({ uid: userUid }); + if (!user || user.role !== UserRole.ADMIN) { + return res.status(AuthError.NOT_ADMIN.status).send(AuthError.NOT_ADMIN.displayMessage(true)); + } + return next(); +}; + +export { requireSignedIn, requireStaffOrAdmin, requireAdmin }; diff --git a/backend/src/models/furnitureItem.ts b/backend/src/models/furnitureItem.ts index 8258949..f5998bc 100644 --- a/backend/src/models/furnitureItem.ts +++ b/backend/src/models/furnitureItem.ts @@ -1,10 +1,19 @@ import { InferSchemaType, Schema, model } from "mongoose"; +/** + * A model for a furniture item that veterans can request on the VSR form. + */ const furnitureItemSchema = new Schema({ - _id: { type: String, required: true }, + // Category for the item (e.g. bedroom, bathroom) category: { type: String, required: true }, + + // Name of the item (e.g. Rug, Bed) name: { type: String, required: true }, + + // Whether to allow veterans to request multiple of the item (if true) or just 1 (if false) allowMultiple: { type: Boolean, required: true }, + + // Index of the item within its category, used to display items in a deterministic order on the VSR form categoryIndex: { type: Number, required: true }, }); diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts new file mode 100644 index 0000000..566df40 --- /dev/null +++ b/backend/src/models/user.ts @@ -0,0 +1,33 @@ +import { InferSchemaType, Schema, model } from "mongoose"; + +/** + * A model for a user of our application. + */ +const userSchema = new Schema({ + // The user's role (either staff or admin) + role: { + type: String, + required: true, + }, + + // The user's Firebase UID (used to relate the MongoDB user to the Firebas user) + uid: { + type: String, + required: true, + }, +}); + +export enum UserRole { + /** + * For some reason we are getting ESLint errors about these vars not being used, + * even though they are used in other files. Disable these ESLint errors. + */ + // eslint-disable-next-line no-unused-vars + STAFF = "staff", + // eslint-disable-next-line no-unused-vars + ADMIN = "admin", +} + +type User = InferSchemaType; + +export default model("User", userSchema); diff --git a/backend/src/models/users.ts b/backend/src/models/users.ts deleted file mode 100644 index 35fec50..0000000 --- a/backend/src/models/users.ts +++ /dev/null @@ -1,31 +0,0 @@ -import mongoose from "mongoose"; - -interface UserInterface { - role: string; - uid: string; -} - -interface UserDoc extends mongoose.Document { - role: string; - uid: string; -} - -interface UserModelInterface extends mongoose.Model { - // eslint-disable-next-line no-unused-vars - build(attr: UserInterface): UserDoc; -} - -const userSchema = new mongoose.Schema({ - role: { - type: String, - required: true, - }, - uid: { - type: String, - required: true, - }, -}); - -const User = mongoose.model("User", userSchema); - -export { User, userSchema }; diff --git a/backend/src/models/vsr.ts b/backend/src/models/vsr.ts index b6a949d..556cddf 100644 --- a/backend/src/models/vsr.ts +++ b/backend/src/models/vsr.ts @@ -1,11 +1,22 @@ import { InferSchemaType, Schema, model } from "mongoose"; +/** + * A schema for a single furniture item that a veteran can request + */ const furntitureInputSchema = new Schema({ + // ID of the furniture item being required (Object ID for an instance of the furniture item model) furnitureItemId: { type: String, required: true }, + + // Quantity being requested by this veteran quantity: { type: Number, required: true }, }); +/** + * A model for a VSR (veteran service request), submitted by a veteran to request + * furniture items from PAP. + */ const vsrSchema = new Schema({ + /** Page 1 of VSR */ name: { type: String, required: true }, gender: { type: String, required: true }, age: { type: Number, required: true }, @@ -17,6 +28,8 @@ const vsrSchema = new Schema({ employmentStatus: { type: String, required: true }, incomeLevel: { type: String, required: true }, sizeOfHome: { type: String, required: true }, + + /** Page 2 of VSR */ streetAddress: { type: String, required: true }, city: { type: String, required: true }, state: { type: String, required: true }, @@ -31,8 +44,12 @@ const vsrSchema = new Schema({ militaryID: { type: Number, required: true }, petCompanion: { type: Boolean, required: true }, hearFrom: { type: String, required: true }, + + /** Page 3 of VSR */ selectedFurnitureItems: { type: [furntitureInputSchema], required: true }, additionalItems: { type: String, required: false }, + + /** Fields that are created/updated automatically or on staff side */ dateReceived: { type: Date, required: true }, lastUpdated: { type: Date, required: true }, status: { type: String, required: true }, diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts new file mode 100644 index 0000000..efd4171 --- /dev/null +++ b/backend/src/routes/user.ts @@ -0,0 +1,10 @@ +import express from "express"; + +import { requireSignedIn } from "src/middleware/auth"; +import * as UserController from "src/controllers/user"; + +const router = express.Router(); + +router.get("/whoami", requireSignedIn, UserController.getWhoAmI); + +export default router; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts deleted file mode 100644 index 48e0e8e..0000000 --- a/backend/src/routes/users.ts +++ /dev/null @@ -1,38 +0,0 @@ -import express, { NextFunction, Request, Response } from "express"; -import { ServiceError } from "src/errors/service"; -import { User } from "src/models/users"; -import { verifyAuthToken } from "src/middleware/auth"; - -const router = express.Router(); - -router.get( - "/api/whoami", - [verifyAuthToken], - async (req: Request, res: Response, next: NextFunction) => { - try { - const uid = req.body.uid; - const user = await User.findOne({ uid: uid }); - if (!user) { - throw ServiceError.USER_NOT_FOUND; - } - const { _id: mongoId, role } = user; - res.status(200).send({ - message: "Current user information", - user: { - mongoId, - uid, - role, - }, - }); - return; - } catch (e) { - next(); - console.log(e); - return res.status(400).json({ - error: e, - }); - } - }, -); - -export { router as userRouter }; diff --git a/backend/src/routes/vsr.ts b/backend/src/routes/vsr.ts index 8488faf..3a98a70 100644 --- a/backend/src/routes/vsr.ts +++ b/backend/src/routes/vsr.ts @@ -1,13 +1,28 @@ import express from "express"; import * as VSRController from "src/controllers/vsr"; +import { requireAdmin, requireSignedIn, requireStaffOrAdmin } from "src/middleware/auth"; import * as VSRValidator from "src/validators/vsr"; const router = express.Router(); -router.get("/:id", VSRController.getVSR); +router.get("/", requireSignedIn, requireStaffOrAdmin, VSRController.getAllVSRS); router.post("/", VSRValidator.createVSR, VSRController.createVSR); -router.get("/", VSRController.getAllVSRS); -router.patch("/:id/status", VSRValidator.updateStatus, VSRController.updateStatus); +router.get("/:id", requireSignedIn, requireStaffOrAdmin, VSRController.getVSR); +router.delete("/:id", requireSignedIn, requireAdmin, VSRController.deleteVSR); +router.patch( + "/:id/status", + requireSignedIn, + requireStaffOrAdmin, + VSRValidator.updateStatus, + VSRController.updateStatus, +); +router.put( + "/:id", + requireSignedIn, + requireStaffOrAdmin, + VSRValidator.updateVSR, + VSRController.updateVSR, +); export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index b4e8e25..422690f 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -20,3 +20,5 @@ mongoose }); }) .catch(console.error); + +module.exports = app; diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index 90d645a..e608914 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -1,6 +1,10 @@ import { firebaseAuth } from "src/services/firebase"; import { AuthError } from "src/errors/auth"; +/** + * Decodes a Firebase token and returns the user info for the user who the token is for, + * or throws an error if the token is invalid. + */ async function decodeAuthToken(token: string) { try { const userInfo = await firebaseAuth.verifyIdToken(token); diff --git a/backend/src/services/emails.ts b/backend/src/services/emails.ts index bef1fff..f229faa 100644 --- a/backend/src/services/emails.ts +++ b/backend/src/services/emails.ts @@ -1,6 +1,12 @@ import "dotenv/config"; import nodemailer from "nodemailer"; -import env from "../util/validateEnv"; +import env from "src/util/validateEnv"; + +const trimmedFrontendUrl = env.FRONTEND_ORIGIN.replace( + // Trim trailing slash from frontend URL, if there is one + /\/$/gi, + "", +); /** * Sends a notification email to PAP staff when a VSR is submitted. @@ -8,10 +14,11 @@ import env from "../util/validateEnv"; * * @param name Name of veteran who submitted the VSR * @param email Email address of veteran who submitted the VSR + * @param id ID of the submitted VSR, used to construct a link to the VSR on our web app */ -const sendVSRNotificationEmailToStaff = async (name: string, email: string) => { +const sendVSRNotificationEmailToStaff = async (name: string, email: string, id: string) => { const EMAIL_SUBJECT = "New VSR Submitted"; - const EMAIL_BODY = `A new VSR was submitted by ${name} from ${email}`; + const EMAIL_BODY = `A new VSR was submitted by ${name} from ${email}. You can view it at ${trimmedFrontendUrl}/staff/vsr/${id}`; const transporter = nodemailer.createTransport({ service: "gmail", @@ -31,4 +38,103 @@ const sendVSRNotificationEmailToStaff = async (name: string, email: string) => { await transporter.sendMail(mailOptions); }; -export { sendVSRNotificationEmailToStaff }; +/** + * Sends a confirmation email to the veteran who submitted a VSR. + * Throws an error if the email could not be sent. + * + * @param name Name of veteran who submitted the VSR + * @param email Email address of veteran who submitted the VSR + */ +const sendVSRConfirmationEmailToVeteran = async (name: string, email: string) => { + const EMAIL_SUBJECT = "VSR Submission Confirmation"; + const EMAIL_HTML = `

Dear ${name},

\ +

Thank you for submitting a veteran service request (VSR).

\ +

\ + We schedule appointments on Tuesdays, Thursday, and Saturdays from 10-3pm. You need to be\ + specific about what day and time you want to make your appointment.\ + \ +

\ + \ +

\ + PLEASE NOTE: We will not be making appointments on THURSDAY, NOVEMBER 23RD (THANKSGIVING) OR\ + SATURDAY, DECEMBER 2nd.\ +

\ + \ +

\ + Respond to this email and let us know what day and time will work and we will put you\ + down.\ +

\ + \ +

\ + You pick up your items on the date and time of your appointment. You need to come prepared on that\ + day.\ +

\ +

We will help you with as much as we can on your list.

\ +

\ + Please remember that items are donated from the community as a way of saying THANK YOU FOR YOUR\ + SERVICE, so we are like a cashless thrift store environment.\ +

\ +

You will need to provide your own transportation for all your items you are requesting.

\ +

\ + \ + Wherever you are going to rent a truck, don't wait to the last minute to rent a truck as there\ + might not be one available.\ +

\ +

We are located in the Anaheim, in Orange County.

\ +

Once we confirm your appointment we will send you the warehouse protocol and address.

\ +

\ + \ + Items in the warehouse have been donated from the General Public and are strictly for Veterans,\ + Active Military and Reservists and their immediate family (wives and school aged children) who\ + live with them. The Veteran/Active Duty/Reservist has to be at appointment and will need to show\ + proof of service, such as a VA Card, DD214 or Active Military card.\ +

\ +

They are not for family members and friends who have not served our Country.

\ +

Thank you for contacting us.

\ +

Volunteer

\ + \ +

veteran@patriotsandpaws.org

\ + Patriots & Paws Logo\ +

\ + Facebook\ + https://www.facebook.com/pages/Patriots-and-Paws/283613748323930\ +

\ +

Twitter @patriotsandpaws

\ +

Instagram patriotsandpaws

\ + `; + + const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: env.EMAIL_USER, + pass: env.EMAIL_APP_PASSWORD, + }, + }); + + const mailOptions = { + from: env.EMAIL_USER, + to: email, + subject: EMAIL_SUBJECT, + html: EMAIL_HTML, + attachments: [ + { + filename: "pap_logo.png", + path: `${__dirname}/../../public/pap_logo.png`, + cid: "pap_logo.png", + }, + ], + }; + + await transporter.sendMail(mailOptions); +}; + +export { sendVSRNotificationEmailToStaff, sendVSRConfirmationEmailToVeteran }; diff --git a/backend/src/services/firebase.ts b/backend/src/services/firebase.ts index 10363ab..d11b472 100644 --- a/backend/src/services/firebase.ts +++ b/backend/src/services/firebase.ts @@ -10,6 +10,9 @@ import { getAuth } from "firebase-admin/auth"; import { InternalError } from "src/errors/internal"; import env from "src/util/validateEnv"; +/** + * Initializes Firebase Auth using the SERVICE_ACCOUNT_KEY environment variable + */ let serviceAccountKey: firebase.ServiceAccount; if (!env.SERVICE_ACCOUNT_KEY) { diff --git a/backend/src/tests/testUtils.ts b/backend/src/tests/testUtils.ts new file mode 100644 index 0000000..14c3dbf --- /dev/null +++ b/backend/src/tests/testUtils.ts @@ -0,0 +1,102 @@ +import { UserRecord } from "firebase-admin/auth"; +import { initializeApp } from "firebase/app"; +import { getAuth, signInWithCustomToken } from "firebase/auth"; +import { MongoClient } from "mongodb"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import mongoose from "mongoose"; +import { firebaseAuth } from "src/services/firebase"; +import env from "src/util/validateEnv"; + +const TEST_DB_NAME = "test"; + +/** + * A utility function to run some before & after testing hooks to set up and clean up + * a Mongo memory server, used for testing. + */ +export const mongoMemoryHooks = () => { + let client: MongoClient, memoryServer: MongoMemoryServer; + + /** + * This hook runs once, before all our tests are run. In this function, we + * set up a Mongo memory server, which emulates MongoDB by storing data in memory, to + * make it faster to run tests using the database. This allows our backend code to interface + * with MongoDB like normal, but all its actions will go to our test database. + */ + beforeAll(async () => { + // Create Mongo memory server + memoryServer = await MongoMemoryServer.create({ + instance: { + dbName: TEST_DB_NAME, + }, + }); + + // Get URI of memory server + const testMongoUri = memoryServer.getUri(); + + /** + * Connect to memory server, so that we can drop data between tests + * in the "afterEach" hook + */ + client = await MongoClient.connect(testMongoUri); + + /** + * Connect Mongoose to memory server, so we can use Mongoose like normal + * in our backend code that we are testing + */ + await mongoose.connect(testMongoUri); + }); + + /** + * This hook runs after each test in our suite. We use this function to drop all data + * from our testing MongoDB database, so that the database will be fresh and empty for each test. + * + * We may also want a "beforeEach" hook in the future, to insert some example data into + * the testing MongoDB database before each of our tests, so that we have some data to run tests on. + */ + afterEach(async () => { + // Drop all data from test database + await client.db(TEST_DB_NAME).dropDatabase(); + }); + + /** + * This hook runs once, after all tests in the suite are done. We use this function + * to close connections to the MongoDB memory database and clean up any other resources. + */ + afterAll(async () => { + await client.close(true); + await memoryServer.stop(); + await mongoose.disconnect(); + }); +}; + +let _testUserInfo: UserRecord | null = null; +let _testUserToken: string | null = null; + +/** + * Gets the userInfo (Firebase object) for the testing user + */ +export const getTestUserInfo = async () => { + if (_testUserInfo === null) { + _testUserInfo = await firebaseAuth.getUserByEmail(env.EMAIL_USER); + } + return _testUserInfo; +}; + +/** + * Gets a user token for the testing user, which can be used as an Authorization ehader + * on HTTP requests to our backend + */ +export const getTestUserToken = async () => { + if (_testUserToken === null) { + const testUserInfo = await getTestUserInfo(); + const customToken = await firebaseAuth.createCustomToken(testUserInfo.uid); + + const firebaseApp = initializeApp(env.BACKEND_FIREBASE_SETTINGS); + const auth = getAuth(firebaseApp); + + const { user } = await signInWithCustomToken(auth, customToken); + _testUserToken = await user.getIdToken(); + } + + return _testUserToken; +}; diff --git a/backend/src/validators/vsr.ts b/backend/src/validators/vsr.ts index e332037..2c62a0d 100644 --- a/backend/src/validators/vsr.ts +++ b/backend/src/validators/vsr.ts @@ -1,5 +1,9 @@ import { body } from "express-validator"; +/** + * Validators for creating and updating VSRs + */ + const makeNameValidator = () => body("name") .exists({ checkFalsy: true }) @@ -59,7 +63,7 @@ const makeEthnicityValidator = () => .custom((ethnicities: string[]) => ethnicities.every((ethnicity) => typeof ethnicity === "string"), ) - .withMessage("Each ethnicity in Ethnicities must be a positive integer"); + .withMessage("Each ethnicity in Ethnicities must be a string"); const makeEmploymentStatusValidator = () => body("employmentStatus") @@ -188,6 +192,7 @@ const ALLOWED_STATUSES = [ "Received", "Appointment Scheduled", "Approved", + "Complete", "Resubmit", "No-show / Incomplete", "Archived", @@ -231,3 +236,17 @@ export const createVSR = [ ]; export const updateStatus = [updateStatusValidator()]; + +export const updateVSR = [ + makeNameValidator(), + makeGenderValidator(), + makeAgeValidator(), + makeMaritalStatusValidator(), + makeSpouseNameValidator(), + makeAgesOfBoysValidator(), + makeAgesOfGirlsValidator(), + makeEthnicityValidator(), + makeEmploymentStatusValidator(), + makeIncomeLevelValidator(), + makeSizeOfHomeValidator(), +]; diff --git a/backend/vercel.json b/backend/vercel.json new file mode 100644 index 0000000..599aff5 --- /dev/null +++ b/backend/vercel.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "builds": [ + { + "src": "dist/src/server.js", + "use": "@vercel/node", + "config": { "includeFiles": ["dist/**"] } + } + ], + "routes": [ + { + "src": "/(.*)", + "dest": "dist/src/server.js" + } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c0615ee..7f606b4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "@mui/x-data-grid": "^6.19.5", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.1.2", + "email-validator": "^2.0.4", "envalid": "^8.0.0", "firebase": "^10.6.0", "jest": "^29.7.0", @@ -4242,6 +4243,14 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.594.tgz", "integrity": "sha512-xT1HVAu5xFn7bDfkjGQi9dNpMqGchUkebwf1GL7cZN32NSwwlHRPMSDJ1KN6HkS0bWUtndbSQZqvpQftKG2uFQ==" }, + "node_modules/email-validator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz", + "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==", + "engines": { + "node": ">4.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c08b4d4..d7af5cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@mui/x-data-grid": "^6.19.5", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.1.2", + "email-validator": "^2.0.4", "envalid": "^8.0.0", "firebase": "^10.6.0", "jest": "^29.7.0", diff --git a/frontend/public/errors/404_not_found.svg b/frontend/public/errors/404_not_found.svg new file mode 100644 index 0000000..8df41d6 --- /dev/null +++ b/frontend/public/errors/404_not_found.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/errors/500_internal_error.svg b/frontend/public/errors/500_internal_error.svg new file mode 100644 index 0000000..58a82f1 --- /dev/null +++ b/frontend/public/errors/500_internal_error.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/errors/cannot_load.svg b/frontend/public/errors/cannot_load.svg new file mode 100644 index 0000000..79890d6 --- /dev/null +++ b/frontend/public/errors/cannot_load.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/errors/no_internet.svg b/frontend/public/errors/no_internet.svg new file mode 100644 index 0000000..2cc5baf --- /dev/null +++ b/frontend/public/errors/no_internet.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/public/errors/red_x.svg b/frontend/public/errors/red_x.svg new file mode 100644 index 0000000..7dbfc68 --- /dev/null +++ b/frontend/public/errors/red_x.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/ic_check.svg b/frontend/public/ic_check.svg new file mode 100644 index 0000000..3122240 --- /dev/null +++ b/frontend/public/ic_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_close_large.svg b/frontend/public/ic_close_large.svg new file mode 100644 index 0000000..1eecd53 --- /dev/null +++ b/frontend/public/ic_close_large.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/ic_close_red.svg b/frontend/public/ic_close_red.svg new file mode 100644 index 0000000..af8b99b --- /dev/null +++ b/frontend/public/ic_close_red.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/ic_edit.svg b/frontend/public/ic_edit.svg index 728339f..5765a8e 100644 --- a/frontend/public/ic_edit.svg +++ b/frontend/public/ic_edit.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/frontend/public/ic_error.svg b/frontend/public/ic_error.svg new file mode 100644 index 0000000..438f0c6 --- /dev/null +++ b/frontend/public/ic_error.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/ic_hide.svg b/frontend/public/ic_hide.svg new file mode 100644 index 0000000..7d20b54 --- /dev/null +++ b/frontend/public/ic_hide.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/ic_show.svg b/frontend/public/ic_show.svg new file mode 100644 index 0000000..ab9a4f7 --- /dev/null +++ b/frontend/public/ic_show.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/ic_success.svg b/frontend/public/ic_success.svg new file mode 100644 index 0000000..6b9c9d4 --- /dev/null +++ b/frontend/public/ic_success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/mdi_trash.svg b/frontend/public/mdi_trash.svg new file mode 100644 index 0000000..313d9b0 --- /dev/null +++ b/frontend/public/mdi_trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/api/FurnitureItems.ts b/frontend/src/api/FurnitureItems.ts index edb35d2..e20f904 100644 --- a/frontend/src/api/FurnitureItems.ts +++ b/frontend/src/api/FurnitureItems.ts @@ -1,11 +1,5 @@ import { APIResult, handleAPIError, get } from "@/api/requests"; -export interface FurnitureItemJson { - _id: string; - category: string; - name: string; - allowMultiple: boolean; - categoryIndex: number; -} + export interface FurnitureItem { _id: string; category: string; @@ -14,21 +8,11 @@ export interface FurnitureItem { categoryIndex: number; } -function parseFurnitureItem(furnitureItem: FurnitureItemJson) { - return { - _id: furnitureItem._id, - category: furnitureItem.category, - name: furnitureItem.name, - allowMultiple: furnitureItem.allowMultiple, - categoryIndex: furnitureItem.categoryIndex, - }; -} export async function getFurnitureItems(): Promise> { try { const response = await get(`/api/furnitureItems`); - const json = (await response.json()) as FurnitureItemJson[]; - const furnitureItems = json.map(parseFurnitureItem); - return { success: true, data: furnitureItems }; + const json = (await response.json()) as FurnitureItem[]; + return { success: true, data: json }; } catch (error) { return handleAPIError(error); } diff --git a/frontend/src/api/Users.ts b/frontend/src/api/Users.ts new file mode 100644 index 0000000..64e76b7 --- /dev/null +++ b/frontend/src/api/Users.ts @@ -0,0 +1,21 @@ +import { APIResult, get, handleAPIError } from "@/api/requests"; + +export interface User { + _id: string; + uid: string; + role: string; +} + +export const createAuthHeader = (firebaseToken: string) => ({ + Authorization: `Bearer ${firebaseToken}`, +}); + +export const getWhoAmI = async (firebaseToken: string): Promise> => { + try { + const response = await get("/api/user/whoami", createAuthHeader(firebaseToken)); + const json = (await response.json()) as User; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +}; diff --git a/frontend/src/api/VSRs.ts b/frontend/src/api/VSRs.ts index 035f41b..8f9b235 100644 --- a/frontend/src/api/VSRs.ts +++ b/frontend/src/api/VSRs.ts @@ -1,4 +1,5 @@ -import { APIResult, get, handleAPIError, patch, post } from "@/api/requests"; +import { APIResult, get, handleAPIError, httpDelete, patch, post, put } from "@/api/requests"; +import { createAuthHeader } from "@/api/Users"; export interface FurnitureInput { furnitureItemId: string; @@ -107,39 +108,41 @@ export interface CreateVSRRequest { additionalItems: string; } +export interface UpdateVSRRequest { + name: string; + gender: string; + age: number; + maritalStatus: string; + spouseName?: string; + agesOfBoys: number[]; + agesOfGirls: number[]; + ethnicity: string[]; + employmentStatus: string; + incomeLevel: string; + sizeOfHome: string; + streetAddress: string; + city: string; + state: string; + zipCode: number; + phoneNumber: string; + email: string; + branch: string[]; + conflicts: string[]; + dischargeStatus: string; + serviceConnected: boolean; + lastRank: string; + militaryID: number; + petCompanion: boolean; + hearFrom: string; + selectedFurnitureItems: FurnitureInput[]; + additionalItems: string; +} + function parseVSR(vsr: VSRJson) { return { - _id: vsr._id, - name: vsr.name, - gender: vsr.gender, - age: vsr.age, - maritalStatus: vsr.maritalStatus, - spouseName: vsr.spouseName, - agesOfBoys: vsr.agesOfBoys, - agesOfGirls: vsr.agesOfGirls, - ethnicity: vsr.ethnicity, - employmentStatus: vsr.employmentStatus, - incomeLevel: vsr.incomeLevel, - sizeOfHome: vsr.sizeOfHome, - streetAddress: vsr.streetAddress, - city: vsr.city, - state: vsr.state, - zipCode: vsr.zipCode, - phoneNumber: vsr.phoneNumber, - email: vsr.email, - branch: vsr.branch, - conflicts: vsr.conflicts, - dischargeStatus: vsr.dischargeStatus, - serviceConnected: vsr.serviceConnected, - lastRank: vsr.lastRank, - militaryID: vsr.militaryID, - petCompanion: vsr.petCompanion, - selectedFurnitureItems: vsr.selectedFurnitureItems, - additionalItems: vsr.additionalItems, + ...vsr, dateReceived: new Date(vsr.dateReceived), lastUpdated: new Date(vsr.lastUpdated), - status: vsr.status, - hearFrom: vsr.hearFrom, }; } @@ -153,9 +156,9 @@ export async function createVSR(vsr: CreateVSRRequest): Promise> } } -export async function getAllVSRs(): Promise> { +export async function getAllVSRs(firebaseToken: string): Promise> { try { - const response = await get("/api/vsr"); + const response = await get("/api/vsr", createAuthHeader(firebaseToken)); const json = (await response.json()) as VSRListJson; return { success: true, data: json.vsrs.map(parseVSR) }; } catch (error) { @@ -163,9 +166,9 @@ export async function getAllVSRs(): Promise> { } } -export async function getVSR(id: string): Promise> { +export async function getVSR(id: string, firebaseToken: string): Promise> { try { - const response = await get(`/api/vsr/${id}`); + const response = await get(`/api/vsr/${id}`, createAuthHeader(firebaseToken)); const json = (await response.json()) as VSRJson; return { success: true, data: parseVSR(json) }; } catch (error) { @@ -173,9 +176,40 @@ export async function getVSR(id: string): Promise> { } } -export async function updateVSRStatus(id: string, status: string): Promise> { +export async function updateVSRStatus( + id: string, + status: string, + firebaseToken: string, +): Promise> { + try { + const response = await patch( + `/api/vsr/${id}/status`, + { status }, + createAuthHeader(firebaseToken), + ); + const json = (await response.json()) as VSRJson; + return { success: true, data: parseVSR(json) }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function deleteVSR(id: string, firebaseToken: string): Promise> { + try { + await httpDelete(`/api/vsr/${id}`, createAuthHeader(firebaseToken)); + return { success: true, data: null }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function updateVSR( + id: string, + vsr: UpdateVSRRequest, + firebaseToken: string, +): Promise> { try { - const response = await patch(`/api/vsr/${id}/status`, { status }); + const response = await put(`/api/vsr/${id}`, vsr, createAuthHeader(firebaseToken)); const json = (await response.json()) as VSRJson; return { success: true, data: parseVSR(json) }; } catch (error) { diff --git a/frontend/src/api/requests.ts b/frontend/src/api/requests.ts index 691e4ce..9f250c6 100644 --- a/frontend/src/api/requests.ts +++ b/frontend/src/api/requests.ts @@ -74,7 +74,7 @@ async function assertOk(response: Response): Promise { export async function get(url: string, headers: Record = {}): Promise { // GET requests do not have a body const response = await fetchRequest("GET", API_BASE_URL + url, undefined, headers); - assertOk(response); + await assertOk(response); return response; } @@ -92,7 +92,7 @@ export async function post( headers: Record = {}, ): Promise { const response = await fetchRequest("POST", API_BASE_URL + url, body, headers); - assertOk(response); + await assertOk(response); return response; } @@ -110,7 +110,7 @@ export async function put( headers: Record = {}, ): Promise { const response = await fetchRequest("PUT", API_BASE_URL + url, body, headers); - assertOk(response); + await assertOk(response); return response; } @@ -128,7 +128,23 @@ export async function patch( headers: Record = {}, ): Promise { const response = await fetchRequest("PATCH", API_BASE_URL + url, body, headers); - assertOk(response); + await assertOk(response); + return response; +} + +/** + * Sends a DELETE request to the provided API URL. + * + * @param url The URL to request + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function httpDelete( + url: string, + headers: Record = {}, +): Promise { + const response = await fetchRequest("DELETE", API_BASE_URL + url, undefined, headers); + await assertOk(response); return response; } @@ -157,9 +173,7 @@ export type APIError = { success: false; error: string }; * }) * ``` * - * See `createTask` in `src/api/tasks` and its use in `src/components/TaskForm` - * for a more concrete example, and see - * https://www.typescriptlang.org/docs/handbook/2/narrowing.html for more info + * See https://www.typescriptlang.org/docs/handbook/2/narrowing.html for more info * about type narrowing. */ export type APIResult = APIData | APIError; diff --git a/frontend/src/app/dummyPage/layout.tsx b/frontend/src/app/dummyPage/layout.tsx deleted file mode 100644 index 5ffb94e..0000000 --- a/frontend/src/app/dummyPage/layout.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const metadata = { - title: "Patriots & Paws", - description: "Web application for Patriots & Paws", -}; - -export default function DummyLayout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/frontend/src/app/dummyPage/page.tsx b/frontend/src/app/dummyPage/page.tsx deleted file mode 100644 index 879ddf8..0000000 --- a/frontend/src/app/dummyPage/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const Dummy = () => { - return

Login Successful!

; -}; - -export default Dummy; diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx new file mode 100644 index 0000000..0eb79e4 --- /dev/null +++ b/frontend/src/app/error.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { ErrorPage } from "@/components/Errors/ErrorPage"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import Image from "next/image"; + +const PAP_EMAIL = "info@patriotsandpaws.org"; + +/** + * Page that is shown if our web app throws a 500 Internal Error (e.g. our JavaScript crashes) + */ +const InternalError500Page = () => { + const { isMobile, isTablet } = useScreenSizes(); + + return ( + + } + title="Internal Error" + content={[ + "Something went wrong on our end. Our experts are trying to fix the problem.", + "Please refresh the page or wait for some time.", + <> + If you need immediate help while the system is down, please email our staff at{" "} +
{PAP_EMAIL}. + , + "Error Code: 500", + ]} + /> + ); +}; + +export default InternalError500Page; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 9bdea3d..d96ffcf 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -3,6 +3,7 @@ import React from "react"; import type { Metadata } from "next"; import "@/app/globals.css"; +import { UserContextProvider } from "@/contexts/userContext"; const inter = Inter({ subsets: ["latin"] }); @@ -14,7 +15,9 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/frontend/src/app/login/page.module.css b/frontend/src/app/login/page.module.css index c06f1c5..2c10ed0 100644 --- a/frontend/src/app/login/page.module.css +++ b/frontend/src/app/login/page.module.css @@ -96,19 +96,17 @@ width: 100%; height: 50px; flex-shrink: 0; - border-radius: 4px; - background: var(--Secondary-1, #102d5f); - color: #fff; - cursor: pointer; text-align: center; font-family: Lora; font-size: 24px; - font-style: normal; font-weight: 700; - line-height: normal; margin-bottom: 49px; } +.disabledButton { + background: grey !important; +} + /* tablet version */ @media screen and (max-width: 850px) { .backgroundImage { diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index d4dcdbd..012c12a 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -2,135 +2,267 @@ "use client"; import React, { useState } from "react"; -import InputField from "@/components/shared/input/InputField"; import Image from "next/image"; import styles from "@/app/login/page.module.css"; import { signInWithEmailAndPassword } from "firebase/auth"; import { initFirebase } from "@/firebase/firebase"; -import { useRouter } from "next/navigation"; -import { useScreenSizes } from "@/util/useScreenSizes"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { useRedirectToHomeIfSignedIn } from "@/hooks/useRedirection"; +import { getWhoAmI } from "@/api/Users"; +import { ErrorNotification } from "@/components/Errors/ErrorNotification"; +import { FirebaseError } from "firebase/app"; +import { Button } from "@/components/shared/Button"; +import TextField from "@/components/shared/input/TextField"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { IconButton } from "@mui/material"; +enum LoginPageError { + NO_INTERNET, + INVALID_CREDENTIALS, + INTERNAL, + TOO_MANY_REQUESTS, + NONE, +} + +interface ILoginFormInput { + email: string; + password: string; +} + +/** + * The root Login page component. + */ const Login = () => { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); + const [passwordVisible, setPasswordVisible] = useState(false); + const [loading, setLoading] = useState(false); + const [pageError, setPageError] = useState(LoginPageError.NONE); - const { auth } = initFirebase(); + useRedirectToHomeIfSignedIn(); - const router = useRouter(); + const { auth } = initFirebase(); const { isMobile } = useScreenSizes(); + const { + handleSubmit, + register, + formState: { errors, isValid }, + } = useForm(); + + /** + * Sends the user's Firebase token to the /api/user/whoami backend route, + * to ensure they are a valid user and we can retrieve their identity. + */ const sendTokenToBackend = async (token: string) => { - try { - const response = await fetch(`http://localhost:3001/api/whoami`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - - if (response.ok) { - const userInfo = await response.json(); - console.log(userInfo); - router.push("/dummyPage"); + const res = await getWhoAmI(token); + if (!res.success) { + if (res.error === "Failed to fetch") { + setPageError(LoginPageError.NO_INTERNET); } else { - console.error("Failed to get user info from JWT Token"); + console.error(`Cannot retrieve whoami: error ${res.error}`); + setPageError(LoginPageError.INTERNAL); } - } catch (error) { - console.error("error sending JWT token to backend", error); } }; - const login = async (email: string, password: string) => { + /** + * Logs the user in using their entered email and password. + */ + const onSubmit: SubmitHandler = async (data) => { try { - const userCredential = await signInWithEmailAndPassword(auth, email, password); + setLoading(true); + + // Sign in to Firebase + const userCredential = await signInWithEmailAndPassword(auth, data.email, data.password); + + // Get token for user const token = await userCredential.user?.getIdToken(); if (!token) { console.error("JWT token not retrieved."); + setPageError(LoginPageError.INTERNAL); } else { + // Once we have the token, send it to the /api/user/whoami backend route await sendTokenToBackend(token); } } catch (error) { - console.error("login failed: ", error); + console.error("Firebase login failed with error: ", error); + + // See https://firebase.google.com/docs/auth/admin/errors for Firebase error codes + switch ((error as FirebaseError)?.code) { + case "auth/invalid-email": + case "auth/invalid-credentials": + case "auth/invalid-login-credentials": + setPageError(LoginPageError.INVALID_CREDENTIALS); + break; + case "auth/network-request-failed": + setPageError(LoginPageError.NO_INTERNET); + break; + case "auth/too-many-requests": + setPageError(LoginPageError.TOO_MANY_REQUESTS); + break; + default: + setPageError(LoginPageError.INTERNAL); + break; + } + } finally { + setLoading(false); } }; - const handleLogin = (e: React.FormEvent) => { - e.preventDefault(); - login(email, password); + // Move error notification higher up than usual + const errorNotificationStyles = { + top: 18, + }; + + /** + * Renders an error notification based on the current error, or renders nothing + * if there is no error. + */ + const renderErrorNotification = () => { + switch (pageError) { + case LoginPageError.NO_INTERNET: + return ( + setPageError(LoginPageError.NONE)} + style={errorNotificationStyles} + /> + ); + case LoginPageError.INVALID_CREDENTIALS: + return ( + setPageError(LoginPageError.NONE)} + style={errorNotificationStyles} + /> + ); + case LoginPageError.TOO_MANY_REQUESTS: + return ( + setPageError(LoginPageError.NONE)} + style={errorNotificationStyles} + /> + ); + case LoginPageError.INTERNAL: + return ( + setPageError(LoginPageError.NONE)} + style={errorNotificationStyles} + /> + ); + default: + return null; + } }; return ( - -
- -
-
-
-
- Logo -
+
+ +
+
+
+
+ Logo
-
Welcome!
-
-
- setEmail(e.target.value)} - /> -
-
- setPassword(e.target.value)} - type="password" - /> -
-
Forgot Password?
- -
+
Welcome!
+
+
+ +
+
+ setPasswordVisible((prevVisible) => !prevVisible)}> + {passwordVisible + + ), + }} + /> +
+
Forgot Password?
+
- + {renderErrorNotification()} +
); }; diff --git a/frontend/src/app/not-found.tsx b/frontend/src/app/not-found.tsx new file mode 100644 index 0000000..6dbbe09 --- /dev/null +++ b/frontend/src/app/not-found.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { ErrorPage } from "@/components/Errors/ErrorPage"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import Image from "next/image"; + +const VSR_URL = "/vsr"; + +/** + * Page that is shown on any URL that does not match one of our defined frontend routes. + */ +const NotFound404Page = () => { + const { isMobile, isTablet } = useScreenSizes(); + + return ( + + } + title="Page Not Found" + content={[ + "Sorry, we couldn't find the page you're looking for.", + <> + If you would like to submit a VSR form, please click{" "} + { + + here + + } + . + , + "Please ensure that the URL is correct and try again.", + "Error Code: 404", + ]} + /> + ); +}; + +export default NotFound404Page; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index bcafc39..76ae5e0 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,14 +1,10 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; - -const Home = () => { - const router = useRouter(); - useEffect(() => { - router.push("/login"); - }); - return null; -}; - -export default Home; +import { redirect } from "next/navigation"; + +/** + * On the root URL of our site (i.e. "/"), redirect the user to the staff login page. + */ +const Home = () => { + redirect("/login"); +}; + +export default Home; diff --git a/frontend/src/app/staff/vsr/[id]/page.tsx b/frontend/src/app/staff/vsr/[id]/page.tsx index 29226fa..d74047f 100644 --- a/frontend/src/app/staff/vsr/[id]/page.tsx +++ b/frontend/src/app/staff/vsr/[id]/page.tsx @@ -1,10 +1,13 @@ "use client"; -import { Page } from "@/components/VSRIndividual"; +import { VSRIndividualPage } from "@/components/VSRIndividual"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/useRedirection"; export default function Individual() { + useRedirectToLoginIfNotSignedIn(); + return (
- +
); } diff --git a/frontend/src/app/staff/vsr/page.module.css b/frontend/src/app/staff/vsr/page.module.css index 3818081..e664bfc 100644 --- a/frontend/src/app/staff/vsr/page.module.css +++ b/frontend/src/app/staff/vsr/page.module.css @@ -1,6 +1,7 @@ .page { background-color: var(--color-background); padding-bottom: 67px; + min-height: 100vh; } .column { @@ -62,32 +63,6 @@ align-items: center; } -.buttons { - display: flex; - flex-direction: row; - align-items: center; - max-width: 137px; - padding: 11px 16px; - border-radius: 4px; - gap: 6px; - justify-content: center; - background-color: var(--color-tse-secondary-1); - cursor: pointer; - border: none !important; -} - -.buttontext { - color: var(--Primary-Background-Light, #f7f7f7); - text-align: center; - - /* Desktop/Body 2 */ - font-family: "Open Sans"; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; -} - .table { display: flex; flex-direction: column; diff --git a/frontend/src/app/staff/vsr/page.tsx b/frontend/src/app/staff/vsr/page.tsx index f34a762..bd5c5e8 100644 --- a/frontend/src/app/staff/vsr/page.tsx +++ b/frontend/src/app/staff/vsr/page.tsx @@ -6,18 +6,136 @@ import SearchKeyword from "@/components/VSRTable/SearchKeyword"; import PageTitle from "@/components/VSRTable/PageTitle"; import HeaderBar from "@/components/shared/HeaderBar"; import Image from "next/image"; -import React from "react"; -import { StatusDropdown } from "@/components/VSRIndividual"; +import React, { useContext, useEffect, useState } from "react"; +import { StatusDropdown } from "@/components/shared/StatusDropdown"; import { useMediaQuery } from "@mui/material"; +import { useRedirectToLoginIfNotSignedIn } from "@/hooks/useRedirection"; +import { UserContext } from "@/contexts/userContext"; +import { DeleteVSRsModal } from "@/components/shared/DeleteVSRsModal"; +import { VSR, getAllVSRs } from "@/api/VSRs"; +import { VSRErrorModal } from "@/components/VSRForm/VSRErrorModal"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { Button } from "@/components/shared/Button"; +enum VSRTableError { + CANNOT_FETCH_VSRS_NO_INTERNET, + CANNOT_FETCH_VSRS_INTERNAL, + NONE, +} + +/** + * Root component for the VSR list/table view. + */ export default function VSRTableView() { + const { isMobile, isTablet } = useScreenSizes(); const searchOnOwnRow = useMediaQuery("@media screen and (max-width: 1000px)"); - const buttonIconsOnly = useMediaQuery("@media screen and (max-width: 700px)"); - const buttonIconSize = buttonIconsOnly ? 16 : 24; + + const { firebaseUser, papUser } = useContext(UserContext); + const [loadingVsrs, setLoadingVsrs] = useState(true); + const [vsrs, setVsrs] = useState(); + const [tableError, setTableError] = useState(VSRTableError.NONE); + + const [selectedVsrIds, setSelectedVsrIds] = useState([]); + const [deleteVsrModalOpen, setDeleteVsrModalOpen] = useState(false); + + useRedirectToLoginIfNotSignedIn(); + + const atLeastOneRowSelected = selectedVsrIds.length > 0; + + /** + * Fetches the list of all VSRs from the backend and updates our vsrs state. + */ + const fetchVSRs = () => { + if (!firebaseUser) { + return; + } + + setLoadingVsrs(true); + firebaseUser?.getIdToken().then((firebaseToken) => { + getAllVSRs(firebaseToken).then((result) => { + if (result.success) { + setVsrs(result.data); + } else { + if (result.error === "Failed to fetch") { + setTableError(VSRTableError.CANNOT_FETCH_VSRS_NO_INTERNET); + } else { + console.error(`Error retrieving VSRs: ${result.error}`); + setTableError(VSRTableError.CANNOT_FETCH_VSRS_INTERNAL); + } + } + setLoadingVsrs(false); + }); + }); + }; + + // Fetch the VSRs from the backend once the Firebase user loads. + useEffect(() => { + fetchVSRs(); + }, [firebaseUser]); + + /** + * Renders an error modal corresponding to the page's error state, or renders + * nothing if there is no error. + */ + const renderErrorModal = () => { + switch (tableError) { + case VSRTableError.CANNOT_FETCH_VSRS_NO_INTERNET: + return ( + { + setTableError(VSRTableError.NONE); + }} + imageComponent={ + No Internet + } + title="No Internet Connection" + content="Unable to retrieve the VSRs due to no internet connection. Please check your connection and try again." + buttonText="Try Again" + onButtonClicked={() => { + setTableError(VSRTableError.NONE); + fetchVSRs(); + }} + /> + ); + case VSRTableError.CANNOT_FETCH_VSRS_INTERNAL: + return ( + { + setTableError(VSRTableError.NONE); + }} + imageComponent={ + Internal Error + } + title="Internal Error" + content="Something went wrong with retrieving the VSRs. Our team is working to fix it. Please try again later." + buttonText="Try Again" + onButtonClicked={() => { + setTableError(VSRTableError.NONE); + fetchVSRs(); + }} + /> + ); + default: + return null; + } + }; return (
- +
@@ -32,31 +150,68 @@ export default function VSRTableView() {
- - + )} +
{searchOnOwnRow ? : null}
- + {loadingVsrs ? ( + + ) : ( + + )}
+ + {/* Error modal and delete modal */} + {renderErrorModal()} + setDeleteVsrModalOpen(false)} + afterDelete={() => { + setSelectedVsrIds([]); + fetchVSRs(); + }} + vsrIds={selectedVsrIds} + />
); } diff --git a/frontend/src/app/vsr/page.module.css b/frontend/src/app/vsr/page.module.css index ee4dc60..5a5b088 100644 --- a/frontend/src/app/vsr/page.module.css +++ b/frontend/src/app/vsr/page.module.css @@ -29,6 +29,11 @@ line-height: normal; } +.emailLink { + font-style: italic; + text-decoration-line: underline; +} + .fieldsMarked { color: #000; font-family: "Open Sans"; @@ -87,15 +92,6 @@ flex-direction: row; } -.numChildren { - display: flex; - flex-direction: column; - gap: 20px; - align-items: center; - flex: 1; - width: 100%; -} - .asterisk { color: var(--Secondary-2, #be2d46); } @@ -116,10 +112,6 @@ width: 100%; } -.childInputWrapper { - width: 100%; -} - .canvas { border-radius: 20px; background-color: #fff; @@ -132,7 +124,7 @@ .title { color: var(--Accent-Blue-1, #102d5f); font-family: "Lora"; - font-size: 24px; + font-size: 40px; font-style: normal; font-weight: 700; line-height: normal; @@ -201,24 +193,8 @@ border-radius: 4px; } -.submit { - color: white; - border: none; -} - -.enabled { - background-color: #102d5f; -} - .disabled { - background-color: grey; -} - -.back { - background-color: var(--color-tse-primary-light); - color: #102d5f; - border: 1px solid var(--Secondary-1, #102d5f); - background: rgba(255, 255, 255, 0); + background-color: grey !important; } /* tablet version */ diff --git a/frontend/src/app/vsr/page.tsx b/frontend/src/app/vsr/page.tsx index 29bd642..4eadbf1 100644 --- a/frontend/src/app/vsr/page.tsx +++ b/frontend/src/app/vsr/page.tsx @@ -1,7 +1,8 @@ "use client"; +import emailValidator from "email-validator"; import React, { useEffect, useState } from "react"; import styles from "src/app/vsr/page.module.css"; -import { useForm, Controller, SubmitHandler } from "react-hook-form"; +import { useForm, Controller, SubmitHandler, RegisterOptions } from "react-hook-form"; import TextField from "@/components/shared/input/TextField"; import MultipleChoice from "@/components/shared/input/MultipleChoice"; import Dropdown from "@/components/shared/input/Dropdown"; @@ -10,208 +11,86 @@ import PageNumber from "@/components/VSRForm/PageNumber"; import { createVSR, CreateVSRRequest, FurnitureInput } from "@/api/VSRs"; import { FurnitureItem, getFurnitureItems } from "@/api/FurnitureItems"; import BinaryChoice from "@/components/shared/input/BinaryChoice"; -import { FurnitureItemSelection } from "@/components/VeteranForm/FurnitureItemSelection"; -import { useScreenSizes } from "@/util/useScreenSizes"; - -interface IFormInput { - name: string; - marital_status: string; - gender: string; - spouse: string; - age: number; - ethnicity: string; - other_ethnicity: string; - employment_status: string; - income_level: string; - size_of_home: string; - num_boys: number; - num_girls: number; - ages_of_boys: number[]; - ages_of_girls: number[]; - streetAddress: string; - city: string; - state: string; - zipCode: number; - phoneNumber: string; - email: string; - branch: string[]; - conflicts: string[]; - dischargeStatus: string; - serviceConnected: boolean; - lastRank: string; - militaryID: number; - petCompanion: boolean; - hearFrom: string; +import { ConfirmVSRSubmissionModal } from "@/components/VSRForm/ConfirmVSRSubmissionModal"; +import { FurnitureItemSelection } from "@/components/VSRForm/FurnitureItemSelection"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { VSRErrorModal } from "@/components/VSRForm/VSRErrorModal"; +import Image from "next/image"; +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { + branchOptions, + conflictsOptions, + dischargeStatusOptions, + employmentOptions, + ethnicityOptions, + genderOptions, + hearFromOptions, + homeOptions, + maritalOptions, + incomeOptions, + stateOptions, +} from "@/constants/fieldOptions"; +import { ChildrenInput } from "@/components/shared/input/ChildrenInput"; +import { Button } from "@/components/shared/Button"; +import { ICreateVSRFormInput, IVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { vsrInputFieldValidators } from "@/components/VSRForm/VSRFormValidators"; + +enum VSRFormError { + CANNOT_RETRIEVE_FURNITURE_NO_INTERNET, + CANNOT_RETRIEVE_FURNITURE_INTERNAL, + CANNOT_SUBMIT_NO_INTERNET, + CANNOT_SUBMIT_INTERNAL, + NONE, } +/** + * Root component for the page with the VSR form for veterans to fill out. + */ const VeteranServiceRequest: React.FC = () => { + /** + * Form utilities + */ + const formProps = useForm(); const { register, handleSubmit, control, formState: { errors, isValid }, watch, - } = useForm(); + reset, + } = formProps; + + /** + * Internal state for fields that are complicated and cannot be controlled with a + * named form field alone (e.g. there is a multiple choice and a text field for "Other") + */ const [selectedEthnicities, setSelectedEthnicities] = useState([]); const [otherEthnicity, setOtherEthnicity] = useState(""); const [selectedConflicts, setSelectedConflicts] = useState([]); const [otherConflict, setOtherConflict] = useState(""); - const [selectedBranch, setSelectedBranch] = useState([]); - const [selectedHearFrom, setSelectedHearFrom] = useState(""); const [otherHearFrom, setOtherHearFrom] = useState(""); - const [pageNumber, setPageNumber] = useState(1); - - const numBoys = watch("num_boys"); - const numGirls = watch("num_girls"); - - const maritalOptions = ["Married", "Single", "It's Complicated", "Widowed/Widower"]; - const genderOptions = ["Male", "Female", "Other"]; - const employmentOptions = [ - "Employed", - "Unemployed", - "Currently Looking", - "Retired", - "In School", - "Unable to work", - ]; - - const incomeOptions = [ - "$12,500 and under", - "$12,501 - $25,000", - "$25,001 - $50,000", - "$50,001 and over", - ]; - - const homeOptions = [ - "House", - "Apartment", - "Studio", - "1 Bedroom", - "2 Bedroom", - "3 Bedroom", - "4 Bedroom", - "4+ Bedroom", - ]; - - const ethnicityOptions = [ - "Asian", - "African American", - "Caucasian", - "Native American", - "Pacific Islander", - "Middle Eastern", - "Prefer not to say", - ]; - - const branchOptions = [ - "Air Force", - "Air Force Reserve", - "Air National Guard", - "Army", - "Army Air Corps", - "Army Reserve", - "Coast Guard", - "Marine Corps", - "Navy", - "Navy Reserve", - ]; - - const conflictsOptions = [ - "WWII", - "Korea", - "Vietnam", - "Persian Gulf", - "Bosnia", - "Kosovo", - "Panama", - "Kuwait", - "Iraq", - "Somalia", - "Desert Shield/Storm", - "Operation Enduring Freedom (OEF)", - "Afghanistan", - "Irani Crisis", - "Granada", - "Lebanon", - "Beirut", - "Special Ops", - "Peacetime", - ]; - - const dischargeStatusOptions = [ - "Honorable Discharge", - "General Under Honorable", - "Other Than Honorable", - "Bad Conduct", - "Entry Level", - "Dishonorable", - "Still Serving", - "Civilian", - "Medical", - "Not Given", - ]; + const [additionalItems, setAdditionalItems] = useState(""); - const hearFromOptions = ["Colleague", "Social Worker", "Friend", "Internet", "Social Media"]; + const [pageNumber, setPageNumber] = useState(1); - const stateOptions = [ - "AL", - "AK", - "AZ", - "AR", - "CA", - "CO", - "CT", - "DE", - "FL", - "GA", - "HI", - "ID", - "IL", - "IN", - "IA", - "KS", - "KY", - "LA", - "ME", - "MD", - "MA", - "MI", - "MN", - "MS", - "MO", - "MT", - "NE", - "NV", - "NH", - "NJ", - "NM", - "NY", - "NC", - "ND", - "OH", - "OK", - "OR", - "PA", - "RI", - "SC", - "SD", - "TN", - "TX", - "UT", - "VT", - "VA", - "WA", - "WV", - "WI", - "WY", - ]; + const goToPage = (newPage: number) => { + setPageNumber(newPage); + // Jump to top of window when going to new page + document.body.scrollTop = document.documentElement.scrollTop = 0; + }; - const [errorMessage, setErrorMessage] = useState(null); + /** + * Internal state for loading, errors, and data + */ + const [loadingVsrSubmission, setLoadingVsrSubmission] = useState(false); + const [confirmSubmissionModalOpen, setConfirmSubmissionModalOpen] = useState(false); + const [vsrFormError, setVsrFormError] = useState(VSRFormError.NONE); + const [loadingFurnitureItems, setLoadingFurnitureItems] = useState(false); const [furnitureCategoriesToItems, setFurnitureCategoriesToItems] = useState>(); // Map furniture item IDs to selections for those items @@ -219,30 +98,42 @@ const VeteranServiceRequest: React.FC = () => { Record >({}); - const [additionalItems, setAdditionalItems] = useState(""); + /** + * Fetches the list of available furniture items from the backend, and updates + * our state for the items to render on page 3. + */ + const fetchFurnitureItems = () => { + if (loadingFurnitureItems) { + return; + } + setLoadingFurnitureItems(true); + getFurnitureItems().then((result) => { + if (result.success) { + setFurnitureCategoriesToItems( + result.data.reduce( + (prevMap: Record, curItem) => ({ + ...prevMap, + [curItem.category]: [...(prevMap[curItem.category] ?? []), curItem], + }), + {}, + ), + ); + setVsrFormError(VSRFormError.NONE); + } else { + if (result.error === "Failed to fetch") { + setVsrFormError(VSRFormError.CANNOT_RETRIEVE_FURNITURE_NO_INTERNET); + } else { + setVsrFormError(VSRFormError.CANNOT_RETRIEVE_FURNITURE_INTERNAL); + console.error(`Cannot retrieve furniture items: error ${result.error}`); + } + } + setLoadingFurnitureItems(false); + }); + }; // Fetch all available furniture items from database useEffect(() => { - getFurnitureItems() - .then((result) => { - if (result.success) { - setFurnitureCategoriesToItems( - result.data.reduce( - (prevMap: Record, curItem) => ({ - ...prevMap, - [curItem.category]: [...(prevMap[curItem.category] ?? []), curItem], - }), - {}, - ), - ); - setErrorMessage(null); - } else { - setErrorMessage("Furniture items not found."); - } - }) - .catch((error) => { - setErrorMessage(`An error occurred: ${error.message}`); - }); + fetchFurnitureItems(); }, []); // Handle furniture item count whenever a change is made @@ -253,23 +144,29 @@ const VeteranServiceRequest: React.FC = () => { })); }; - const { isMobile } = useScreenSizes(); + const { isMobile, isTablet } = useScreenSizes(); // Execute when submit button is pressed - const onSubmit: SubmitHandler = async (data) => { + const onSubmit: SubmitHandler = async (data) => { + if (loadingVsrSubmission) { + return; + } + setLoadingVsrSubmission(true); + // Construct the request object + const createVSRRequest: CreateVSRRequest = { name: data.name, gender: data.gender, age: data.age, - maritalStatus: data.marital_status, - spouseName: data.spouse, + maritalStatus: data.maritalStatus, + spouseName: data.spouseName, agesOfBoys: - data.ages_of_boys + data.agesOfBoys ?.slice(0, data.num_boys) .map((age) => (typeof age === "number" ? age : parseInt(age))) ?? [], agesOfGirls: - data.ages_of_girls + data.agesOfGirls ?.slice(0, data.num_girls) .map((age) => (typeof age === "number" ? age : parseInt(age))) ?? [], ethnicity: selectedEthnicities.concat(otherEthnicity === "" ? [] : [otherEthnicity]), @@ -283,7 +180,7 @@ const VeteranServiceRequest: React.FC = () => { zipCode: data.zipCode, phoneNumber: data.phoneNumber, email: data.email, - branch: selectedBranch, + branch: data.branch, conflicts: selectedConflicts.concat(otherConflict === "" ? [] : [otherConflict]), dischargeStatus: data.dischargeStatus, serviceConnected: data.serviceConnected, @@ -299,84 +196,29 @@ const VeteranServiceRequest: React.FC = () => { additionalItems, }; - try { - const response = await createVSR(createVSRRequest); + // Send request to backend + const response = await createVSR(createVSRRequest); - if (!response.success) { - // TODO: better way of displaying error - throw new Error(`HTTP error! status: ${response.error}`); + // Handle success/error + if (response.success) { + setConfirmSubmissionModalOpen(true); + } else { + if (response.error === "Failed to fetch") { + setVsrFormError(VSRFormError.CANNOT_SUBMIT_NO_INTERNET); + } else { + setVsrFormError(VSRFormError.CANNOT_SUBMIT_INTERNAL); + console.error(`Cannot submit VSR, error ${response.error}`); } - - // TODO: better way of displaying successful submission (popup/modal) - alert("VSR submitted successfully!"); - } catch (error) { - console.error("There was a problem with the fetch operation:", error); } + setLoadingVsrSubmission(false); }; const incrementPageNumber = () => { - setPageNumber(pageNumber + 1); + goToPage(pageNumber + 1); }; const decrementPageNumber = () => { - setPageNumber(pageNumber - 1); - }; - - const renderChildInput = (gender: "boy" | "girl") => { - const numChildrenThisGender = gender === "boy" ? numBoys : numGirls; - - return ( - <> -
- -
- - {numChildrenThisGender > 0 ? ( -
- {/* Cap it at 99 children per gender to avoid freezing web browser */} - {Array.from({ length: Math.min(numChildrenThisGender, 99) }, (_, index) => ( -
- -
- ))} -
- ) : null} - - ); + goToPage(pageNumber - 1); }; const renderPageNumber = () => { @@ -387,22 +229,31 @@ const VeteranServiceRequest: React.FC = () => { return pageNumber === 1 ? (
) : ( - + + /> ); }; @@ -428,11 +279,117 @@ const VeteranServiceRequest: React.FC = () => { } }; + const renderErrorModal = () => { + switch (vsrFormError) { + case VSRFormError.CANNOT_RETRIEVE_FURNITURE_NO_INTERNET: + return ( + { + setVsrFormError(VSRFormError.NONE); + }} + imageComponent={ + No Internet + } + title="No Internet Connection" + content="Unable to retrieve the available furniture items due to no internet connection. Please check your connection and try again." + buttonText="Try Again" + onButtonClicked={() => { + setVsrFormError(VSRFormError.NONE); + fetchFurnitureItems(); + }} + /> + ); + case VSRFormError.CANNOT_RETRIEVE_FURNITURE_INTERNAL: + return ( + { + setVsrFormError(VSRFormError.NONE); + }} + imageComponent={ + Internal Error + } + title="Internal Error" + content="Something went wrong with retrieving the available furniture items. Our team is working to fix it. Please try again later." + buttonText="Try Again" + onButtonClicked={() => { + setVsrFormError(VSRFormError.NONE); + fetchFurnitureItems(); + }} + /> + ); + case VSRFormError.CANNOT_SUBMIT_NO_INTERNET: + return ( + { + setVsrFormError(VSRFormError.NONE); + }} + imageComponent={ + No Internet + } + title="No Internet Connection" + content="Unable to submit the VSR form due to no internet connection. Please check your connection and try again." + buttonText="Try Again" + onButtonClicked={() => { + setVsrFormError(VSRFormError.NONE); + onSubmit(watch()); + }} + /> + ); + case VSRFormError.CANNOT_SUBMIT_INTERNAL: + return ( + { + setVsrFormError(VSRFormError.NONE); + }} + imageComponent={ + Internal Error + } + title="Internal Error" + content="Something went wrong with submitting the VSR form. Our team is working to fix it. Please try again later." + buttonText="OK" + onButtonClicked={() => { + setVsrFormError(VSRFormError.NONE); + }} + /> + ); + case VSRFormError.NONE: + default: + return null; + } + }; + + /** + * Render different fields based on current page number + */ if (pageNumber == 1) { return (
- +

Veteran Service Request Form

@@ -446,8 +403,11 @@ const VeteranServiceRequest: React.FC = () => { spam folder if you don't receive a response within 48 business hours.



- If you have any questions or concerns, send us an email at - veteran@patriotsandpaws.org. + If you have any questions or concerns, send us an email at{" "} + + veteran@patriotsandpaws.org + + .

@@ -467,7 +427,10 @@ const VeteranServiceRequest: React.FC = () => { label="Name" variant="outlined" placeholder="e.g. Justin Timberlake" - {...register("name", { required: "Name is required" })} + {...register( + "name", + vsrInputFieldValidators.name as RegisterOptions, + )} required error={!!errors.name} helperText={errors.name?.message} @@ -483,7 +446,9 @@ const VeteranServiceRequest: React.FC = () => { defaultValue="" name="gender" control={control} - rules={{ required: "Gender is required" }} + rules={ + vsrInputFieldValidators.gender as RegisterOptions + } render={({ field }) => ( { type="number" variant="outlined" placeholder="Enter your age" - {...register("age", { - required: "Age is required", - pattern: { - // Only allow up to 2 digits - value: /^[0-9]+$/, - message: "This field must be a number", - }, - })} + {...register( + "age", + vsrInputFieldValidators.age as RegisterOptions, + )} required error={!!errors.age} helperText={errors.age?.message} @@ -522,9 +483,14 @@ const VeteranServiceRequest: React.FC = () => {
+ } render={({ field }) => ( { value={field.value} onChange={(newValue) => field.onChange(newValue)} required - error={!!errors.marital_status} - helperText={errors.marital_status?.message} + error={!!errors.maritalStatus} + helperText={errors.maritalStatus?.message} /> )} /> - {watch().marital_status === "Married" ? ( + {watch().maritalStatus === "Married" ? (
, + )} required - error={!!errors.spouse} - helperText={errors.spouse?.message} + error={!!errors.spouseName} + helperText={errors.spouseName?.message} />
{/* Add an empty div here with flex: 1 to take up the right half of the row */} @@ -560,15 +530,21 @@ const VeteranServiceRequest: React.FC = () => {

Children Under the Age of 18:

-
{renderChildInput("boy")}
-
{renderChildInput("girl")}
+
+ +
+
+ +
+ } render={({ field }) => ( <> { + } render={({ field }) => ( { + } render={({ field }) => ( { + } render={({ field }) => ( { {renderBottomRow()}
+ {renderErrorModal()}
); } else if (pageNumber === 2) { return (
- +
@@ -684,7 +676,13 @@ const VeteranServiceRequest: React.FC = () => { label="Street Address" variant="outlined" placeholder="e.g. 1234 Baker Street" - {...register("streetAddress", { required: "Street address is required" })} + {...register( + "streetAddress", + vsrInputFieldValidators.streetAddress as RegisterOptions< + IVSRFormInput, + "streetAddress" + >, + )} required error={!!errors.streetAddress} helperText={errors.streetAddress?.message} @@ -696,7 +694,10 @@ const VeteranServiceRequest: React.FC = () => { label="City" variant="outlined" placeholder="e.g. San Diego" - {...register("city", { required: "City is required" })} + {...register( + "city", + vsrInputFieldValidators.city as RegisterOptions, + )} required error={!!errors.city} helperText={errors.city?.message} @@ -709,7 +710,9 @@ const VeteranServiceRequest: React.FC = () => { defaultValue="" name="state" control={control} - rules={{ required: "State is required" }} + rules={ + vsrInputFieldValidators.state as RegisterOptions + } render={({ field }) => ( { type="number" variant="outlined" placeholder="e.g. 92092" - {...register("zipCode", { - required: "Zip Code is required", - pattern: { - // Must be 5 digits - value: /^\d{5}$/, - message: "This field must be a 5 digit number", - }, - })} + {...register( + "zipCode", + vsrInputFieldValidators.zipCode as RegisterOptions< + IVSRFormInput, + "zipCode" + >, + )} required error={!!errors.zipCode} helperText={errors.zipCode?.message} @@ -754,13 +756,13 @@ const VeteranServiceRequest: React.FC = () => { type="tel" variant="outlined" placeholder="e.g. 6197123276" - {...register("phoneNumber", { - required: "Phone Number is required", - pattern: { - value: /^\d{10}$/, - message: "This field must be a 10 digit number", - }, - })} + {...register( + "phoneNumber", + vsrInputFieldValidators.phoneNumber as RegisterOptions< + IVSRFormInput, + "phoneNumber" + >, + )} required error={!!errors.phoneNumber} helperText={errors.phoneNumber?.message} @@ -773,15 +775,39 @@ const VeteranServiceRequest: React.FC = () => { type="email" variant="outlined" placeholder="e.g. justintimberlake@gmail.com" - {...register("email", { - required: "Email Address is required", - })} + {...register( + "email", + vsrInputFieldValidators.email as RegisterOptions, + )} required error={!!errors.email} helperText={errors.email?.message} />
+
+ {isMobile ? null :
} +
+ + emailValidator.validate(emailAddress) + ? emailAddress === watch().email || "Emails do not match" + : "This field must be a valid email address", + }, + })} + required + error={!!errors.confirmEmail} + helperText={errors.confirmEmail?.message} + /> +
+
@@ -794,19 +820,15 @@ const VeteranServiceRequest: React.FC = () => { + } render={({ field }) => ( { - const valueToSet = ((newValue as string[]) ?? [])[0] ?? ""; - if (valueToSet !== "") { - field.onChange(valueToSet); - } - setSelectedBranch(newValue as string[]); - }} + value={field.value} + onChange={field.onChange} required error={!!errors.branch} helperText={errors.branch?.message} @@ -818,7 +840,12 @@ const VeteranServiceRequest: React.FC = () => { + } render={({ field }) => ( <> { + } render={({ field }) => ( { - [true, false].includes(value) || "Service connected is required", - }} + rules={ + vsrInputFieldValidators.serviceConnected as RegisterOptions< + IVSRFormInput, + "serviceConnected" + > + } render={({ field }) => ( { label="Last Rank" variant="outlined" placeholder="Enter" - {...register("lastRank", { required: "Last rank is required" })} + {...register( + "lastRank", + vsrInputFieldValidators.lastRank as RegisterOptions< + IVSRFormInput, + "lastRank" + >, + )} required error={!!errors.lastRank} helperText={errors.lastRank?.message} @@ -913,13 +953,13 @@ const VeteranServiceRequest: React.FC = () => { label="Military ID Number (Last 4)" variant="outlined" placeholder="Enter" - {...register("militaryID", { - required: "Last rank is required", - pattern: { - value: /^\d{4}$/, - message: "This field must be a 4 digit number", - }, - })} + {...register( + "militaryID", + vsrInputFieldValidators.militaryID as RegisterOptions< + IVSRFormInput, + "militaryID" + >, + )} required error={!!errors.militaryID} helperText={errors.militaryID?.message} @@ -937,10 +977,12 @@ const VeteranServiceRequest: React.FC = () => { - [true, false].includes(value) || "Companionship animal is required", - }} + rules={ + vsrInputFieldValidators.petCompanion as RegisterOptions< + IVSRFormInput, + "petCompanion" + > + } render={({ field }) => ( { + } render={({ field }) => ( <> { {renderBottomRow()}
+ {renderErrorModal()}
); } else { return (
- +
Furnishings
- {Object.entries(furnitureCategoriesToItems ?? {}).map(([category, items]) => ( -
-

{category}

-
- {(items ?? []).map((furnitureItem) => ( - + ) : ( + Object.entries(furnitureCategoriesToItems ?? {}).map(([category, items]) => ( +
+

{category}

+
+ {(items ?? []).map((furnitureItem) => ( + - handleSelectionChange(newSelection) - } - /> - ))} + onChangeSelection={(newSelection) => + handleSelectionChange(newSelection) + } + /> + ))} +
-
- ))} + )) + )}
{ required={false} variant={"outlined"} onChange={(e) => setAdditionalItems(e.target.value)} - > + />
@@ -1054,11 +1103,27 @@ const VeteranServiceRequest: React.FC = () => {
- {/* TODO: better error handling */} - {errorMessage} + { + setConfirmSubmissionModalOpen(false); + goToPage(1); + + // Reset all form fields after submission + reset(); + setSelectedEthnicities([]); + setOtherEthnicity(""); + setSelectedConflicts([]); + setOtherConflict(""); + setSelectedHearFrom(""); + setOtherHearFrom(""); + setSelectedFurnitureItems({}); + setAdditionalItems(""); + }} + /> + {renderErrorModal()}
); } }; - export default VeteranServiceRequest; diff --git a/frontend/src/components/Errors/ErrorNotification/index.tsx b/frontend/src/components/Errors/ErrorNotification/index.tsx new file mode 100644 index 0000000..bca7bb5 --- /dev/null +++ b/frontend/src/components/Errors/ErrorNotification/index.tsx @@ -0,0 +1,50 @@ +import styles from "@/components/Errors/ErrorNotification/styles.module.css"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { Portal } from "@mui/material"; +import Image from "next/image"; +import { CSSProperties } from "react"; + +interface ErrorNotificationProps { + isOpen: boolean; + mainText: string; + subText: string; + actionText: string; + onActionClicked: () => unknown; + style?: CSSProperties; +} + +/** + * A component that displays an error notification at the top of the screen + */ +export const ErrorNotification = ({ + isOpen, + mainText, + subText, + actionText, + onActionClicked, + style, +}: ErrorNotificationProps) => { + const { isMobile } = useScreenSizes(); + const iconSize = isMobile ? 36 : 49; + + return isOpen ? ( + +
+ Exclamation Mark +
+

{mainText}

+

{subText}

+
+

+ {actionText} +

+
+
+ ) : null; +}; diff --git a/frontend/src/components/Errors/ErrorNotification/styles.module.css b/frontend/src/components/Errors/ErrorNotification/styles.module.css new file mode 100644 index 0000000..9a002e1 --- /dev/null +++ b/frontend/src/components/Errors/ErrorNotification/styles.module.css @@ -0,0 +1,88 @@ +.root { + position: fixed; + top: 133px; + left: 50%; + transform: translateX(-50%); + width: 707px; + display: flex; + flex-direction: row; + align-items: center; + border: 5px solid #be2d46; + border-radius: 6px; + padding: 24px; + gap: 16px; + background-color: white; +} + +.icon { + margin-bottom: auto; +} + +.textColumn { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.mainText { + color: var(--Secondary-2, #be2d46); + font-family: "Lora"; + font-size: 24px; + font-weight: 700; + width: 100%; +} + +.subText { + color: var(--Primary-Background-Dark, #232220); + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +.actionText { + color: rgba(35, 34, 32, 0.5); + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; + cursor: pointer; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .root { + width: calc(100% - 48px); + } + + .mainText { + font-size: 20px; + } + + .subText { + font-size: 16px; + } + + .actionText { + font-size: 16px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + padding: 12px; + gap: 12px; + } + + .mainText { + font-size: 18px; + } + + .subText { + font-size: 14px; + } + + .actionText { + font-size: 14px; + } +} diff --git a/frontend/src/components/Errors/ErrorPage/index.tsx b/frontend/src/components/Errors/ErrorPage/index.tsx new file mode 100644 index 0000000..af12309 --- /dev/null +++ b/frontend/src/components/Errors/ErrorPage/index.tsx @@ -0,0 +1,25 @@ +import styles from "@/components/Errors/ErrorPage/styles.module.css"; +import { ReactNode } from "react"; + +interface ErrorPageProps { + imageComponent: ReactNode; + title: string; + content: (string | ReactNode)[]; +} + +/** + * A component that renders an error page with a given image, title, and content + */ +export const ErrorPage = ({ imageComponent, title, content }: ErrorPageProps) => { + return ( +
+ {imageComponent} +

{title}

+ {content.map((paragraph, index) => ( +

+ {paragraph} +

+ ))} +
+ ); +}; diff --git a/frontend/src/components/Errors/ErrorPage/styles.module.css b/frontend/src/components/Errors/ErrorPage/styles.module.css new file mode 100644 index 0000000..032e437 --- /dev/null +++ b/frontend/src/components/Errors/ErrorPage/styles.module.css @@ -0,0 +1,60 @@ +.root { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + align-items: center; + justify-content: center; + background: var(--Primary-Background-Light, #f7f7f7); + gap: 16px; +} + +.title { + color: var(--Secondary-2, #be2d46); + text-align: center; + + font-family: "Lora"; + font-size: 40px; + font-weight: 700; + margin: 51px 0 16px; +} + +.content { + color: var(--Secondary-1, #102d5f); + text-align: center; + + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .root { + padding: 0 48px; + } + + .title { + font-size: 36px; + margin: 16px 0 16px; + } + + .content { + font-size: 18px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + padding: 0 24px; + } + + .title { + font-size: 28px; + } + + .content { + font-size: 14px; + } +} diff --git a/frontend/src/components/VSRForm/ConfirmVSRSubmissionModal/index.tsx b/frontend/src/components/VSRForm/ConfirmVSRSubmissionModal/index.tsx new file mode 100644 index 0000000..7612f0c --- /dev/null +++ b/frontend/src/components/VSRForm/ConfirmVSRSubmissionModal/index.tsx @@ -0,0 +1,40 @@ +import { BaseModal } from "@/components/shared/BaseModal"; + +interface ConfirmVSRSubmissionModalProps { + isOpen: boolean; + onClose: () => unknown; +} + +/** + * A modal that displays a confirmation that a VSR was submitted successfully. + */ +export const ConfirmVSRSubmissionModal = ({ isOpen, onClose }: ConfirmVSRSubmissionModalProps) => { + return ( + + A copy of your submitted VSR form has been sent to your email. We'll review it + promptly and respond via email as soon as possible. Allow up to 48 business hours to be + contacted for appointment scheduling. +

+

+ Please check your spam folder if you don't receive a response within 48 business + hours. +

+

+ If you need to make changes to your VSR form, submit another VSR form and let us know at{" "} + + veteran@patriotsandpaws.org. + + + } + bottomRow={null} + /> + ); +}; diff --git a/frontend/src/components/VeteranForm/FurnitureItemSelection/index.tsx b/frontend/src/components/VSRForm/FurnitureItemSelection/index.tsx similarity index 89% rename from frontend/src/components/VeteranForm/FurnitureItemSelection/index.tsx rename to frontend/src/components/VSRForm/FurnitureItemSelection/index.tsx index 4395633..42656bf 100644 --- a/frontend/src/components/VeteranForm/FurnitureItemSelection/index.tsx +++ b/frontend/src/components/VSRForm/FurnitureItemSelection/index.tsx @@ -1,6 +1,6 @@ import { FurnitureItem } from "@/api/FurnitureItems"; import { FurnitureInput } from "@/api/VSRs"; -import styles from "@/components/VeteranForm/FurnitureItemSelection/styles.module.css"; +import styles from "@/components/VSRForm/FurnitureItemSelection/styles.module.css"; import Image from "next/image"; export interface FurnitureItemSelectionProps { @@ -9,6 +9,10 @@ export interface FurnitureItemSelectionProps { onChangeSelection: (newSelection: FurnitureInput) => unknown; } +/** + * An input component that enables a user to select one or more of a single furniture + * item on the VSR form. + */ export const FurnitureItemSelection = ({ furnitureItem, selection, @@ -61,7 +65,7 @@ export const FurnitureItemSelection = ({ alt="dropdown" /> - {selection.quantity} + {selection.quantity} + {imageComponent} +

{title}

+

{content}

+
+ + ); +}; diff --git a/frontend/src/components/VSRForm/VSRErrorModal/styles.module.css b/frontend/src/components/VSRForm/VSRErrorModal/styles.module.css new file mode 100644 index 0000000..8c36932 --- /dev/null +++ b/frontend/src/components/VSRForm/VSRErrorModal/styles.module.css @@ -0,0 +1,87 @@ +.root { + position: absolute; + width: 723px; + background-color: white; + border-radius: 12px; + border: none; + display: flex; + flex-direction: column; + overflow-x: hidden; + overflow-y: hidden; + align-items: center; + justify-content: center; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 64px; + gap: 32px; + border: none !important; + outline: none !important; +} + +.closeButton { + position: absolute; + top: 28px; + right: 28px; + cursor: pointer; + border: none !important; + background: transparent; +} + +.title { + color: var(--Secondary-2, #be2d46); + text-align: center; + font-family: "Lora"; + font-size: 40px; + font-weight: 700; +} + +.content { + color: var(--Secondary-1, #102d5f); + text-align: center; + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +.button { + font-family: "Lora"; + font-size: 24px; + font-weight: 700; + padding: 12px 24px; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .root { + width: calc(100% - 96px); + } + + .title { + font-size: 36px; + } + + .content { + font-size: 18px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + width: calc(100% - 48px); + padding: 32px 16px; + } + + .title { + font-size: 28px; + } + + .content { + font-size: 14px; + } + + .button { + font-size: 18px; + } +} diff --git a/frontend/src/components/VSRForm/VSRFormTypes.ts b/frontend/src/components/VSRForm/VSRFormTypes.ts new file mode 100644 index 0000000..46eb697 --- /dev/null +++ b/frontend/src/components/VSRForm/VSRFormTypes.ts @@ -0,0 +1,48 @@ +/** + * Common types for the VSR form (create & edit) input fields. + */ + +import { FurnitureInput } from "@/api/VSRs"; + +export interface IVSRFormInput { + name: string; + maritalStatus: string; + gender: string; + spouseName: string; + age: number; + ethnicity: string[]; + other_ethnicity: string; + employment_status: string; + income_level: string; + size_of_home: string; + num_boys: number; + num_girls: number; + agesOfBoys: number[]; + agesOfGirls: number[]; + + streetAddress: string; + city: string; + state: string; + zipCode: number; + phoneNumber: string; + email: string; + branch: string[]; + conflicts: string[]; + other_conflicts: string; + dischargeStatus: string; + serviceConnected: boolean; + lastRank: string; + militaryID: number; + petCompanion: boolean; + hearFrom: string; + other_hearFrom: string; + + selectedFurnitureItems: Record; + additionalItems: string; +} + +export interface ICreateVSRFormInput extends IVSRFormInput { + confirmEmail: string; +} + +export type IEditVSRFormInput = IVSRFormInput; diff --git a/frontend/src/components/VSRForm/VSRFormValidators.ts b/frontend/src/components/VSRForm/VSRFormValidators.ts new file mode 100644 index 0000000..65b0e59 --- /dev/null +++ b/frontend/src/components/VSRForm/VSRFormValidators.ts @@ -0,0 +1,75 @@ +import emailValidator from "email-validator"; +import { RegisterOptions } from "react-hook-form"; +import { IVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; + +/** + * Defines common validators for the VSR form inputs. + */ +export const vsrInputFieldValidators: Partial< + Record> +> = { + name: { required: "Name is required" }, + gender: { required: "Gender is required" }, + age: { + required: "Age is required", + pattern: { + // Only allow up to 2 digits + value: /^[0-9]+$/, + message: "This field must be a positive number", + }, + }, + maritalStatus: { required: "Marital status is required" }, + spouseName: { + required: "Spouse's Name is required", + }, + ethnicity: { required: "Ethnicity is required" }, + employment_status: { required: "Employment status is required" }, + income_level: { required: "Income level is required" }, + size_of_home: { required: "Size of home is required" }, + streetAddress: { required: "Street address is required" }, + city: { required: "City is required" }, + state: { required: "State is required" }, + zipCode: { + required: "Zip Code is required", + pattern: { + // Must be 5 digits + value: /^\d{5}$/, + message: "This field must be a 5 digit number", + }, + }, + phoneNumber: { + required: "Phone Number is required", + pattern: { + value: /^\d{10}$/, + message: "This field must be a 10 digit number", + }, + }, + email: { + required: "Email Address is required", + validate: { + validate: (emailAddress) => + emailValidator.validate(emailAddress as string) || + "This field must be a valid email address", + }, + }, + branch: { required: "Military Branch is required" }, + conflicts: { required: "Military Conflicts is required" }, + dischargeStatus: { required: "Discharge status is required" }, + serviceConnected: { + validate: (value) => + [true, false].includes(value as boolean) || "Service connected is required", + }, + lastRank: { required: "Last rank is required" }, + militaryID: { + required: "Last rank is required", + pattern: { + value: /^\d{4}$/, + message: "This field must be a 4 digit number", + }, + }, + petCompanion: { + validate: (value) => + [true, false].includes(value as boolean) || "Companionship animal is required", + }, + hearFrom: { required: "Referral source is required" }, +}; diff --git a/frontend/src/components/VSRIndividual/AdditionalInfo/index.tsx b/frontend/src/components/VSRIndividual/AdditionalInfo/index.tsx deleted file mode 100644 index f0cac31..0000000 --- a/frontend/src/components/VSRIndividual/AdditionalInfo/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import styles from "src/components/VSRIndividual/ContactInfo/styles.module.css"; -import { ListDetail } from "@/components/VSRIndividual"; -import { type VSR } from "@/api/VSRs"; -import { VSRIndividualAccordion } from "../VSRIndividualAccordion"; - -export interface AdditionalInfoProps { - vsr: VSR; -} - -export const AdditionalInfo = ({ vsr }: AdditionalInfoProps) => { - return ( - -
- -
-
- -
-
- ); -}; diff --git a/frontend/src/components/VSRIndividual/ContactInfo/index.tsx b/frontend/src/components/VSRIndividual/ContactInfo/index.tsx deleted file mode 100644 index 9169c4b..0000000 --- a/frontend/src/components/VSRIndividual/ContactInfo/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from "react"; -import styles from "src/components/VSRIndividual/ContactInfo/styles.module.css"; -import { SingleDetail } from "@/components/VSRIndividual"; -import { type VSR } from "@/api/VSRs"; -import { VSRIndividualAccordion } from "../VSRIndividualAccordion"; - -export interface ContactInfoProps { - vsr: VSR; -} -export const ContactInfo = ({ vsr }: ContactInfoProps) => { - return ( - -
- {" "} -
-
- -
-
- - -
-
- - -
-
- - -
-
- ); -}; diff --git a/frontend/src/components/VSRIndividual/ContactInfo/styles.module.css b/frontend/src/components/VSRIndividual/ContactInfo/styles.module.css deleted file mode 100644 index 19d2092..0000000 --- a/frontend/src/components/VSRIndividual/ContactInfo/styles.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.row { - display: flex; - flex-direction: row; - position: relative; -} - -.row:not(:last-child) { - padding-bottom: 32px; -} - -.row > *:not(:last-child) { - width: calc(50% - 16px); -} - -.second { - position: absolute; - left: 50%; - transform: translateX(16px); -} diff --git a/frontend/src/components/VSRIndividual/FieldDetails/BinaryChoiceInputDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/BinaryChoiceInputDetail/index.tsx new file mode 100644 index 0000000..d5e4deb --- /dev/null +++ b/frontend/src/components/VSRIndividual/FieldDetails/BinaryChoiceInputDetail/index.tsx @@ -0,0 +1,42 @@ +import { Controller, UseFormReturn } from "react-hook-form"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import BinaryChoice from "@/components/shared/input/BinaryChoice"; +import { vsrInputFieldValidators } from "@/components/VSRForm/VSRFormValidators"; + +interface BinaryChoiceInputDetailProps { + title: string; + name: keyof IEditVSRFormInput; + formProps: UseFormReturn; +} + +/** + * A component for a binary choice input field on the VSR individual page. + */ +export const BinaryChoiceInputDetail = ({ + title, + name, + formProps, + ...props +}: BinaryChoiceInputDetailProps) => { + return ( + + ( + + )} + /> + + ); +}; diff --git a/frontend/src/components/VSRIndividual/FieldDetails/FieldDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/FieldDetail/index.tsx new file mode 100644 index 0000000..e13a993 --- /dev/null +++ b/frontend/src/components/VSRIndividual/FieldDetails/FieldDetail/index.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react"; +import styles from "@/components/VSRIndividual/FieldDetails/FieldDetail/styles.module.css"; + +interface FieldDetailProps { + children: ReactNode; + title: string; + className?: string; +} + +/** + * A base component for a field detail on the VSR individual page, that renders a label + * above the provided children. + */ +export const FieldDetail = ({ children, title, className }: FieldDetailProps) => { + return ( +
+
{title}
+ {children} +
+ ); +}; diff --git a/frontend/src/components/VSRIndividual/FieldDetails/FieldDetail/styles.module.css b/frontend/src/components/VSRIndividual/FieldDetails/FieldDetail/styles.module.css new file mode 100644 index 0000000..fe3fb59 --- /dev/null +++ b/frontend/src/components/VSRIndividual/FieldDetails/FieldDetail/styles.module.css @@ -0,0 +1,10 @@ +.title { + color: rgba(35, 34, 32, 0.55); + font-family: var(--font-open-sans); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; + margin-bottom: 10px; + white-space: nowrap; +} diff --git a/frontend/src/components/VSRIndividual/ListDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/ListDetail/index.tsx similarity index 54% rename from frontend/src/components/VSRIndividual/ListDetail/index.tsx rename to frontend/src/components/VSRIndividual/FieldDetails/ListDetail/index.tsx index b3a92a6..5c37f43 100644 --- a/frontend/src/components/VSRIndividual/ListDetail/index.tsx +++ b/frontend/src/components/VSRIndividual/FieldDetails/ListDetail/index.tsx @@ -1,11 +1,15 @@ import React from "react"; -import styles from "src/components/VSRIndividual/ListDetail/styles.module.css"; +import styles from "@/components/VSRIndividual/FieldDetails/ListDetail/styles.module.css"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; export interface ListDetailProps { title: string; values: string[]; } +/** + * A component that displays a list of values separated by commas on the VSR individual page. + */ export function ListDetail({ title, values }: ListDetailProps) { const list = (
@@ -18,12 +22,5 @@ export function ListDetail({ title, values }: ListDetailProps) { ); const noList =
N/A
; - return ( -
-
-
{title}
- {values.includes("N/A") ? noList : list} -
-
- ); + return {values.includes("N/A") ? noList : list}; } diff --git a/frontend/src/components/VSRIndividual/ListDetail/styles.module.css b/frontend/src/components/VSRIndividual/FieldDetails/ListDetail/styles.module.css similarity index 100% rename from frontend/src/components/VSRIndividual/ListDetail/styles.module.css rename to frontend/src/components/VSRIndividual/FieldDetails/ListDetail/styles.module.css diff --git a/frontend/src/components/VSRIndividual/FieldDetails/MultipleChoiceInputDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/MultipleChoiceInputDetail/index.tsx new file mode 100644 index 0000000..5204981 --- /dev/null +++ b/frontend/src/components/VSRIndividual/FieldDetails/MultipleChoiceInputDetail/index.tsx @@ -0,0 +1,48 @@ +import { Controller, UseFormReturn } from "react-hook-form"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import MultipleChoice from "@/components/shared/input/MultipleChoice"; +import { vsrInputFieldValidators } from "@/components/VSRForm/VSRFormValidators"; + +interface MultipleChoiceInputDetailProps { + title: string; + name: keyof IEditVSRFormInput; + options: string[]; + allowMultiple: boolean; + formProps: UseFormReturn; +} + +/** + * A component for a multiple choice input field on the VSR individual page. + */ +export const MultipleChoiceInputDetail = ({ + title, + name, + options, + allowMultiple, + formProps, + ...props +}: MultipleChoiceInputDetailProps) => { + return ( + + ( + + )} + /> + + ); +}; diff --git a/frontend/src/components/VSRIndividual/FieldDetails/MultipleChoiceWithOtherInputDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/MultipleChoiceWithOtherInputDetail/index.tsx new file mode 100644 index 0000000..a7e69a4 --- /dev/null +++ b/frontend/src/components/VSRIndividual/FieldDetails/MultipleChoiceWithOtherInputDetail/index.tsx @@ -0,0 +1,84 @@ +import { Controller, UseFormReturn } from "react-hook-form"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import MultipleChoice from "@/components/shared/input/MultipleChoice"; +import TextField from "@/components/shared/input/TextField"; +import { vsrInputFieldValidators } from "@/components/VSRForm/VSRFormValidators"; + +interface MultipleChoiceWithOtherInputDetailProps { + title: string; + name: keyof IEditVSRFormInput; + otherName: keyof IEditVSRFormInput; + options: string[]; + allowMultiple: boolean; + formProps: UseFormReturn; +} + +/** + * + * A component for a multiple choice input field on the VSR individual page, + * that also renders a text field below, for the fields with an "Other" option. + */ +export const MultipleChoiceWithOtherInputDetail = ({ + title, + name, + otherName, + options, + allowMultiple, + formProps, + ...props +}: MultipleChoiceWithOtherInputDetailProps) => { + return ( + + ( + { + field.onChange(value); + if (!allowMultiple && value.length > 0) { + // If user can't enter multiple options, clear the "other" text field when they select an option + formProps.setValue(otherName, ""); + } + }} + required={false} + error={!!formProps.formState.errors[name]} + helperText={formProps.formState.errors[name]?.message as string} + allowMultiple={allowMultiple} + {...props} + /> + )} + /> + ( +
+ { + const value = e.target.value; + field.onChange(value); + if (!allowMultiple && value.length > 0) { + // If user can't enter multiple options, clear the chips field when + // they enter something into the "other" text field + formProps.setValue(name, ""); + } + }} + variant={"outlined"} + required={false} + /> +
+ )} + /> +
+ ); +}; diff --git a/frontend/src/components/VSRIndividual/FieldDetails/SelectInputDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/SelectInputDetail/index.tsx new file mode 100644 index 0000000..e31d892 --- /dev/null +++ b/frontend/src/components/VSRIndividual/FieldDetails/SelectInputDetail/index.tsx @@ -0,0 +1,48 @@ +import { Controller, UseFormReturn } from "react-hook-form"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import Dropdown from "@/components/shared/input/Dropdown"; +import { vsrInputFieldValidators } from "@/components/VSRForm/VSRFormValidators"; + +interface SelectInputDetailProps { + title: string; + name: keyof IEditVSRFormInput; + options: string[]; + placeholder?: string; + formProps: UseFormReturn; +} + +/** + * A component for a dropdown input detail field on the VSR individual pag.e + */ +export const SelectInputDetail = ({ + title, + name, + options, + placeholder, + formProps, + ...props +}: SelectInputDetailProps) => { + return ( + + ( + + )} + /> + + ); +}; diff --git a/frontend/src/components/VSRIndividual/SingleDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/SingleDetail/index.tsx similarity index 58% rename from frontend/src/components/VSRIndividual/SingleDetail/index.tsx rename to frontend/src/components/VSRIndividual/FieldDetails/SingleDetail/index.tsx index 38bb134..bee47f4 100644 --- a/frontend/src/components/VSRIndividual/SingleDetail/index.tsx +++ b/frontend/src/components/VSRIndividual/FieldDetails/SingleDetail/index.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from "react"; -import styles from "src/components/VSRIndividual/SingleDetail/styles.module.css"; +import styles from "@/components/VSRIndividual/FieldDetails/SingleDetail/styles.module.css"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; export interface SingleDetailProps { title: string; @@ -8,6 +9,9 @@ export interface SingleDetailProps { className?: string; } +/** + * A component for a single (non-list) field on the VSR individual page. + */ export function SingleDetail({ title, value, valueFontSize, className }: SingleDetailProps) { const valueStyle = { fontSize: valueFontSize, // Use the passed font size or default to CSS class @@ -28,15 +32,12 @@ export function SingleDetail({ title, value, valueFontSize, className }: SingleD const noValue =
N/A
; return ( -
-
-
{title}
- {typeof value === "string" && value.includes("@") - ? email - : typeof value === "string" && value.includes("N/A") - ? noValue - : basic} -
-
+ + {typeof value === "string" && value.includes("@") + ? email + : typeof value === "string" && value.includes("N/A") + ? noValue + : basic} + ); } diff --git a/frontend/src/components/VSRIndividual/SingleDetail/styles.module.css b/frontend/src/components/VSRIndividual/FieldDetails/SingleDetail/styles.module.css similarity index 100% rename from frontend/src/components/VSRIndividual/SingleDetail/styles.module.css rename to frontend/src/components/VSRIndividual/FieldDetails/SingleDetail/styles.module.css diff --git a/frontend/src/components/VSRIndividual/FieldDetails/TextInputDetail/index.tsx b/frontend/src/components/VSRIndividual/FieldDetails/TextInputDetail/index.tsx new file mode 100644 index 0000000..3811f7f --- /dev/null +++ b/frontend/src/components/VSRIndividual/FieldDetails/TextInputDetail/index.tsx @@ -0,0 +1,47 @@ +import { Controller, UseFormReturn } from "react-hook-form"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import TextField from "@/components/shared/input/TextField"; +import { vsrInputFieldValidators } from "@/components/VSRForm/VSRFormValidators"; + +interface TextInputDetailProps { + title: string; + name: keyof IEditVSRFormInput; + placeholder?: string; + formProps: UseFormReturn; + type?: string; +} + +/** + * A component for a text input detail field on the VSR individual page. + */ +export const TextInputDetail = ({ + title, + name, + placeholder, + formProps, + ...props +}: TextInputDetailProps) => { + return ( + + ( + + )} + /> + + ); +}; diff --git a/frontend/src/components/VSRIndividual/HeaderBar/styles.module.css b/frontend/src/components/VSRIndividual/HeaderBar/HeaderBar.module.css similarity index 100% rename from frontend/src/components/VSRIndividual/HeaderBar/styles.module.css rename to frontend/src/components/VSRIndividual/HeaderBar/HeaderBar.module.css diff --git a/frontend/src/components/VSRIndividual/HeaderBar/index.tsx b/frontend/src/components/VSRIndividual/HeaderBar/HeaderBar.tsx similarity index 85% rename from frontend/src/components/VSRIndividual/HeaderBar/index.tsx rename to frontend/src/components/VSRIndividual/HeaderBar/HeaderBar.tsx index 266dc8e..6f914ab 100644 --- a/frontend/src/components/VSRIndividual/HeaderBar/index.tsx +++ b/frontend/src/components/VSRIndividual/HeaderBar/HeaderBar.tsx @@ -1,5 +1,5 @@ import React from "react"; -import styles from "src/components/VSRIndividual/HeaderBar/styles.module.css"; +import styles from "@/components/VSRIndividual/HeaderBar/HeaderBar.module.css"; import Image from "next/image"; export const HeaderBar = () => { diff --git a/frontend/src/components/VSRIndividual/MilitaryBackground/index.tsx b/frontend/src/components/VSRIndividual/MilitaryBackground/index.tsx deleted file mode 100644 index abd6d5e..0000000 --- a/frontend/src/components/VSRIndividual/MilitaryBackground/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import styles from "@/components/VSRIndividual/ContactInfo/styles.module.css"; -import { SingleDetail, ListDetail } from "@/components/VSRIndividual"; -import { type VSR } from "@/api/VSRs"; -import { VSRIndividualAccordion } from "../VSRIndividualAccordion"; - -export interface MilitaryBackgroundProps { - vsr: VSR; -} - -export const MilitaryBackground = ({ vsr }: MilitaryBackgroundProps) => { - return ( - -
- 0 ? vsr.branch : ["N/A"]} - /> -
-
- 0 ? vsr.conflicts : ["N/A"]} - /> -
-
- 0 ? [vsr.dischargeStatus] : ["N/A"] - } - /> -
-
- -
-
- - -
-
- ); -}; diff --git a/frontend/src/components/VSRIndividual/Page/index.tsx b/frontend/src/components/VSRIndividual/Page/index.tsx deleted file mode 100644 index d50aed5..0000000 --- a/frontend/src/components/VSRIndividual/Page/index.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { - HeaderBar, - VeteranTag, - ContactInfo, - CaseDetails, - PersonalInformation, - MilitaryBackground, - AdditionalInfo, - RequestedFurnishings, -} from "@/components/VSRIndividual"; -import styles from "src/components/VSRIndividual/Page/styles.module.css"; -import Image from "next/image"; -import { type VSR, getVSR, updateVSRStatus } from "@/api/VSRs"; -import { useParams } from "next/navigation"; -import { FurnitureItem, getFurnitureItems } from "@/api/FurnitureItems"; -import { useScreenSizes } from "@/util/useScreenSizes"; - -export const Page = () => { - const [vsr, setVSR] = useState({} as VSR); - const { id } = useParams(); - const [errorMessage, setErrorMessage] = useState(null); - const [furnitureItems, setFurnitureItems] = useState(); - - const { isMobile, isTablet } = useScreenSizes(); - const iconSize = isMobile ? 16 : isTablet ? 19 : 24; - - const renderApproveButton = () => - vsr.status == "Received" || vsr.status === undefined ? ( - - ) : null; - - const renderActions = () => ( - - ); - - useEffect(() => { - getVSR(id as string) - .then((result) => { - if (result.success) { - setVSR(result.data); - setErrorMessage(null); - } else { - setErrorMessage("VSR not found."); - } - }) - .catch((error) => { - setErrorMessage(`An error occurred: ${error.message}`); - }); - }, [id]); - - // Fetch all available furniture items from database - useEffect(() => { - getFurnitureItems() - .then((result) => { - if (result.success) { - setFurnitureItems(result.data); - - setErrorMessage(null); - } else { - setErrorMessage("Furniture items not found."); - } - }) - .catch((error) => { - setErrorMessage(`An error occurred: ${error.message}`); - }); - }, []); - - return ( - <> - -
-
- - - - {isMobile ? renderActions() : null} -
-
-
-
- {errorMessage &&
{errorMessage}
} - - -
- {isMobile ? null : renderActions()} -
-
- -
- {isTablet ? renderApproveButton() : null} -
- - - - -
-
- - {isTablet ? null : ( -
{renderApproveButton()}
- )} -
-
-
-
-
-
- - ); -}; diff --git a/frontend/src/components/VSRIndividual/PageSections/AdditionalInfo/index.tsx b/frontend/src/components/VSRIndividual/PageSections/AdditionalInfo/index.tsx new file mode 100644 index 0000000..1e5785d --- /dev/null +++ b/frontend/src/components/VSRIndividual/PageSections/AdditionalInfo/index.tsx @@ -0,0 +1,61 @@ +import React, { useEffect } from "react"; +import styles from "@/components/VSRIndividual/PageSections/AdditionalInfo/styles.module.css"; +import { ListDetail } from "@/components/VSRIndividual"; +import { type VSR } from "@/api/VSRs"; +import { VSRIndividualAccordion } from "@/components/VSRIndividual/VSRIndividualAccordion"; +import { BinaryChoiceInputDetail } from "@/components/VSRIndividual/FieldDetails/BinaryChoiceInputDetail"; +import { UseFormReturn } from "react-hook-form"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { MultipleChoiceWithOtherInputDetail } from "@/components/VSRIndividual/FieldDetails/MultipleChoiceWithOtherInputDetail"; +import { hearFromOptions } from "@/constants/fieldOptions"; + +export interface AdditionalInfoProps { + vsr: VSR; + isEditing: boolean; + formProps: UseFormReturn; +} + +/** + * The "Additional Information" section of the VSR individual page. + */ +export const AdditionalInfo = ({ vsr, isEditing, formProps }: AdditionalInfoProps) => { + useEffect(() => { + formProps.setValue("petCompanion", vsr.petCompanion); + const isHearFromChip = hearFromOptions.includes(vsr.hearFrom); + formProps.setValue("hearFrom", isHearFromChip ? vsr.hearFrom : ""); + formProps.setValue("other_hearFrom", isHearFromChip ? "" : vsr.hearFrom); + }, [vsr]); + + return ( + +
+ {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/VSRIndividual/AdditionalInfo/styles.module.css b/frontend/src/components/VSRIndividual/PageSections/AdditionalInfo/styles.module.css similarity index 100% rename from frontend/src/components/VSRIndividual/AdditionalInfo/styles.module.css rename to frontend/src/components/VSRIndividual/PageSections/AdditionalInfo/styles.module.css diff --git a/frontend/src/components/VSRIndividual/CaseDetails/index.tsx b/frontend/src/components/VSRIndividual/PageSections/CaseDetails/index.tsx similarity index 58% rename from frontend/src/components/VSRIndividual/CaseDetails/index.tsx rename to frontend/src/components/VSRIndividual/PageSections/CaseDetails/index.tsx index 650a4bc..cd1521b 100644 --- a/frontend/src/components/VSRIndividual/CaseDetails/index.tsx +++ b/frontend/src/components/VSRIndividual/PageSections/CaseDetails/index.tsx @@ -1,15 +1,18 @@ -import styles from "src/components/VSRIndividual/CaseDetails/styles.module.css"; -import { SingleDetail, StatusDropdown } from "@/components/VSRIndividual"; -import { updateVSRStatus, type VSR } from "@/api/VSRs"; +import styles from "@/components/VSRIndividual/PageSections/CaseDetails/styles.module.css"; +import { SingleDetail } from "@/components/VSRIndividual"; +import { type VSR } from "@/api/VSRs"; import moment from "moment"; import { VSRIndividualAccordion } from "@/components/VSRIndividual/VSRIndividualAccordion"; -import { STATUS_OPTIONS } from "@/components/shared/StatusDropdown"; +import { STATUS_OPTIONS, StatusDropdown } from "@/components/shared/StatusDropdown"; import { StatusChip } from "@/components/shared/StatusChip"; -import { useScreenSizes } from "@/util/useScreenSizes"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { CircularProgress } from "@mui/material"; export interface CaseDetailsProp { vsr: VSR; - onUpdateVSR: (status: VSR) => void; + isEditing: boolean; + loadingStatus: boolean; + onUpdateVSRStatus: (status: string) => void; } /** @@ -21,10 +24,22 @@ const formatDate = (date: Date) => { return `${dateMoment.format("MM-DD-YYYY")} [${dateMoment.format("hh:mm A")}]`; }; -export const CaseDetails = ({ vsr, onUpdateVSR }: CaseDetailsProp) => { +/** + * The "Case Details" section of the VSR individual page. + */ +export const CaseDetails = ({ + vsr, + isEditing, + loadingStatus, + onUpdateVSRStatus, +}: CaseDetailsProp) => { const { isMobile, isTablet } = useScreenSizes(); const renderStatus = () => { + if (loadingStatus) { + return ; + } + if (vsr.status === "Received" || vsr.status === undefined) { return ( { } return ( { - const res = await updateVSRStatus(vsr._id, status); - - // TODO: error handling - - onUpdateVSR(res.success ? res.data : vsr); - }} + onChanged={onUpdateVSRStatus} value={vsr.status != undefined ? vsr.status : "Received"} /> ); @@ -49,7 +58,11 @@ export const CaseDetails = ({ vsr, onUpdateVSR }: CaseDetailsProp) => { const valueFontSize = isMobile ? 14 : isTablet ? 18 : 20; return ( - +
{ className={styles.singleDetail} /> - + {renderStatus()}
} + className={styles.singleDetail} + />
); diff --git a/frontend/src/components/VSRIndividual/CaseDetails/styles.module.css b/frontend/src/components/VSRIndividual/PageSections/CaseDetails/styles.module.css similarity index 70% rename from frontend/src/components/VSRIndividual/CaseDetails/styles.module.css rename to frontend/src/components/VSRIndividual/PageSections/CaseDetails/styles.module.css index d0e9067..1e6a517 100644 --- a/frontend/src/components/VSRIndividual/CaseDetails/styles.module.css +++ b/frontend/src/components/VSRIndividual/PageSections/CaseDetails/styles.module.css @@ -9,7 +9,19 @@ overflow: auto; } -.singleDetail { +.blurred { + opacity: 0.5; +} + +.statusWrapper { + min-width: 230px; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .statusWrapper { + min-width: 190px; + } } /* mobile version */ diff --git a/frontend/src/components/VSRIndividual/PageSections/ContactInfo/index.tsx b/frontend/src/components/VSRIndividual/PageSections/ContactInfo/index.tsx new file mode 100644 index 0000000..f947153 --- /dev/null +++ b/frontend/src/components/VSRIndividual/PageSections/ContactInfo/index.tsx @@ -0,0 +1,131 @@ +import React, { useEffect } from "react"; +import styles from "@/components/VSRIndividual/PageSections/ContactInfo/styles.module.css"; +import { SingleDetail } from "@/components/VSRIndividual"; +import { type VSR } from "@/api/VSRs"; +import { VSRIndividualAccordion } from "@/components/VSRIndividual/VSRIndividualAccordion"; +import { TextInputDetail } from "@/components/VSRIndividual/FieldDetails/TextInputDetail"; +import { UseFormReturn } from "react-hook-form"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { SelectInputDetail } from "@/components/VSRIndividual/FieldDetails/SelectInputDetail"; +import { genderOptions, stateOptions } from "@/constants/fieldOptions"; + +export interface ContactInfoProps { + vsr: VSR; + isEditing: boolean; + formProps: UseFormReturn; +} +/** + * The "Contact Information" section of the VSR individual page. + */ +export const ContactInfo = ({ vsr, isEditing, formProps }: ContactInfoProps) => { + useEffect(() => { + formProps.setValue("phoneNumber", vsr.phoneNumber); + formProps.setValue("email", vsr.email); + formProps.setValue("gender", vsr.gender); + formProps.setValue("age", vsr.age); + formProps.setValue("streetAddress", vsr.streetAddress); + formProps.setValue("city", vsr.city); + formProps.setValue("zipCode", vsr.zipCode); + formProps.setValue("state", vsr.state); + }, [vsr]); + + return ( + +
+ {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} + {isEditing ? ( + + ) : ( + + )} +
+ {isEditing ? ( + <> +
+ +
+
+ +
+ + ) : ( +
+ + +
+ )} +
+ {isEditing ? ( + + ) : ( + + )} + {isEditing ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/VSRIndividual/PageSections/ContactInfo/styles.module.css b/frontend/src/components/VSRIndividual/PageSections/ContactInfo/styles.module.css new file mode 100644 index 0000000..57e3ec4 --- /dev/null +++ b/frontend/src/components/VSRIndividual/PageSections/ContactInfo/styles.module.css @@ -0,0 +1,20 @@ +.row { + display: flex; + flex-direction: row; + position: relative; + gap: 32px; +} + +.row:not(:last-child) { + padding-bottom: 32px; +} + +.row > * { + flex: 1; +} + +@media screen and (max-width: 400px) { + .row { + flex-direction: column; + } +} diff --git a/frontend/src/components/VSRIndividual/PageSections/MilitaryBackground/index.tsx b/frontend/src/components/VSRIndividual/PageSections/MilitaryBackground/index.tsx new file mode 100644 index 0000000..0872fab --- /dev/null +++ b/frontend/src/components/VSRIndividual/PageSections/MilitaryBackground/index.tsx @@ -0,0 +1,132 @@ +import React, { useEffect } from "react"; +import styles from "@/components/VSRIndividual/PageSections/MilitaryBackground/styles.module.css"; +import { SingleDetail, ListDetail } from "@/components/VSRIndividual"; +import { type VSR } from "@/api/VSRs"; +import { VSRIndividualAccordion } from "@/components/VSRIndividual/VSRIndividualAccordion"; +import { UseFormReturn } from "react-hook-form"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { branchOptions, conflictsOptions, dischargeStatusOptions } from "@/constants/fieldOptions"; +import { MultipleChoiceInputDetail } from "@/components/VSRIndividual/FieldDetails/MultipleChoiceInputDetail"; +import { MultipleChoiceWithOtherInputDetail } from "@/components/VSRIndividual/FieldDetails/MultipleChoiceWithOtherInputDetail"; +import { BinaryChoiceInputDetail } from "@/components/VSRIndividual/FieldDetails/BinaryChoiceInputDetail"; +import { TextInputDetail } from "@/components/VSRIndividual/FieldDetails/TextInputDetail"; + +export interface MilitaryBackgroundProps { + vsr: VSR; + isEditing: boolean; + formProps: UseFormReturn; +} + +/** + * The "Military Background" section of the VSR individual page. + */ +export const MilitaryBackground = ({ vsr, isEditing, formProps }: MilitaryBackgroundProps) => { + useEffect(() => { + formProps.setValue("branch", vsr.branch); + const conflictChips = []; + formProps.setValue("other_conflicts", ""); + for (const inputtedConflict of vsr.conflicts) { + if (conflictsOptions.includes(inputtedConflict)) { + conflictChips.push(inputtedConflict); + } else { + formProps.setValue("other_conflicts", inputtedConflict); + } + } + formProps.setValue("conflicts", conflictChips); + formProps.setValue("dischargeStatus", vsr.dischargeStatus); + formProps.setValue("serviceConnected", vsr.serviceConnected); + formProps.setValue("lastRank", vsr.lastRank); + formProps.setValue("militaryID", vsr.militaryID); + }, [vsr]); + + return ( + +
+ {isEditing ? ( + + ) : ( + 0 ? vsr.branch : ["N/A"]} + /> + )} +
+
+ {isEditing ? ( + + ) : ( + 0 ? vsr.conflicts : ["N/A"]} + /> + )} +
+
+ {isEditing ? ( + + ) : ( + 0 + ? [vsr.dischargeStatus] + : ["N/A"] + } + /> + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} + {isEditing ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/VSRIndividual/MilitaryBackground/styles.module.css b/frontend/src/components/VSRIndividual/PageSections/MilitaryBackground/styles.module.css similarity index 100% rename from frontend/src/components/VSRIndividual/MilitaryBackground/styles.module.css rename to frontend/src/components/VSRIndividual/PageSections/MilitaryBackground/styles.module.css diff --git a/frontend/src/components/VSRIndividual/PageSections/PersonalInformation/index.tsx b/frontend/src/components/VSRIndividual/PageSections/PersonalInformation/index.tsx new file mode 100644 index 0000000..4d4981e --- /dev/null +++ b/frontend/src/components/VSRIndividual/PageSections/PersonalInformation/index.tsx @@ -0,0 +1,242 @@ +import React, { useEffect } from "react"; +import styles from "@/components/VSRIndividual/PageSections/PersonalInformation/styles.module.css"; +import { SingleDetail, ListDetail } from "@/components/VSRIndividual"; +import { type VSR } from "@/api/VSRs"; +import { VSRIndividualAccordion } from "@/components/VSRIndividual/VSRIndividualAccordion"; +import { UseFormReturn } from "react-hook-form"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { TextInputDetail } from "@/components/VSRIndividual/FieldDetails/TextInputDetail"; +import { SelectInputDetail } from "@/components/VSRIndividual/FieldDetails/SelectInputDetail"; +import { + employmentOptions, + ethnicityOptions, + homeOptions, + incomeOptions, + maritalOptions, + stateOptions, +} from "@/constants/fieldOptions"; +import { MultipleChoiceInputDetail } from "@/components/VSRIndividual/FieldDetails/MultipleChoiceInputDetail"; +import { MultipleChoiceWithOtherInputDetail } from "@/components/VSRIndividual/FieldDetails/MultipleChoiceWithOtherInputDetail"; +import { ChildrenInput } from "@/components/shared/input/ChildrenInput"; + +export interface PersonalInformationProps { + vsr: VSR; + isEditing: boolean; + formProps: UseFormReturn; +} + +/** + * The "Personal Information" section of the VSR individual page. + */ +export const PersonalInformation = ({ vsr, isEditing, formProps }: PersonalInformationProps) => { + useEffect(() => { + formProps.setValue("name", vsr.name); + formProps.setValue("streetAddress", vsr.streetAddress); + formProps.setValue("city", vsr.city); + formProps.setValue("zipCode", vsr.zipCode); + formProps.setValue("state", vsr.state); + formProps.setValue("maritalStatus", vsr.maritalStatus); + formProps.setValue("spouseName", vsr.spouseName ?? ""); + formProps.setValue("num_boys", vsr.agesOfBoys.length); + formProps.setValue("num_girls", vsr.agesOfGirls.length); + formProps.setValue("agesOfBoys", vsr.agesOfBoys); + formProps.setValue("agesOfGirls", vsr.agesOfGirls); + formProps.setValue("other_ethnicity", ""); + const ethnicityChips = []; + for (const inputtedEthnicity of vsr.ethnicity) { + if (ethnicityOptions.includes(inputtedEthnicity)) { + ethnicityChips.push(inputtedEthnicity); + } else { + formProps.setValue("other_ethnicity", inputtedEthnicity); + } + } + formProps.setValue("ethnicity", ethnicityChips); + formProps.setValue("employment_status", vsr.employmentStatus); + formProps.setValue("income_level", vsr.incomeLevel); + formProps.setValue("size_of_home", vsr.sizeOfHome); + }, [vsr]); + + return ( + +
+ {isEditing ? ( + + ) : ( + + )} +
+ {isEditing ? ( + <> +
+ +
+
+ +
+ + ) : ( +
+ + +
+ )} +
+ {isEditing ? ( + + ) : ( + + )} + {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+ {formProps.watch().maritalStatus === "Married" ? ( +
+ {isEditing ? ( + + ) : ( + 0 ? vsr.spouseName : "N/A"} + /> + )} +
+ ) : null} +
+ {isEditing ? ( + <> + + + ) : ( + <> + + 0 ? vsr.agesOfBoys.join(", ") : "N/A" + } + /> + + )} +
+
+ {isEditing ? ( + <> + + + ) : ( + <> + + 0 ? vsr.agesOfGirls.join(", ") : "N/A" + } + /> + + )} +
+
+ {isEditing ? ( + + ) : ( + 0 ? vsr.ethnicity : ["N/A"]} + /> + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/components/VSRIndividual/PersonalInformation/styles.module.css b/frontend/src/components/VSRIndividual/PageSections/PersonalInformation/styles.module.css similarity index 51% rename from frontend/src/components/VSRIndividual/PersonalInformation/styles.module.css rename to frontend/src/components/VSRIndividual/PageSections/PersonalInformation/styles.module.css index 1b30a8d..a69a98b 100644 --- a/frontend/src/components/VSRIndividual/PersonalInformation/styles.module.css +++ b/frontend/src/components/VSRIndividual/PageSections/PersonalInformation/styles.module.css @@ -1,13 +1,19 @@ .row { display: flex; flex-direction: row; + gap: 32px; +} + +.row:not(:last-child) { + padding-bottom: 32px; } .row > * { - padding-right: 32px; - width: 60%; + flex: 1; } -.row:not(:last-child) { - padding-bottom: 32px; +@media screen and (max-width: 400px) { + .row { + flex-direction: column; + } } diff --git a/frontend/src/components/VSRIndividual/PageSections/RequestedFurnishings/index.tsx b/frontend/src/components/VSRIndividual/PageSections/RequestedFurnishings/index.tsx new file mode 100644 index 0000000..f08f890 --- /dev/null +++ b/frontend/src/components/VSRIndividual/PageSections/RequestedFurnishings/index.tsx @@ -0,0 +1,151 @@ +import styles from "@/components/VSRIndividual/PageSections/RequestedFurnishings/styles.module.css"; +import { SingleDetail, ListDetail } from "@/components/VSRIndividual"; +import { type VSR } from "@/api/VSRs"; +import { VSRIndividualAccordion } from "@/components/VSRIndividual/VSRIndividualAccordion"; +import { FurnitureItem } from "@/api/FurnitureItems"; +import { useEffect, useMemo } from "react"; +import { Controller, UseFormReturn } from "react-hook-form"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { TextInputDetail } from "@/components/VSRIndividual/FieldDetails/TextInputDetail"; +import { FurnitureItemSelection } from "@/components/VSRForm/FurnitureItemSelection"; +import { FieldDetail } from "@/components/VSRIndividual/FieldDetails/FieldDetail"; + +export interface RequestedFurnishingsProps { + vsr: VSR; + furnitureItems: FurnitureItem[]; + isEditing: boolean; + formProps: UseFormReturn; +} + +/** + * The "Furnishing Requests" section of the VSR individual page. + */ +export const RequestedFurnishings = ({ + vsr, + furnitureItems, + isEditing, + formProps, +}: RequestedFurnishingsProps) => { + useEffect(() => { + formProps.setValue( + "selectedFurnitureItems", + Object.fromEntries( + vsr.selectedFurnitureItems.map((selectedItem) => [ + selectedItem.furnitureItemId, + selectedItem, + ]), + ), + ); + formProps.setValue("additionalItems", vsr.additionalItems); + }, [vsr]); + + const furnitureItemIdsToItems = useMemo( + () => + furnitureItems?.reduce( + (prevMap: Record, curItem) => ({ + ...prevMap, + [curItem._id]: curItem, + }), + {}, + ) ?? {}, + [furnitureItems], + ); + + const furnitureCategoriesToItems = useMemo( + () => + furnitureItems.reduce( + (prevMap: Record, curItem) => ({ + ...prevMap, + [curItem.category]: [...(prevMap[curItem.category] ?? []), curItem], + }), + {}, + ), + [furnitureItems], + ); + + const findSelectedItemsByCategory = (category: string) => { + return Object.values(formProps.watch().selectedFurnitureItems ?? {}).filter( + (selectedItem) => + furnitureItemIdsToItems[selectedItem.furnitureItemId]?.category === category, + ); + }; + + const renderItemsSection = (categoryTitle: string, categoryName: string) => { + const selectedItemsForCategory = findSelectedItemsByCategory(categoryName); + + return isEditing ? ( + ( +
+ +
+ {furnitureCategoriesToItems[categoryName].map((furnitureItem) => ( + + field.onChange({ + ...field.value, + [furnitureItem._id]: newSelection, + }) + } + /> + ))} +
+
+
+ )} + /> + ) : ( +
+ 0 + ? selectedItemsForCategory.map( + (selectedItem) => + `${furnitureItemIdsToItems[selectedItem.furnitureItemId].name} ${ + furnitureItemIdsToItems[selectedItem.furnitureItemId]?.allowMultiple + ? ": " + selectedItem.quantity + : "" + }`, + ) + : ["N/A"] + } + /> +
+ ); + }; + + return ( + + {renderItemsSection("Bedroom:", "bedroom")} + {renderItemsSection("Bathroom:", "bathroom")} + {renderItemsSection("Kitchen:", "kitchen")} + {renderItemsSection("Living Room:", "living room")} + {renderItemsSection("Dining Room:", "dining room")} + {renderItemsSection("Other:", "other")} + +
+ {isEditing ? ( + + ) : ( + 0 ? vsr.additionalItems : "n/a" + } + /> + )} +
+
+ ); +}; diff --git a/frontend/src/components/VSRIndividual/RequestedFurnishings/styles.module.css b/frontend/src/components/VSRIndividual/PageSections/RequestedFurnishings/styles.module.css similarity index 59% rename from frontend/src/components/VSRIndividual/RequestedFurnishings/styles.module.css rename to frontend/src/components/VSRIndividual/PageSections/RequestedFurnishings/styles.module.css index 44c8480..ae72e2e 100644 --- a/frontend/src/components/VSRIndividual/RequestedFurnishings/styles.module.css +++ b/frontend/src/components/VSRIndividual/PageSections/RequestedFurnishings/styles.module.css @@ -7,3 +7,10 @@ .row:not(:last-child) { padding-bottom: 32px; } + +.chipContainer { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 16px; +} diff --git a/frontend/src/components/VSRIndividual/PersonalInformation/index.tsx b/frontend/src/components/VSRIndividual/PersonalInformation/index.tsx deleted file mode 100644 index 86b06d9..0000000 --- a/frontend/src/components/VSRIndividual/PersonalInformation/index.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import styles from "src/components/VSRIndividual/ContactInfo/styles.module.css"; -import { SingleDetail, ListDetail } from "@/components/VSRIndividual"; -import { type VSR } from "@/api/VSRs"; -import { VSRIndividualAccordion } from "../VSRIndividualAccordion"; - -export interface PersonalInformationProps { - vsr: VSR; -} -export const PersonalInformation = ({ vsr }: PersonalInformationProps) => { - return ( - -
- -
-
- -
-
- -
-
- - -
-
- -
-
- 0 ? vsr.spouseName : "N/A"} - /> -
-
- - 0 ? vsr.agesOfBoys.join(", ") : "N/A"} - /> -
-
- - 0 ? vsr.agesOfGirls.join(", ") : "N/A"} - /> -
-
- 0 ? vsr.ethnicity : ["N/A"]} - /> -
-
- -
-
- -
-
- -
-
- ); -}; diff --git a/frontend/src/components/VSRIndividual/RequestedFurnishings/index.tsx b/frontend/src/components/VSRIndividual/RequestedFurnishings/index.tsx deleted file mode 100644 index c1d4bb7..0000000 --- a/frontend/src/components/VSRIndividual/RequestedFurnishings/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import styles from "src/components/VSRIndividual/RequestedFurnishings/styles.module.css"; -import { SingleDetail, ListDetail } from "@/components/VSRIndividual"; -import { type VSR } from "@/api/VSRs"; -import { VSRIndividualAccordion } from "../VSRIndividualAccordion"; -import { FurnitureItem } from "@/api/FurnitureItems"; -import { useMemo } from "react"; - -export interface RequestedFurnishingsProps { - vsr: VSR; - furnitureItems: FurnitureItem[]; -} - -export const RequestedFurnishings = ({ vsr, furnitureItems }: RequestedFurnishingsProps) => { - const furnitureItemIdsToItems = useMemo( - () => - furnitureItems?.reduce( - (prevMap: Record, curItem) => ({ - ...prevMap, - [curItem._id]: curItem, - }), - {}, - ) ?? {}, - [furnitureItems], - ); - - const findSelectedItemsByCategory = (category: string) => { - return vsr.selectedFurnitureItems?.filter( - (selectedItem) => - furnitureItemIdsToItems[selectedItem.furnitureItemId]?.category === category, - ); - }; - - const renderItemsSection = (categoryTitle: string, categoryName: string) => { - const selectedItemsForCategory = findSelectedItemsByCategory(categoryName); - - return ( -
- 0 - ? selectedItemsForCategory.map( - (selectedItem) => - `${furnitureItemIdsToItems[selectedItem.furnitureItemId].name} ${ - furnitureItemIdsToItems[selectedItem.furnitureItemId]?.allowMultiple - ? ": " + selectedItem.quantity - : "" - }`, - ) - : ["N/A"] - } - /> -
- ); - }; - - return ( - - {renderItemsSection("Bedroom:", "bedroom")} - {renderItemsSection("Bathroom:", "bathroom")} - {renderItemsSection("Kitchen:", "kitchen")} - {renderItemsSection("Living Room:", "living room")} - {renderItemsSection("Dining Room:", "dining room")} - {renderItemsSection("Other:", "other")} - -
- 0 ? vsr.additionalItems : "n/a" - } - /> -
-
- ); -}; diff --git a/frontend/src/components/VSRIndividual/VSRIndividualAccordion/index.tsx b/frontend/src/components/VSRIndividual/VSRIndividualAccordion/index.tsx index 1d5872c..2a94a35 100644 --- a/frontend/src/components/VSRIndividual/VSRIndividualAccordion/index.tsx +++ b/frontend/src/components/VSRIndividual/VSRIndividualAccordion/index.tsx @@ -5,35 +5,46 @@ import AccordionSummary from "@mui/material/AccordionSummary"; import AccordionDetails from "@mui/material/AccordionDetails"; import Typography from "@mui/material/Typography"; import Image from "next/image"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; export interface VSRIndividualAccordionProps { title: string; permanentlyExpanded: boolean; + className?: string; children: ReactNode; } +/** + * A component for an accordion for one of the VSR individual pages. Can be either + * permanently expanded, or expand/collapse on click. + */ export const VSRIndividualAccordion = ({ title, permanentlyExpanded, + className, children, }: VSRIndividualAccordionProps) => { const [expanded, setExpanded] = useState(permanentlyExpanded); + const { isMobile, isTablet } = useScreenSizes(); + useEffect(() => { setExpanded(permanentlyExpanded); }, [permanentlyExpanded]); return ( -
+
setExpanded(isExpanded || permanentlyExpanded)} sx={{ + display: "inline-block", + backgroundColor: "hsl(0, 0%, 100%)", + width: "100%", + borderRadius: 6, + boxShadow: "none", + padding: "8px 6px", paddingTop: "6px", - "&.Mui-expanded": { - paddingTop: "0px", - }, }} > - {title} + + {title} +
{children}
diff --git a/frontend/src/components/VSRIndividual/VSRIndividualAccordion/styles.module.css b/frontend/src/components/VSRIndividual/VSRIndividualAccordion/styles.module.css index eb34b9c..1507da9 100644 --- a/frontend/src/components/VSRIndividual/VSRIndividualAccordion/styles.module.css +++ b/frontend/src/components/VSRIndividual/VSRIndividualAccordion/styles.module.css @@ -1,12 +1,3 @@ -.accordion { - display: inline-block; - background-color: hsl(0, 0%, 100%); - width: 100%; - border-radius: 6px; - box-shadow: none; - padding: 8px 6px; -} - .title { font-family: var(--font-title); color: var(--Primary-Background-Dark, #232220); @@ -24,17 +15,3 @@ padding-left: 6px; padding-right: 6px; } - -/* tablet version */ -@media screen and (max-width: 550px) { - .title { - font-size: 28px; - } -} - -/* mobile version */ -@media screen and (max-width: 550px) { - .title { - font-size: 20px; - } -} diff --git a/frontend/src/components/VSRIndividual/VSRIndividualPage/index.tsx b/frontend/src/components/VSRIndividual/VSRIndividualPage/index.tsx new file mode 100644 index 0000000..0753d4f --- /dev/null +++ b/frontend/src/components/VSRIndividual/VSRIndividualPage/index.tsx @@ -0,0 +1,670 @@ +import React, { useContext, useEffect, useState } from "react"; +import { + VeteranTag, + ContactInfo, + CaseDetails, + PersonalInformation, + MilitaryBackground, + AdditionalInfo, + RequestedFurnishings, +} from "@/components/VSRIndividual"; +import styles from "@/components/VSRIndividual/VSRIndividualPage/styles.module.css"; +import Image from "next/image"; +import { type VSR, getVSR, updateVSRStatus, UpdateVSRRequest, updateVSR } from "@/api/VSRs"; +import { useParams, useRouter } from "next/navigation"; +import { FurnitureItem, getFurnitureItems } from "@/api/FurnitureItems"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import HeaderBar from "@/components/shared/HeaderBar"; +import { SuccessNotification } from "@/components/shared/SuccessNotification"; +import { ErrorNotification } from "@/components/Errors/ErrorNotification"; +import { UserContext } from "@/contexts/userContext"; +import { VSRErrorModal } from "@/components/VSRForm/VSRErrorModal"; +import { LoadingScreen } from "@/components/shared/LoadingScreen"; +import { DeleteVSRsModal } from "@/components/shared/DeleteVSRsModal"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { BaseModal } from "@/components/shared/BaseModal"; +import { Button } from "@/components/shared/Button"; + +enum VSRIndividualError { + CANNOT_RETRIEVE_FURNITURE_NO_INTERNET, + CANNOT_RETRIEVE_FURNITURE_INTERNAL, + CANNOT_FETCH_VSR_NO_INTERNET, + CANNOT_FETCH_VSR_NOT_FOUND, + CANNOT_FETCH_VSR_INTERNAL, + NONE, +} + +/** + * The root component for the VSR individual page. + */ +export const VSRIndividualPage = () => { + const [loadingVsr, setLoadingVsr] = useState(true); + const [vsr, setVSR] = useState({} as VSR); + const { id } = useParams(); + const { firebaseUser, papUser } = useContext(UserContext); + const router = useRouter(); + const [loadingFurnitureItems, setLoadingFurnitureItems] = useState(false); + const [furnitureItems, setFurnitureItems] = useState(); + const [pageError, setPageError] = useState(VSRIndividualError.NONE); + + const [isEditing, setIsEditing] = useState(false); + + const formProps = useForm(); + const { handleSubmit } = formProps; + + const [updateStatusSuccessNotificationOpen, setUpdateStatusSuccessNotificationOpen] = + useState(false); + const [updateStatusErrorNotificationOpen, setUpdateStatusErrorNotificationOpen] = useState(false); + const [previousVSRStatus, setPreviousVSRStatus] = useState(null); + const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false); + + const [discardEditsConfirmationModalOpen, setDiscardEditsConfirmationModalOpen] = useState(false); + const [saveEditsConfirmationModalOpen, setSaveEditsConfirmationModalOpen] = useState(false); + const [editSuccessNotificationOpen, setEditSuccessNotificationOpen] = useState(false); + const [editErrorNotificationOpen, setEditErrorNotificationOpen] = useState(false); + const [loadingEdit, setLoadingEdit] = useState(false); + + const [deleteVsrModalOpen, setDeleteVsrModalOpen] = useState(false); + + const { isMobile, isTablet } = useScreenSizes(); + + /** + * Callback triggered when form edits are submitted + */ + const onSubmitEdits: SubmitHandler = async (data) => { + if (loadingEdit) { + return; + } + + setEditSuccessNotificationOpen(false); + setEditErrorNotificationOpen(false); + setLoadingEdit(true); + + const updateVSRRequest: UpdateVSRRequest = { + name: data.name, + gender: data.gender, + age: data.age, + maritalStatus: data.maritalStatus, + spouseName: data.spouseName, + agesOfBoys: + data.agesOfBoys + ?.slice(0, data.num_boys) + .map((age) => (typeof age === "number" ? age : parseInt(age))) ?? [], + agesOfGirls: + data.agesOfGirls + ?.slice(0, data.num_girls) + .map((age) => (typeof age === "number" ? age : parseInt(age))) ?? [], + ethnicity: data.ethnicity.concat(data.other_ethnicity === "" ? [] : [data.other_ethnicity]), + employmentStatus: data.employment_status, + incomeLevel: data.income_level, + sizeOfHome: data.size_of_home, + + streetAddress: data.streetAddress, + city: data.city, + state: data.state, + zipCode: data.zipCode, + phoneNumber: data.phoneNumber, + email: data.email, + branch: data.branch, + conflicts: data.conflicts.concat(data.other_conflicts === "" ? [] : [data.other_conflicts]), + dischargeStatus: data.dischargeStatus, + serviceConnected: data.serviceConnected, + lastRank: data.lastRank, + militaryID: data.militaryID, + petCompanion: data.petCompanion, + hearFrom: data.hearFrom, + + // Only submit items that the user selected at least 1 of + selectedFurnitureItems: Object.values(data.selectedFurnitureItems).filter( + (selectedItem) => selectedItem.quantity > 0, + ), + additionalItems: data.additionalItems, + }; + + const firebaseToken = await firebaseUser?.getIdToken(); + if (!firebaseToken) { + setLoadingEdit(false); + setEditErrorNotificationOpen(true); + return; + } + const response = await updateVSR(vsr._id, updateVSRRequest, firebaseToken); + + // Handle success/error + if (response.success) { + setIsEditing(false); + setVSR(response.data); + setEditSuccessNotificationOpen(true); + } else { + console.error(`Cannot edit VSR, error ${response.error}`); + setEditErrorNotificationOpen(true); + } + setLoadingEdit(false); + }; + + /** + * Callback triggered when the VSR's status is updated to a new value. + */ + const onUpdateVSRStatus = async (newStatus: string) => { + if (loadingUpdateStatus) { + return; + } + + setLoadingUpdateStatus(true); + setUpdateStatusSuccessNotificationOpen(false); + setUpdateStatusErrorNotificationOpen(false); + const currentStatus = vsr.status; + + const firebaseToken = await firebaseUser?.getIdToken(); + if (!firebaseToken) { + setLoadingUpdateStatus(false); + setUpdateStatusErrorNotificationOpen(true); + return; + } + const res = await updateVSRStatus(vsr._id, newStatus, firebaseToken); + + if (res.success) { + setPreviousVSRStatus(currentStatus); + setVSR(res.data); + setUpdateStatusSuccessNotificationOpen(true); + } else { + setUpdateStatusErrorNotificationOpen(true); + } + setLoadingUpdateStatus(false); + }; + + /** + * Callback triggered when the user clicks "Undo" on the success notification + * after updating the VSR's status + */ + const onUndoVSRStatusUpdate = async () => { + if (loadingUpdateStatus) { + return; + } + + setLoadingUpdateStatus(true); + setUpdateStatusSuccessNotificationOpen(false); + setUpdateStatusErrorNotificationOpen(false); + + const firebaseToken = await firebaseUser?.getIdToken(); + if (!firebaseToken) { + setLoadingUpdateStatus(false); + setUpdateStatusErrorNotificationOpen(true); + return; + } + const res = await updateVSRStatus(vsr._id, previousVSRStatus!, firebaseToken); + + if (res.success) { + setPreviousVSRStatus(null); + setVSR(res.data); + setUpdateStatusSuccessNotificationOpen(true); + } else { + setUpdateStatusErrorNotificationOpen(true); + } + setLoadingUpdateStatus(false); + }; + + /** + * Conditionally renders the "Approve" button on the page, if the VSR's status is "Received" + */ + const renderApproveButton = () => + vsr.status == "Received" || vsr.status === undefined ? ( +
+ ); + + /** + * Fetches the current VSR data from the backend and updates our "vsr" state. + */ + const fetchVSR = () => { + if (!firebaseUser) { + return; + } + + setLoadingVsr(true); + firebaseUser?.getIdToken().then((firebaseToken) => { + setPageError(VSRIndividualError.NONE); + getVSR(id as string, firebaseToken).then((result) => { + if (result.success) { + setVSR(result.data); + } else { + if (result.error === "Failed to fetch") { + setPageError(VSRIndividualError.CANNOT_FETCH_VSR_NO_INTERNET); + } else if (result.error.startsWith("404")) { + setPageError(VSRIndividualError.CANNOT_FETCH_VSR_NOT_FOUND); + } else { + console.error(`Error retrieving VSR: ${result.error}`); + setPageError(VSRIndividualError.CANNOT_FETCH_VSR_INTERNAL); + } + } + setLoadingVsr(false); + }); + }); + }; + + /** + * Fetch the VSR from the backend, once we have the VSR id and the Firebase user is loaded + */ + useEffect(() => { + fetchVSR(); + }, [id, firebaseUser]); + + /** + * Fetches the list of available furniture items from the backend and updates + * our "furnitureItems" state + */ + const fetchFurnitureItems = () => { + if (loadingFurnitureItems) { + return; + } + + setLoadingFurnitureItems(true); + getFurnitureItems().then((result) => { + if (result.success) { + setFurnitureItems(result.data); + } else { + if (result.error === "Failed to fetch") { + setPageError(VSRIndividualError.CANNOT_RETRIEVE_FURNITURE_NO_INTERNET); + } else { + console.error(`Error retrieving furniture items: ${result.error}`); + setPageError(VSRIndividualError.CANNOT_RETRIEVE_FURNITURE_INTERNAL); + } + } + setLoadingFurnitureItems(false); + }); + }; + + // Fetch all available furniture items from database when page first loads + useEffect(() => { + fetchFurnitureItems(); + }, []); + + /** + * Renders the error modal corresponding to the current page error, or renders nothing + * if there is no error. + */ + const renderErrorModal = () => { + switch (pageError) { + case VSRIndividualError.CANNOT_RETRIEVE_FURNITURE_NO_INTERNET: + return ( + { + setPageError(VSRIndividualError.NONE); + }} + imageComponent={ + No Internet + } + title="No Internet Connection" + content="Unable to retrieve the available furniture items due to no internet connection. Please check your connection and try again." + buttonText="Try Again" + onButtonClicked={() => { + setPageError(VSRIndividualError.NONE); + fetchFurnitureItems(); + }} + /> + ); + case VSRIndividualError.CANNOT_RETRIEVE_FURNITURE_INTERNAL: + return ( + { + setPageError(VSRIndividualError.NONE); + }} + imageComponent={ + Internal Error + } + title="Internal Error" + content="Something went wrong with retrieving the available furniture items. Our team is working to fix it. Please try again later." + buttonText="Try Again" + onButtonClicked={() => { + setPageError(VSRIndividualError.NONE); + fetchFurnitureItems(); + }} + /> + ); + case VSRIndividualError.CANNOT_FETCH_VSR_NO_INTERNET: + return ( + { + setPageError(VSRIndividualError.NONE); + }} + imageComponent={ + No Internet + } + title="No Internet Connection" + content="Unable to retrieve the VSR data due to no internet connection. Please check your connection and try again." + buttonText="Try Again" + onButtonClicked={() => { + setPageError(VSRIndividualError.NONE); + fetchVSR(); + }} + /> + ); + case VSRIndividualError.CANNOT_FETCH_VSR_NOT_FOUND: + return ( + { + setPageError(VSRIndividualError.NONE); + }} + imageComponent={ + Red X + } + title="VSR Not Found" + content="Sorry, we couldn't find the VSR you're looking for." + buttonText="Back to Dashboard" + onButtonClicked={() => { + router.push("/staff/vsr"); + }} + /> + ); + case VSRIndividualError.CANNOT_FETCH_VSR_INTERNAL: + return ( + { + setPageError(VSRIndividualError.NONE); + }} + imageComponent={ + Internal Error + } + title="Internal Error" + content="Something went wrong with retrieving the VSR. Our team is working to fix it. Please try again later." + buttonText="Try Again" + onButtonClicked={() => { + setPageError(VSRIndividualError.NONE); + fetchVSR(); + }} + /> + ); + default: + return null; + } + }; + + return ( + <> + +
+
+ +
+ {loadingVsr ? ( + + ) : pageError === VSRIndividualError.NONE ? ( +
+
+
+ +
+ {isMobile ? null : renderActions()} +
+
+ +
+ {isTablet ? renderApproveButton() : null} +
+ + + + +
+
+ {loadingFurnitureItems ? ( + + ) : ( + + )} + {isTablet ? null : ( +
{renderApproveButton()}
+ )} +
+
+
+
+
+ ) : null} +
+ + {/* Success, error, and delete modals/notifications */} + {renderErrorModal()} + setUpdateStatusSuccessNotificationOpen(false), + }, + ]} + /> + setUpdateStatusErrorNotificationOpen(false)} + /> + setDeleteVsrModalOpen(false)} + afterDelete={() => { + // Redirect user to dashboard after deleting VSR, but give them some time to see the success message first + setTimeout(() => { + router.push("/staff/vsr"); + }, 1000); + }} + vsrIds={[vsr._id]} + /> + setDiscardEditsConfirmationModalOpen(false)} + title="Discard Changes" + content="Are you sure you want to discard your changes?" + bottomRow={ +
+
+ } + /> + + {/* Modals & notifications for saving changes to VSR */} + setSaveEditsConfirmationModalOpen(false)} + title="Save Changes" + content="Would you like to save your changes?" + bottomRow={ +
+
+ } + /> + setEditSuccessNotificationOpen(false), + }, + ]} + /> + setEditErrorNotificationOpen(false)} + /> + + ); +}; diff --git a/frontend/src/components/VSRIndividual/Page/styles.module.css b/frontend/src/components/VSRIndividual/VSRIndividualPage/styles.module.css similarity index 82% rename from frontend/src/components/VSRIndividual/Page/styles.module.css rename to frontend/src/components/VSRIndividual/VSRIndividualPage/styles.module.css index 303d516..89f660c 100644 --- a/frontend/src/components/VSRIndividual/Page/styles.module.css +++ b/frontend/src/components/VSRIndividual/VSRIndividualPage/styles.module.css @@ -1,6 +1,7 @@ .page { background-color: var(--color-background); padding: 0 120px; + min-height: 100vh; } .toDashboardRow { @@ -43,24 +44,32 @@ flex-direction: row; } -.button { - border-radius: 4px; - border: 1px solid var(--color-tse-accent-blue-1); - background-color: var(--color-tse-accent-blue-1); - padding: 8px 16px; - font-family: var(--font-open-sans); - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; +.modalButton { + font-size: 24px; text-align: center; - color: white; - cursor: pointer; + width: 100%; +} + +.blueOutlinedButton { + background-color: transparent; + border: 1px solid var(--color-tse-accent-blue-1); + color: var(--color-tse-accent-blue-1); +} + +.redOutlinedButton { + background-color: transparent; + border: 1px solid #be2d46; + color: #be2d46; +} + +.redButton { + background-color: #be2d46; +} + +.modalBottomRow { display: flex; flex-direction: row; - gap: 6px; - align-items: center; - white-space: nowrap; + gap: 32px; } #edit:hover { @@ -130,19 +139,11 @@ .approveButton { width: 207px; - border-radius: 4px; - border: 1px solid var(--color-tse-secondary-1); - color: white; text-align: center; font-family: "Lora"; font-size: 18px; - font-style: normal; - font-weight: 400; - line-height: normal; - background: var(--color-tse-secondary-1); align-items: end; padding: 20px 24px; - cursor: pointer; } .footer { @@ -176,8 +177,8 @@ padding: 0 48px; } - .button { - font-size: 14px; + .modalButton { + font-size: 18px; } } @@ -194,4 +195,8 @@ .actions { gap: 16px; } + + .modalButton { + font-size: 14px; + } } diff --git a/frontend/src/components/VSRIndividual/VeteranTag/index.tsx b/frontend/src/components/VSRIndividual/VeteranTag/index.tsx index fa6a3b7..ddb06fa 100644 --- a/frontend/src/components/VSRIndividual/VeteranTag/index.tsx +++ b/frontend/src/components/VSRIndividual/VeteranTag/index.tsx @@ -1,13 +1,17 @@ -import styles from "src/components/VSRIndividual/VeteranTag/styles.module.css"; +import styles from "@/components/VSRIndividual/VeteranTag/styles.module.css"; import { type VSR } from "@/api/VSRs"; export interface AdditionalInfoProps { vsr: VSR; + isEditing: boolean; } -export function VeteranTag({ vsr }: AdditionalInfoProps) { +/** + * A component that displays the veteran's name at the top of the VSR individual page. + */ +export function VeteranTag({ vsr, isEditing }: AdditionalInfoProps) { return ( -
+
{vsr.name && vsr.name.length > 0 ? {vsr.name} : }
); diff --git a/frontend/src/components/VSRIndividual/index.ts b/frontend/src/components/VSRIndividual/index.ts index f17bdd4..6ce85cc 100644 --- a/frontend/src/components/VSRIndividual/index.ts +++ b/frontend/src/components/VSRIndividual/index.ts @@ -1,12 +1,10 @@ -export { HeaderBar } from "./HeaderBar"; -export { Page } from "./Page"; +export { VSRIndividualPage } from "./VSRIndividualPage"; export { VeteranTag } from "./VeteranTag"; -export { CaseDetails } from "./CaseDetails"; -export { ContactInfo } from "./ContactInfo"; -export { PersonalInformation } from "./PersonalInformation"; -export { MilitaryBackground } from "./MilitaryBackground"; -export { AdditionalInfo } from "./AdditionalInfo"; -export { RequestedFurnishings } from "./RequestedFurnishings"; -export { SingleDetail } from "./SingleDetail"; -export { StatusDropdown } from "../shared/StatusDropdown"; -export { ListDetail } from "./ListDetail"; +export { CaseDetails } from "./PageSections/CaseDetails"; +export { ContactInfo } from "./PageSections/ContactInfo"; +export { PersonalInformation } from "./PageSections/PersonalInformation"; +export { MilitaryBackground } from "./PageSections/MilitaryBackground"; +export { AdditionalInfo } from "./PageSections/AdditionalInfo"; +export { RequestedFurnishings } from "./PageSections/RequestedFurnishings"; +export { SingleDetail } from "./FieldDetails/SingleDetail"; +export { ListDetail } from "./FieldDetails/ListDetail"; diff --git a/frontend/src/components/VSRTable/PageTitle/index.tsx b/frontend/src/components/VSRTable/PageTitle/index.tsx index f52bbde..0bdfbcf 100644 --- a/frontend/src/components/VSRTable/PageTitle/index.tsx +++ b/frontend/src/components/VSRTable/PageTitle/index.tsx @@ -1,6 +1,9 @@ import React from "react"; import styles from "@/components/VSRTable/PageTitle/styles.module.css"; +/** + * A component for the title at the top of the VSR table page. + */ export default function PageTitle() { return Service Requests; } diff --git a/frontend/src/components/VSRTable/SearchKeyword/index.tsx b/frontend/src/components/VSRTable/SearchKeyword/index.tsx index 96c50cd..cf92950 100644 --- a/frontend/src/components/VSRTable/SearchKeyword/index.tsx +++ b/frontend/src/components/VSRTable/SearchKeyword/index.tsx @@ -2,6 +2,9 @@ import styles from "@/components/VSRTable/SearchKeyword/styles.module.css"; import Image from "next/image"; import * as React from "react"; +/** + * A component for the Search input above the VSR table. + */ export default function SearchKeyword() { return (
diff --git a/frontend/src/components/VSRTable/VSRTable/index.tsx b/frontend/src/components/VSRTable/VSRTable/index.tsx index b51fe37..c520edd 100644 --- a/frontend/src/components/VSRTable/VSRTable/index.tsx +++ b/frontend/src/components/VSRTable/VSRTable/index.tsx @@ -2,28 +2,35 @@ import * as React from "react"; import Box from "@mui/material/Box"; -import { GRID_CHECKBOX_SELECTION_COL_DEF } from "@mui/x-data-grid"; +import { GRID_CHECKBOX_SELECTION_COL_DEF, GridRowSelectionModel } from "@mui/x-data-grid"; import { DataGrid, GridColDef } from "@mui/x-data-grid"; -import { useEffect } from "react"; -import { VSR, getAllVSRs } from "@/api/VSRs"; import moment from "moment"; import { useRouter } from "next/navigation"; -import { StatusChip } from "@/components/shared/StatusChip"; import { STATUS_OPTIONS } from "@/components/shared/StatusDropdown"; -import { useScreenSizes } from "@/util/useScreenSizes"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { VSR } from "@/api/VSRs"; +import { StatusChip } from "@/components/shared/StatusChip"; const formatDateReceived = (dateReceived: Date) => { // Return the empty string on a falsy date received, instead of defaulting to today's date return dateReceived ? moment(dateReceived).format("MMMM D, YYYY") : ""; }; -export default function VSRTable() { - const [vsrs, setVsrs] = React.useState(); - const router = useRouter(); +interface VSRTableProps { + vsrs: VSR[]; + selectedVsrIds: string[]; + onChangeSelectedVsrIds: (newIds: string[]) => unknown; +} +/** + * A component for the table itself on the VSR table page. + */ +export default function VSRTable({ vsrs, selectedVsrIds, onChangeSelectedVsrIds }: VSRTableProps) { const { isMobile, isTablet } = useScreenSizes(); + const router = useRouter(); + // Define the columns to show in the table (some columns are hidden on smaller screens) const columns: GridColDef[] = React.useMemo(() => { const result = [ { @@ -34,20 +41,6 @@ export default function VSRTable() { ]; if (!isMobile) { - result.push({ - field: "_id", - headerName: "Case ID", - type: "string", - flex: 1, - headerAlign: "left", - headerClassName: "header", - disableColumnMenu: true, - hideSortIcons: true, - width: 100, - }); - } - - if (!isTablet) { result.push({ field: "militaryID", headerName: "Military ID (Last 4)", @@ -107,17 +100,6 @@ export default function VSRTable() { return result; }, [isMobile, isTablet]); - useEffect(() => { - getAllVSRs().then((result) => { - if (result.success) { - setVsrs(result.data); - } else { - // TODO better error handling - alert(`Could not fetch VSRs: ${result.error}`); - } - }); - }, []); - return ( + onChangeSelectedVsrIds(vsrIds as string[]) + } /> ); diff --git a/frontend/src/components/shared/BaseModal/index.tsx b/frontend/src/components/shared/BaseModal/index.tsx new file mode 100644 index 0000000..b72dc46 --- /dev/null +++ b/frontend/src/components/shared/BaseModal/index.tsx @@ -0,0 +1,32 @@ +import styles from "@/components/shared/BaseModal/styles.module.css"; +import { Modal } from "@mui/material"; +import Image from "next/image"; +import { ReactElement } from "react"; + +interface BaseModalProps { + isOpen: boolean; + onClose: () => unknown; + title: string; + content: string | ReactElement; + bottomRow: ReactElement | null; +} + +/** + * A base component for a modal with a title, content, and a bottom row. + */ +export const BaseModal = ({ isOpen, onClose, title, content, bottomRow }: BaseModalProps) => { + return ( + <> + +
+ +

{title}

+

{content}

+
{bottomRow}
+
+
+ + ); +}; diff --git a/frontend/src/components/shared/BaseModal/styles.module.css b/frontend/src/components/shared/BaseModal/styles.module.css new file mode 100644 index 0000000..ea52672 --- /dev/null +++ b/frontend/src/components/shared/BaseModal/styles.module.css @@ -0,0 +1,80 @@ +.root { + position: absolute; + width: 723px; + background-color: white; + border-radius: 12px; + border: none; + display: flex; + flex-direction: column; + overflow-x: hidden; + overflow-y: hidden; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 64px; + gap: 16px; + border: none !important; + outline: none !important; +} + +.closeButton { + position: absolute; + top: 28px; + right: 28px; + cursor: pointer; + border: none !important; + background: transparent; +} + +.title { + color: var(--Accent-Blue-2, #04183b); + font-family: "Lora"; + font-size: 40px; + font-weight: 700; +} + +.content { + color: #000; + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; +} + +.bottomRowContainer { + margin-top: 48px; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .root { + width: calc(100% - 96px); + } + + .title { + font-size: 36px; + } + + .content { + font-size: 18px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + width: calc(100% - 48px); + padding: 64px 24px; + } + + .title { + font-size: 28px; + } + + .content { + font-size: 14px; + } + + .bottomRowContainer { + margin-top: 16px; + } +} diff --git a/frontend/src/components/shared/Button/index.tsx b/frontend/src/components/shared/Button/index.tsx new file mode 100644 index 0000000..f207218 --- /dev/null +++ b/frontend/src/components/shared/Button/index.tsx @@ -0,0 +1,72 @@ +import styles from "@/components/shared/Button/styles.module.css"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { CircularProgress } from "@mui/material"; +import Image from "next/image"; +import { ButtonHTMLAttributes } from "react"; + +interface ButtonProps extends ButtonHTMLAttributes { + // Variant of the button (primary = dark blue, error = red) + variant: "primary" | "error"; + + // Whether the button should be outlined instead of filled + outlined: boolean; + + // Optional: path to icon image to display on left side of button + iconPath?: string; + + // Optional: alt for icon image + iconAlt?: string; + + // Optional: Text to display inside the button + text?: string; + + // Whether the button is loading (in which case it is disabled and a spinner is rendered) + loading?: boolean; + + // Whether to hide the button's text on mobile screens + hideTextOnMobile?: boolean; +} + +/** + * A reusable button component, styled to our application. + */ +export const Button = ({ + variant, + outlined, + iconPath, + iconAlt, + text, + loading, + hideTextOnMobile = false, + ...props +}: ButtonProps) => { + const { isMobile, isTablet } = useScreenSizes(); + const iconSize = isMobile ? 16 : isTablet ? 19 : 24; + + const mainColor = variant === "primary" ? "var(--color-tse-accent-blue-1)" : "#be2d46"; + + return ( + + ); +}; diff --git a/frontend/src/components/shared/Button/styles.module.css b/frontend/src/components/shared/Button/styles.module.css new file mode 100644 index 0000000..ab53931 --- /dev/null +++ b/frontend/src/components/shared/Button/styles.module.css @@ -0,0 +1,23 @@ +.button { + border-radius: 4px; + padding: 8px 16px; + font-family: var(--font-open-sans); + font-size: 16px; + font-weight: 400; + text-align: center; + cursor: pointer; + display: flex; + flex-direction: row; + gap: 6px; + align-items: center; + justify-content: center; + white-space: nowrap; + outline: none !important; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .button { + font-size: 14px; + } +} diff --git a/frontend/src/components/shared/DeleteVSRsModal/index.tsx b/frontend/src/components/shared/DeleteVSRsModal/index.tsx new file mode 100644 index 0000000..c595773 --- /dev/null +++ b/frontend/src/components/shared/DeleteVSRsModal/index.tsx @@ -0,0 +1,116 @@ +import { deleteVSR } from "@/api/VSRs"; +import styles from "@/components/shared/DeleteVSRsModal/styles.module.css"; +import { UserContext } from "@/contexts/userContext"; +import { useContext, useState } from "react"; +import { SuccessNotification } from "@/components/shared/SuccessNotification"; +import { ErrorNotification } from "@/components/Errors/ErrorNotification"; +import { BaseModal } from "@/components/shared/BaseModal"; +import { Button } from "@/components/shared/Button"; + +interface DeleteVSRsModalProps { + isOpen: boolean; + onClose: () => unknown; + afterDelete: () => unknown; + vsrIds: string[]; +} + +/** + * A modal that asks the user to confirm whether they want to delete one or more + * VSRs, and then deletes them if they confirm that they want to. + */ +export const DeleteVSRsModal = ({ isOpen, onClose, afterDelete, vsrIds }: DeleteVSRsModalProps) => { + const { firebaseUser } = useContext(UserContext); + + const [loadingDelete, setLoadingDelete] = useState(false); + const [successNotificationOpen, setSuccessNotificationOpen] = useState(false); + const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); + + const onDelete = async () => { + if (loadingDelete || !firebaseUser) { + return; + } + + setSuccessNotificationOpen(false); + setErrorNotificationOpen(false); + setLoadingDelete(true); + + try { + const firebaseToken = await firebaseUser.getIdToken(); + if (!firebaseToken) { + setLoadingDelete(false); + return; + } + + await Promise.all( + vsrIds.map((vsrId) => + deleteVSR(vsrId, firebaseToken).then((res) => { + if (res.success) { + return Promise.resolve(); + } else { + return Promise.reject(res.error); + } + }), + ), + ); + + setSuccessNotificationOpen(true); + afterDelete(); + } catch (error) { + console.error(`Error deleting VSR(s): ${error}`); + setErrorNotificationOpen(true); + } finally { + setLoadingDelete(false); + onClose(); + } + }; + + return ( + <> + + {"Deleted VSR’s "} + cannot + {" be recovered. Are you sure you’d like to delete the selected VSR forms ("} + {vsrIds.length} + {")?"} + + } + bottomRow={ +
+
+ } + /> + setSuccessNotificationOpen(false) }]} + /> + setErrorNotificationOpen(false)} + /> + + ); +}; diff --git a/frontend/src/components/shared/DeleteVSRsModal/styles.module.css b/frontend/src/components/shared/DeleteVSRsModal/styles.module.css new file mode 100644 index 0000000..f4d4c9e --- /dev/null +++ b/frontend/src/components/shared/DeleteVSRsModal/styles.module.css @@ -0,0 +1,32 @@ +.buttonContainer { + display: flex; + flex-direction: row; + gap: 32px; +} + +.button { + width: 100%; + padding: 12px 24px; + text-align: center; + font-family: "Lora"; + font-size: 24px; + font-weight: 700; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .button { + font-size: 18px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .buttonContainer { + gap: 16px; + } + + .button { + font-size: 14px; + } +} diff --git a/frontend/src/components/shared/HeaderBar/index.tsx b/frontend/src/components/shared/HeaderBar/index.tsx index d5b927d..05da385 100644 --- a/frontend/src/components/shared/HeaderBar/index.tsx +++ b/frontend/src/components/shared/HeaderBar/index.tsx @@ -1,18 +1,55 @@ import Image from "next/image"; -import React from "react"; +import React, { useState } from "react"; import styles from "@/components/shared/HeaderBar/styles.module.css"; -import { useScreenSizes } from "@/util/useScreenSizes"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { signOut } from "firebase/auth"; +import { initFirebase } from "@/firebase/firebase"; +import { ErrorNotification } from "@/components/Errors/ErrorNotification"; +import { Button } from "@/components/shared/Button"; -const HeaderBar = () => { +interface HeaderBarProps { + showLogoutButton: boolean; +} + +/** + * A component that displays a header bar at the top of the screen and, optionally, + * a logout button for staff and admins. + */ +const HeaderBar = ({ showLogoutButton }: HeaderBarProps) => { const { isTablet } = useScreenSizes(); + const { auth } = initFirebase(); + const [loading, setLoading] = useState(false); + const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); + + const logout = () => { + setErrorNotificationOpen(false); + setLoading(true); + signOut(auth) + .catch((error) => { + console.error(`Could not logout: ${error}`); + setErrorNotificationOpen(true); + }) + .finally(() => setLoading(false)); + }; + return (
- logo + {showLogoutButton ? ( +
); diff --git a/frontend/src/components/shared/HeaderBar/styles.module.css b/frontend/src/components/shared/HeaderBar/styles.module.css index 07117de..65f7189 100644 --- a/frontend/src/components/shared/HeaderBar/styles.module.css +++ b/frontend/src/components/shared/HeaderBar/styles.module.css @@ -1,26 +1,21 @@ .headerBar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; position: sticky; top: 0; left: 0; width: 100%; - height: 101px; + padding: 27px 24px 26px 63px; + padding-right: 24px; background-color: #fff; border-bottom: 1px solid rgba(0, 0, 0, 0.25); } -.logo { - margin-top: 27px; - margin-left: 63px; -} - /* tablet version */ @media screen and (max-width: 850px) { .headerBar { - height: 115px; - } - - .logo { - margin-top: 52px; - margin-left: 24px; + padding: 52px 12px 20px 24px; } } diff --git a/frontend/src/components/shared/LoadingScreen/index.tsx b/frontend/src/components/shared/LoadingScreen/index.tsx new file mode 100644 index 0000000..3b41db6 --- /dev/null +++ b/frontend/src/components/shared/LoadingScreen/index.tsx @@ -0,0 +1,19 @@ +import styles from "@/components/shared/LoadingScreen/styles.module.css"; +import { CircularProgress } from "@mui/material"; +import { CSSProperties } from "react"; + +interface LoadingScreenProps { + style?: CSSProperties; +} + +/** + * A component that displays a loading screen with a spinner and some text + */ +export const LoadingScreen = ({ style }: LoadingScreenProps) => { + return ( +
+ +

Please wait...

+
+ ); +}; diff --git a/frontend/src/components/shared/LoadingScreen/styles.module.css b/frontend/src/components/shared/LoadingScreen/styles.module.css new file mode 100644 index 0000000..ef1f181 --- /dev/null +++ b/frontend/src/components/shared/LoadingScreen/styles.module.css @@ -0,0 +1,29 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 32px; +} + +.text { + color: var(--Secondary-1, #102d5f); + text-align: center; + font-family: "Lora"; + font-size: 24px; + font-weight: 700; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .text { + font-size: 28px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .text { + font-size: 20px; + } +} diff --git a/frontend/src/components/shared/StatusChip/index.tsx b/frontend/src/components/shared/StatusChip/index.tsx index ec8a04d..ba0ad8c 100644 --- a/frontend/src/components/shared/StatusChip/index.tsx +++ b/frontend/src/components/shared/StatusChip/index.tsx @@ -5,6 +5,10 @@ interface StatusChipProps { status: StatusOption; } +/** + * A component that displays a chip with a VSR's status, with a certain text + * and background color based on the status + */ export const StatusChip = ({ status }: StatusChipProps) => { return (
diff --git a/frontend/src/components/shared/StatusDropdown/index.tsx b/frontend/src/components/shared/StatusDropdown/index.tsx index eaa6eec..4cd1589 100644 --- a/frontend/src/components/shared/StatusDropdown/index.tsx +++ b/frontend/src/components/shared/StatusDropdown/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import styles from "@/components/shared/StatusDropdown/styles.module.css"; import MenuItem from "@mui/material/MenuItem"; import FormControl from "@mui/material/FormControl"; @@ -11,6 +11,9 @@ export interface StatusOption { color: string; } +/** + * All available statuses that can be set using the status dropdown + */ export const STATUS_OPTIONS: StatusOption[] = [ { value: "Received", @@ -24,6 +27,10 @@ export const STATUS_OPTIONS: StatusOption[] = [ value: "Approved", color: "#d7eebc", }, + { + value: "Complete", + color: "#bfe1f6", + }, { value: "Resubmit", color: "#fae69e", @@ -38,6 +45,10 @@ export const STATUS_OPTIONS: StatusOption[] = [ }, ]; +/** + * An input component that displays a dropdown menu with all available status + * options and enables the user to select a status. + */ export interface StatusDropdownProps { value: string; onChanged?: (value: string) => void; @@ -47,6 +58,8 @@ export function StatusDropdown({ value, onChanged }: StatusDropdownProps) { const [selectedValue, setSelectedValue] = useState(value); const [isOpen, setIsOpen] = useState(false); + useEffect(() => setSelectedValue(value), [value]); + const handleChange = (event: SelectChangeEvent) => { setSelectedValue(event.target.value); onChanged?.(event.target.value); diff --git a/frontend/src/components/shared/SuccessNotification/index.tsx b/frontend/src/components/shared/SuccessNotification/index.tsx new file mode 100644 index 0000000..3319d14 --- /dev/null +++ b/frontend/src/components/shared/SuccessNotification/index.tsx @@ -0,0 +1,37 @@ +import styles from "@/components/shared/SuccessNotification/styles.module.css"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { Portal } from "@mui/material"; +import Image from "next/image"; + +interface SuccessAction { + text: string; + onClick: () => unknown; +} + +interface SuccessNotificationProps { + isOpen: boolean; + mainText: string; + actions: SuccessAction[]; +} + +/** + * A component that displays a success notification at the top of the screen + */ +export const SuccessNotification = ({ isOpen, mainText, actions }: SuccessNotificationProps) => { + const { isMobile } = useScreenSizes(); + const iconSize = isMobile ? 36 : 49; + + return isOpen ? ( + +
+ Checkmark +

{mainText}

+ {actions.map((action, index) => ( +

+ {action.text} +

+ ))} +
+
+ ) : null; +}; diff --git a/frontend/src/components/shared/SuccessNotification/styles.module.css b/frontend/src/components/shared/SuccessNotification/styles.module.css new file mode 100644 index 0000000..dc32813 --- /dev/null +++ b/frontend/src/components/shared/SuccessNotification/styles.module.css @@ -0,0 +1,62 @@ +.root { + position: fixed; + top: 133px; + left: 50%; + transform: translateX(-50%); + width: 707px; + display: flex; + flex-direction: row; + align-items: center; + border: 5px solid #3bb966; + border-radius: 6px; + padding: 24px; + gap: 16px; + background-color: white; +} + +.mainText { + color: var(--Functional-Success, #3bb966); + font-family: "Lora"; + font-size: 24px; + font-weight: 700; + width: 100%; +} + +.actionText { + color: rgba(35, 34, 32, 0.5); + font-family: "Open Sans"; + font-size: 20px; + font-weight: 400; + cursor: pointer; +} + +/* tablet version */ +@media screen and (max-width: 850px) { + .root { + width: calc(100% - 48px); + } + + .mainText { + font-size: 20px; + } + + .actionText { + font-size: 16px; + } +} + +/* mobile version */ +@media screen and (max-width: 550px) { + .root { + padding: 12px; + gap: 12px; + } + + .mainText { + font-size: 18px; + } + + .actionText { + font-size: 14px; + } +} diff --git a/frontend/src/components/shared/input/BinaryChoice/index.tsx b/frontend/src/components/shared/input/BinaryChoice/index.tsx index 69a4110..27ace6a 100644 --- a/frontend/src/components/shared/input/BinaryChoice/index.tsx +++ b/frontend/src/components/shared/input/BinaryChoice/index.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import Chip from "@mui/material/Chip"; import styles from "@/components/shared/input/BinaryChoice/styles.module.css"; -import { FormField } from "../FormField"; +import { FormField } from "@/components/shared/input/FormField"; +import { StyledChip } from "@/components/shared/input/StyledChip"; export interface BinaryChoiceProps { label: string; @@ -12,6 +12,9 @@ export interface BinaryChoiceProps { helperText?: string; } +/** + * An input component that allows the user to select either "Yes" or "No" by clicking on chips. + */ const BinaryChoice = ({ label, value, @@ -30,21 +33,15 @@ const BinaryChoice = ({ return (
- handleOptionClick(true)} - className={`${styles.chip} ${ - selectedOption === true ? styles.chipSelected : styles.chipUnselected - }`} - clickable /> - handleOptionClick(false)} - className={`${styles.chip} ${ - selectedOption === false ? styles.chipSelected : styles.chipUnselected - }`} - clickable />
diff --git a/frontend/src/components/shared/input/BinaryChoice/styles.module.css b/frontend/src/components/shared/input/BinaryChoice/styles.module.css index c40c320..34e1e4a 100644 --- a/frontend/src/components/shared/input/BinaryChoice/styles.module.css +++ b/frontend/src/components/shared/input/BinaryChoice/styles.module.css @@ -1,40 +1,8 @@ /* BinaryChoice.module.css */ -.chip { - border-width: 1px; - border-style: solid; - text-align: center; - font-family: "Open Sans"; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; - border-radius: 64px; - border: 1px solid var(--Secondary-1, #102d5f); -} - -.chipSelected { - color: white; - background: #102d5f; -} - -.chipSelected:hover { - background: #102d5f; -} - .chipContainer { display: flex; flex-direction: row; gap: 16px; flex-wrap: wrap; } - -.chipUnselected { - color: var(--Accent-Blue-1, #102d5f); - background: rgba(255, 255, 255, 0); -} - -/*Need exact color for this*/ -.chipUnselected:hover { - background: rgb(213, 232, 239); -} diff --git a/frontend/src/components/shared/input/ChildrenInput/index.tsx b/frontend/src/components/shared/input/ChildrenInput/index.tsx new file mode 100644 index 0000000..f81b7db --- /dev/null +++ b/frontend/src/components/shared/input/ChildrenInput/index.tsx @@ -0,0 +1,78 @@ +import styles from "@/components/shared/input/ChildrenInput/styles.module.css"; +import { UseFormReturn } from "react-hook-form"; +import TextField from "@/components/shared/input/TextField"; +import { ICreateVSRFormInput, IEditVSRFormInput } from "@/components/VSRForm/VSRFormTypes"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; + +interface ChildrenInputProps { + gender: "boy" | "girl"; + formProps: UseFormReturn | UseFormReturn; +} + +/** + * A component that renders the text fields to input the children of the given gender. + */ +export const ChildrenInput = ({ gender, formProps }: ChildrenInputProps) => { + const numChildrenThisGender = formProps.watch()[`num_${gender}s`]; + const fieldInputName = `agesOf${gender[0].toUpperCase()}${gender.substring(1)}s` as + | "agesOfBoys" + | "agesOfGirls"; + + const { isMobile } = useScreenSizes(); + + return ( + <> +
+ ).register(`num_${gender}s`, { + required: `Number of ${gender}s is required`, + pattern: { + // Only allow up to 2 digits + value: /^[0-9][0-9]?$/, + message: "This field must be a positive number less than 100", + }, + })} + required + error={!!formProps.formState.errors[`num_${gender}s`]} + helperText={formProps.formState.errors[`num_${gender}s`]?.message} + /> +
+ + {numChildrenThisGender > 0 || !isMobile ? ( +
+ {/* Cap it at 99 children per gender to avoid freezing web browser */} + {Array.from({ length: Math.min(numChildrenThisGender, 99) }, (_, index) => ( +
+ ).register( + `${fieldInputName}.${index}`, + { + required: "This field is required", + pattern: { + value: /^[0-9]+$/, + message: "This field must be a positive number", + }, + max: { + value: 17, + message: "Only enter children under 18", + }, + }, + )} + error={!!formProps.formState.errors[fieldInputName]?.[index]} + helperText={formProps.formState.errors[fieldInputName]?.[index]?.message} + required + /> +
+ ))} +
+ ) : null} + + ); +}; diff --git a/frontend/src/components/shared/input/ChildrenInput/styles.module.css b/frontend/src/components/shared/input/ChildrenInput/styles.module.css new file mode 100644 index 0000000..b9f8ad7 --- /dev/null +++ b/frontend/src/components/shared/input/ChildrenInput/styles.module.css @@ -0,0 +1,17 @@ +.textInputWrapper { + flex: 1; + width: 100%; +} + +.numChildren { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + flex: 1; + width: 100%; +} + +.childInputWrapper { + width: 100%; +} diff --git a/frontend/src/components/shared/input/Dropdown/index.tsx b/frontend/src/components/shared/input/Dropdown/index.tsx index e0ed938..9bfebbc 100644 --- a/frontend/src/components/shared/input/Dropdown/index.tsx +++ b/frontend/src/components/shared/input/Dropdown/index.tsx @@ -1,8 +1,8 @@ import React from "react"; import styles from "@/components/shared/input/Dropdown/styles.module.css"; import { FormControl, Select, MenuItem, SelectChangeEvent } from "@mui/material"; -import { FormField } from "../FormField"; -import { useScreenSizes } from "@/util/useScreenSizes"; +import { FormField } from "@/components/shared/input/FormField"; +import { useScreenSizes } from "@/hooks/useScreenSizes"; export interface DropDownProps { label: string; @@ -15,6 +15,10 @@ export interface DropDownProps { placeholder?: string; } +/** + * An input component that displays a dropdown and enables the user to select one + * of the options from the dropdown. + */ const Dropdown = ({ label, options, @@ -47,6 +51,9 @@ const Dropdown = ({ height: isMobile ? 16 : isTablet ? 19 : 22, }, }} + MenuProps={{ + disableScrollLock: true, + }} >

{placeholder}

diff --git a/frontend/src/components/shared/input/FormField/index.tsx b/frontend/src/components/shared/input/FormField/index.tsx index 4ef3b50..8e7dfd5 100644 --- a/frontend/src/components/shared/input/FormField/index.tsx +++ b/frontend/src/components/shared/input/FormField/index.tsx @@ -9,6 +9,10 @@ export interface FormFieldProps { children: ReactNode; } +/** + * A wrapper component for a form input field, that displays a label, asterisk if the field + * is required, the field itself, and helper/error text at the bottom. + */ export const FormField = ({ label, required, error, helperText, children }: FormFieldProps) => { return (
diff --git a/frontend/src/components/shared/input/InputField/index.tsx b/frontend/src/components/shared/input/InputField/index.tsx deleted file mode 100644 index d262f62..0000000 --- a/frontend/src/components/shared/input/InputField/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, { ChangeEvent } from "react"; -import styles from "@/components/shared/input/InputField/styles.module.css"; - -interface InputFieldProps { - label: string; - type?: string; - id: string; - placeholder: string; - value: string; - onChange: (e: ChangeEvent) => void; -} - -const InputField: React.FC = ({ - label, - id, - placeholder, - value, - onChange, - type = "text", -}) => { - return ( -
- - -
- ); -}; - -export default InputField; diff --git a/frontend/src/components/shared/input/InputField/styles.module.css b/frontend/src/components/shared/input/InputField/styles.module.css deleted file mode 100644 index c3609c9..0000000 --- a/frontend/src/components/shared/input/InputField/styles.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.inputField { - margin-bottom: 20px; -} - -.label { - font-size: 16px; - font-family: "Open Sans", sans-serif; - color: #818181; - font-style: normal; - font-weight: 400; - line-height: normal; -} - -.input { - border: 1px solid #d8d8d8; - border-radius: 4px; - padding: 8px; - width: 100%; - font-size: 16px; - font-family: "Open Sans", sans-serif; - color: #232220; - background: #fff; -} - -.input::placeholder { - font-style: italic; - font-family: "Open Sans", sans-serif; - font-size: 16px; - color: #484848; - font-weight: 300; - line-height: normal; -} - -@media screen and (max-width: 550px) { - .label { - font-size: 12px; - } - - .input { - font-size: 12px; - font-weight: 400; - } - - .input::placeholder { - font-size: 12px; - } -} diff --git a/frontend/src/components/shared/input/MultipleChoice/index.tsx b/frontend/src/components/shared/input/MultipleChoice/index.tsx index 23b2566..9592038 100644 --- a/frontend/src/components/shared/input/MultipleChoice/index.tsx +++ b/frontend/src/components/shared/input/MultipleChoice/index.tsx @@ -1,6 +1,6 @@ -import Chip from "@mui/material/Chip"; import styles from "@/components/shared/input/MultipleChoice/styles.module.css"; -import { FormField } from "../FormField"; +import { FormField } from "@/components/shared/input/FormField"; +import { StyledChip } from "@/components/shared/input/StyledChip"; export interface MultipleChoiceProps { label: string; @@ -13,6 +13,10 @@ export interface MultipleChoiceProps { helperText?: string; } +/** + * An input component that displays multiple options as chips, and enables the user + * to choose one of them by clicking on it. + */ const MultipleChoice = ({ label, options, @@ -30,9 +34,10 @@ const MultipleChoice = ({ const optionIsSelected = allowMultiple ? value?.includes(option) : value === option; return ( - { if (allowMultiple) { if (optionIsSelected) { @@ -52,15 +57,6 @@ const MultipleChoice = ({ } } }} - className={`${styles.chip} ${ - optionIsSelected ? styles.chipSelected : styles.chipUnselected - }`} - clickable - sx={{ - ".MuiChip-label": { - padding: "0 !important", - }, - }} /> ); })} diff --git a/frontend/src/components/shared/input/MultipleChoice/styles.module.css b/frontend/src/components/shared/input/MultipleChoice/styles.module.css index 1076148..f02424d 100644 --- a/frontend/src/components/shared/input/MultipleChoice/styles.module.css +++ b/frontend/src/components/shared/input/MultipleChoice/styles.module.css @@ -1,57 +1,8 @@ /* MultipleChoice.module.css */ -.chip { - border-width: 1px; - text-align: center; - font-family: "Open Sans"; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: normal; - border-radius: 64px; - border: 1px solid var(--Secondary-1, #102d5f); - padding: 8px 16px; - height: 40px; -} - -.chipSelected { - color: white; - background: #102d5f; -} - -.chipSelected:hover { - background: #102d5f; -} - .chipContainer { display: flex; flex-direction: row; gap: 16px; flex-wrap: wrap; } - -.chipUnselected { - color: var(--Accent-Blue-1, #102d5f); - background: rgba(255, 255, 255, 0); -} - -/*Need exact color for this*/ -.chipUnselected:hover { - background: rgb(142, 166, 175); -} - -/* tablet version */ -@media screen and (max-width: 850px) { - .chip { - font-size: 14px; - height: 36px; - } -} - -/* mobile version */ -@media screen and (max-width: 550px) { - .chip { - font-size: 12px; - height: 30px; - } -} diff --git a/frontend/src/components/shared/input/StyledChip/index.tsx b/frontend/src/components/shared/input/StyledChip/index.tsx new file mode 100644 index 0000000..aac88cc --- /dev/null +++ b/frontend/src/components/shared/input/StyledChip/index.tsx @@ -0,0 +1,49 @@ +import { useScreenSizes } from "@/hooks/useScreenSizes"; +import { Chip } from "@mui/material"; + +interface StyledChipProps { + text: string; + selected: boolean; + onClick: () => unknown; +} + +/** + * A wrapper component around the MUI Chip that provides our app's custom styles. + */ +export const StyledChip = ({ text, selected, onClick }: StyledChipProps) => { + const { isMobile, isTablet } = useScreenSizes(); + + return ( + + ); +}; diff --git a/frontend/src/components/shared/input/TextField/index.tsx b/frontend/src/components/shared/input/TextField/index.tsx index 0e71772..10002ff 100644 --- a/frontend/src/components/shared/input/TextField/index.tsx +++ b/frontend/src/components/shared/input/TextField/index.tsx @@ -1,7 +1,7 @@ import styles from "@/components/shared/input/TextField/styles.module.css"; import MUITextField, { TextFieldProps as MUITextFieldProps } from "@mui/material/TextField"; import { ForwardedRef, forwardRef } from "react"; -import { FormField } from "../FormField"; +import { FormField } from "@/components/shared/input/FormField"; export interface TextFieldProps extends MUITextFieldProps<"outlined"> { label: string; @@ -10,6 +10,9 @@ export interface TextFieldProps extends MUITextFieldProps<"outlined"> { required: boolean; } +/** + * A text input field component + */ const TextField = forwardRef( ( { label, error, required, helperText, ...props }: TextFieldProps, diff --git a/frontend/src/constants/fieldOptions.ts b/frontend/src/constants/fieldOptions.ts new file mode 100644 index 0000000..fe0e014 --- /dev/null +++ b/frontend/src/constants/fieldOptions.ts @@ -0,0 +1,144 @@ +/** + * Defines the list of options for several multiple choice fields on the VSR form. + */ +export const maritalOptions = ["Married", "Single", "It's Complicated", "Widowed/Widower"]; +export const genderOptions = ["Male", "Female", "Other"]; +export const employmentOptions = [ + "Employed", + "Unemployed", + "Currently Looking", + "Retired", + "In School", + "Unable to work", +]; + +export const incomeOptions = [ + "$12,500 and under", + "$12,501 - $25,000", + "$25,001 - $50,000", + "$50,001 and over", +]; + +export const homeOptions = [ + "House", + "Apartment", + "Studio", + "1 Bedroom", + "2 Bedroom", + "3 Bedroom", + "4 Bedroom", + "4+ Bedroom", +]; + +export const ethnicityOptions = [ + "Asian", + "African American", + "Caucasian", + "Native American", + "Pacific Islander", + "Middle Eastern", + "Prefer not to say", +]; + +export const branchOptions = [ + "Air Force", + "Air Force Reserve", + "Air National Guard", + "Army", + "Army Air Corps", + "Army Reserve", + "Coast Guard", + "Marine Corps", + "Navy", + "Navy Reserve", +]; + +export const conflictsOptions = [ + "WWII", + "Korea", + "Vietnam", + "Persian Gulf", + "Bosnia", + "Kosovo", + "Panama", + "Kuwait", + "Iraq", + "Somalia", + "Desert Shield/Storm", + "Operation Enduring Freedom (OEF)", + "Afghanistan", + "Irani Crisis", + "Granada", + "Lebanon", + "Beirut", + "Special Ops", + "Peacetime", +]; + +export const dischargeStatusOptions = [ + "Honorable Discharge", + "General Under Honorable", + "Other Than Honorable", + "Bad Conduct", + "Entry Level", + "Dishonorable", + "Still Serving", + "Civilian", + "Medical", + "Not Given", +]; + +export const hearFromOptions = ["Colleague", "Social Worker", "Friend", "Internet", "Social Media"]; + +export const stateOptions = [ + "AL", + "AK", + "AZ", + "AR", + "CA", + "CO", + "CT", + "DE", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "TX", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY", +]; diff --git a/frontend/src/contexts/userContext.tsx b/frontend/src/contexts/userContext.tsx new file mode 100644 index 0000000..ae7161d --- /dev/null +++ b/frontend/src/contexts/userContext.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { ReactNode, createContext, useEffect, useState } from "react"; +import { User as FirebaseUser, onAuthStateChanged } from "firebase/auth"; +import { User, getWhoAmI } from "@/api/Users"; +import { initFirebase } from "@/firebase/firebase"; + +interface IUserContext { + firebaseUser: FirebaseUser | null; + papUser: User | null; + loadingUser: boolean; + reloadUser: () => unknown; +} + +/** + * A context that provides the current Firebase and PAP (MongoDB) user data, + * automatically fetching them when the page loads. + */ +export const UserContext = createContext({ + firebaseUser: null, + papUser: null, + loadingUser: true, + reloadUser: () => {}, +}); + +/** + * A provider component that handles the logic for supplying the context + * with its current user & loading state variables. + */ +export const UserContextProvider = ({ children }: { children: ReactNode }) => { + const [firebaseUser, setFirebaseUser] = useState(null); + const [initialLoading, setInitialLoading] = useState(true); + const [papUser, setPapUser] = useState(null); + const [loadingUser, setLoadingUser] = useState(true); + + const { auth } = initFirebase(); + + /** + * Callback triggered by Firebase when the user logs in/out, or on page load + */ + onAuthStateChanged(auth, (firebaseUser) => { + setFirebaseUser(firebaseUser); + setInitialLoading(false); + }); + + const reloadUser = () => { + if (initialLoading) { + return; + } + setLoadingUser(true); + setPapUser(null); + if (firebaseUser === null) { + setLoadingUser(false); + } else { + firebaseUser.getIdToken().then((token) => + getWhoAmI(token).then((res) => { + if (res.success) { + setPapUser(res.data); + } else { + setPapUser(null); + } + setLoadingUser(false); + }), + ); + } + }; + + useEffect(reloadUser, [initialLoading, firebaseUser]); + + return ( + + {children} + + ); +}; diff --git a/frontend/src/firebase/firebase.ts b/frontend/src/firebase/firebase.ts index 137e33a..a21a24d 100644 --- a/frontend/src/firebase/firebase.ts +++ b/frontend/src/firebase/firebase.ts @@ -2,6 +2,10 @@ import { initializeApp } from "firebase/app"; import { getAuth } from "firebase/auth"; import env from "@/util/validateEnv"; +/** + * Initializes Firebase for the frontend, using the NEXT_PUBLIC_FIREBASE_SETTINGS + * environment variable. + */ export const initFirebase = () => { if (!env.NEXT_PUBLIC_FIREBASE_SETTINGS) { throw new Error("Cannot get firebase settings"); diff --git a/frontend/src/hooks/useRedirection.ts b/frontend/src/hooks/useRedirection.ts new file mode 100644 index 0000000..38f864e --- /dev/null +++ b/frontend/src/hooks/useRedirection.ts @@ -0,0 +1,66 @@ +import { User } from "@/api/Users"; +import { UserContext } from "@/contexts/userContext"; +import { User as FirebaseUser } from "firebase/auth"; +import { useRouter } from "next/navigation"; +import { useContext, useEffect } from "react"; + +export const LOGIN_URL = "/login"; +export const HOME_URL = "/staff/vsr"; + +/** + * An interface for the user's current authentication credentials + */ +export interface AuthCredentials { + firebaseUser: FirebaseUser | null; + papUser: User | null; +} + +/** + * A type for a function that determines whether the user should be redirected + * based on their current credentials + */ +export type CheckShouldRedirect = (authCredentials: AuthCredentials) => boolean; + +export interface UseRedirectionProps { + checkShouldRedirect: CheckShouldRedirect; + redirectURL: string; +} + +/** + * A base hook that redirects the user to redirectURL if checkShouldRedirect returns true + */ +export const useRedirection = ({ checkShouldRedirect, redirectURL }: UseRedirectionProps) => { + const { firebaseUser, papUser, loadingUser } = useContext(UserContext); + const router = useRouter(); + + useEffect(() => { + // Don't redirect if we are still loading the current user + if (loadingUser) { + return; + } + + if (checkShouldRedirect({ firebaseUser, papUser })) { + router.push(redirectURL); + } + }, [firebaseUser, papUser, loadingUser]); +}; + +/** + * A hook that redirects the user to the staff/admin home page if they are already signed in + */ +export const useRedirectToHomeIfSignedIn = () => { + useRedirection({ + checkShouldRedirect: ({ firebaseUser, papUser }) => firebaseUser !== null && papUser !== null, + redirectURL: HOME_URL, + }); +}; + +/** + * A hook that redirects the user to the login page if they are not signed in + */ +export const useRedirectToLoginIfNotSignedIn = () => { + useRedirection({ + checkShouldRedirect: ({ firebaseUser, papUser }) => firebaseUser === null || papUser === null, + redirectURL: LOGIN_URL, + }); +}; diff --git a/frontend/src/util/useScreenSizes.ts b/frontend/src/hooks/useScreenSizes.ts similarity index 100% rename from frontend/src/util/useScreenSizes.ts rename to frontend/src/hooks/useScreenSizes.ts