├── backend ├── Dockerrun.aws.json ├── .dockerignore ├── client.ts ├── prisma │ ├── migrations │ │ ├── 20241231212558_location_directions │ │ │ └── migration.sql │ │ ├── 20250423211235_add_legacy_hours │ │ │ └── migration.sql │ │ ├── migration_lock.toml │ │ ├── 20230421204737_cancelation │ │ │ └── migration.sql │ │ ├── 20250515224753_change_hours │ │ │ └── migration.sql │ │ ├── 20230310180636_init │ │ │ └── migration.sql │ │ ├── 20240317021038_test │ │ │ └── migration.sql │ │ ├── 20250109214354_remove_worked_hours │ │ │ └── migration.sql │ │ ├── 20240313215409_ │ │ │ └── migration.sql │ │ ├── 20250104195833_optional_event_owner │ │ │ └── migration.sql │ │ ├── 20240316044752_about │ │ │ └── migration.sql │ │ ├── 20230405215058_ │ │ │ └── migration.sql │ │ ├── 20250102030506_event_status │ │ │ └── migration.sql │ │ ├── 20230207055813_init │ │ │ └── migration.sql │ │ ├── 20240117194255_on_delete │ │ │ └── migration.sql │ │ ├── 0_init │ │ │ └── migration.sql │ │ ├── 20240211003708_ │ │ │ └── migration.sql │ │ ├── 20240217012742_back_to_uppercase │ │ │ └── migration.sql │ │ ├── 20250109213408_remove_columns │ │ │ └── migration.sql │ │ └── 20230217202647_init │ │ │ └── migration.sql │ └── schema.prisma ├── docker-compose.yml ├── Dockerfile ├── Dockerfile.deploy ├── src │ ├── utils │ │ ├── jsonResponses.ts │ │ └── script.ts │ ├── about │ │ ├── controllers.ts │ │ └── views.ts │ ├── website │ │ ├── views.ts │ │ └── controllers.ts │ ├── swagger.ts │ ├── server.ts │ ├── index.ts │ └── middleware │ │ └── auth.ts ├── jest.config.js ├── .env.template ├── .prettierrc ├── package.json └── README.md ├── lfbi_logo.png ├── frontend ├── public │ ├── favicon.ico │ ├── lfbi_logo.png │ ├── lfbi_splash.png │ ├── lfbi_logo_notext.png │ ├── lfbi_sample_event.jpg │ ├── vercel.svg │ ├── thirteen.svg │ └── next.svg ├── postcss.config.js ├── next.config.js ├── src │ ├── components │ │ ├── Layout.tsx │ │ ├── organisms │ │ │ ├── FetchDataError.tsx │ │ │ ├── EventCardCancelConfirmation.tsx │ │ │ ├── NavBar.tsx │ │ │ ├── VerifyEmailConfirmation.tsx │ │ │ ├── ManageWebsite.tsx │ │ │ ├── RecoverEmailConfirmation.tsx │ │ │ ├── ManageProvidersForm.tsx │ │ │ ├── VerifyEmailForm.tsx │ │ │ ├── ForgotPasswordForm.tsx │ │ │ ├── EventCardRegister.tsx │ │ │ ├── EventCard.tsx │ │ │ └── LinkEmailPasswordForm.tsx │ │ ├── atoms │ │ │ ├── Chip.tsx │ │ │ ├── LinearProgress.tsx │ │ │ ├── IconText.tsx │ │ │ ├── Checkbox.tsx │ │ │ ├── IconTextHeader.tsx │ │ │ ├── MultilineTextField.tsx │ │ │ ├── TextField.tsx │ │ │ ├── Snackbar.tsx │ │ │ ├── Alert.tsx │ │ │ ├── DatePicker.tsx │ │ │ ├── TimePicker.tsx │ │ │ ├── Select.tsx │ │ │ ├── SearchBar.tsx │ │ │ ├── Editor.tsx │ │ │ ├── Button.tsx │ │ │ ├── TextCopy.tsx │ │ │ └── Dropzone.tsx │ │ ├── molecules │ │ │ ├── Loading.tsx │ │ │ ├── Card.tsx │ │ │ ├── Avatar.tsx │ │ │ ├── CardList.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Modal.tsx │ │ │ ├── Table.tsx │ │ │ ├── AppBar.tsx │ │ │ └── TabContainer.tsx │ │ └── templates │ │ │ ├── CenteredTemplate.tsx │ │ │ ├── DefaultTemplate.tsx │ │ │ ├── WelcomeTemplate.tsx │ │ │ └── EventTemplate.tsx │ ├── utils │ │ ├── constants.ts │ │ ├── firebase.ts │ │ ├── types.ts │ │ ├── api.ts │ │ ├── useManageUserState.ts │ │ └── useManageAttendeeState.ts │ ├── pages │ │ ├── _document.tsx │ │ ├── api │ │ │ └── hello.ts │ │ ├── about.tsx │ │ ├── login.tsx │ │ ├── website.tsx │ │ ├── signup.tsx │ │ ├── verify.tsx │ │ ├── events │ │ │ ├── view.tsx │ │ │ ├── create.tsx │ │ │ └── [eventid] │ │ │ │ ├── attendees.tsx │ │ │ │ ├── register.tsx │ │ │ │ └── edit.tsx │ │ ├── users │ │ │ ├── view.tsx │ │ │ └── [userid] │ │ │ │ └── manage.tsx │ │ ├── password │ │ │ ├── forgot.tsx │ │ │ ├── [oobCode] │ │ │ │ ├── reset.tsx │ │ │ │ ├── recover.tsx │ │ │ │ └── verify.tsx │ │ │ └── index.tsx │ │ ├── _app.tsx │ │ ├── index.tsx │ │ └── privacy.tsx │ └── styles │ │ └── globals.css ├── next-env.d.ts ├── tailwind.config.js ├── .env.template ├── .gitignore ├── README.md ├── tsconfig.json └── package.json ├── heroku.yml ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── deploy.yml │ └── jestci.yml ├── package.json ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .gitignore ├── LICENSE └── README.md /backend/Dockerrun.aws.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env -------------------------------------------------------------------------------- /lfbi_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cornellh4i/lagos-volunteers/HEAD/lfbi_logo.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cornellh4i/lagos-volunteers/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/lfbi_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cornellh4i/lagos-volunteers/HEAD/frontend/public/lfbi_logo.png -------------------------------------------------------------------------------- /frontend/public/lfbi_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cornellh4i/lagos-volunteers/HEAD/frontend/public/lfbi_splash.png -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/public/lfbi_logo_notext.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cornellh4i/lagos-volunteers/HEAD/frontend/public/lfbi_logo_notext.png -------------------------------------------------------------------------------- /frontend/public/lfbi_sample_event.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cornellh4i/lagos-volunteers/HEAD/frontend/public/lfbi_sample_event.jpg -------------------------------------------------------------------------------- /backend/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prisma = new PrismaClient(); 4 | export default prisma; 5 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: backend/Dockerfile 4 | release: 5 | image: web 6 | command: 7 | - npx prisma migrate deploy 8 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20241231212558_location_directions/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Event" ADD COLUMN "locationLink" TEXT; 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Users who will be automatically be notified & added as reviewers when a PR is made (should be TL) 2 | 3 | * @akinfelami 4 | * @jasozh 5 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250423211235_add_legacy_hours/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "legacyHours" INTEGER NOT NULL DEFAULT 0; 3 | -------------------------------------------------------------------------------- /backend/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /backend/prisma/migrations/20230421204737_cancelation/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "EventEnrollment" ADD COLUMN "cancelationMessage" TEXT, 3 | ADD COLUMN "canceled" BOOLEAN DEFAULT false; 4 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | transpilePackages: ["@mdxeditor/editor"], 5 | }; 6 | 7 | module.exports = nextConfig; 8 | -------------------------------------------------------------------------------- /frontend/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | 3 | const Layout = ({ children }: { children: ReactNode }) => { 4 | return
{children}
; 5 | }; 6 | 7 | export default Layout; 8 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | server: 4 | image: lagos-volunteer-platform 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "8000:8000" 10 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250515224753_change_hours/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Event" ADD COLUMN "hours" INTEGER NOT NULL DEFAULT 0; 3 | 4 | -- AlterTable 5 | ALTER TABLE "EventEnrollment" ADD COLUMN "customHours" INTEGER; 6 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /frontend/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL as string; 2 | export const BASE_WEBSOCKETS_URL = process.env.NEXT_PUBLIC_BASE_WEBSOCKETS_URL; 3 | export const BASE_URL_CLIENT = process.env 4 | .NEXT_PUBLIC_BASE_URL_CLIENT as string; 5 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /app 4 | COPY package.json . yarn.lock ./ 5 | COPY prisma ./prisma 6 | 7 | RUN yarn install 8 | RUN npx prisma generate 9 | COPY . . 10 | 11 | ENV NODE_ENV=production 12 | 13 | EXPOSE 8000 14 | CMD ["yarn", "run", "backend"] 15 | -------------------------------------------------------------------------------- /backend/Dockerfile.deploy: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /app 4 | COPY package.json . yarn.lock ./ 5 | COPY prisma ./prisma 6 | 7 | RUN yarn install 8 | RUN npx prisma generate 9 | COPY . . 10 | 11 | ENV NODE_ENV=production 12 | 13 | EXPOSE 8000 14 | CMD ["yarn", "run", "backend"] 15 | -------------------------------------------------------------------------------- /backend/src/utils/jsonResponses.ts: -------------------------------------------------------------------------------- 1 | export const successJson = (data: any) => { 2 | return { 3 | success: true, 4 | data, 5 | }; 6 | }; 7 | 8 | export const errorJson = (error: Error) => { 9 | return { 10 | success: false, 11 | error: error, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230310180636_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Profile" DROP CONSTRAINT "Profile_userId_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240317021038_test/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "EnrollmentStatus" AS ENUM ('PENDING', 'CHECKED_IN', 'CHECKED_OUT', 'REMOVED', 'CANCELED'); 3 | 4 | -- AlterTable 5 | ALTER TABLE "EventEnrollment" ADD COLUMN "attendeeStatus" "EnrollmentStatus" NOT NULL DEFAULT 'PENDING'; 6 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250109214354_remove_worked_hours/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `workedHours` on the `EventEnrollment` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "EventEnrollment" DROP COLUMN "workedHours"; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lfbi", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "setup": "(cd backend && yarn run setup) && (cd frontend && yarn run setup)", 7 | "test": "cd backend && yarn run test", 8 | "start": "(cd backend && yarn run start) & (cd frontend && yarn run start)" 9 | } 10 | } -------------------------------------------------------------------------------- /backend/prisma/migrations/20240313215409_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "AboutPage" ( 3 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 4 | "updatedAt" TIMESTAMP(3) NOT NULL, 5 | "id" TEXT NOT NULL, 6 | "content" TEXT NOT NULL, 7 | 8 | CONSTRAINT "AboutPage_pkey" PRIMARY KEY ("id") 9 | ); 10 | -------------------------------------------------------------------------------- /frontend/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | const Document = () => { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default Document; 16 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | roots: ["tests/"], 6 | transformIgnorePatterns: ["/node_modules/"], 7 | setupFiles: ["dotenv/config"], 8 | moduleFileExtensions: ["js", "ts"], 9 | maxWorkers: 1, 10 | testTimeout: 30000, 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/FetchDataError.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const FetchDataError = () => { 4 | return ( 5 |
6 |
7 | An error occurred while fetching your data. 8 |
9 |
10 | ); 11 | }; 12 | 13 | export default FetchDataError; 14 | -------------------------------------------------------------------------------- /frontend/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | /* Import Inter from Google Fonts */ 2 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap"); 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | body { 9 | margin: 0; 10 | padding: 0; 11 | line-height: 1.5; 12 | font-family: "Inter", sans-serif; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | type Data = { 5 | name: string; 6 | }; 7 | 8 | const handler = (req: NextApiRequest, res: NextApiResponse) => { 9 | res.status(200).json({ name: "John Doe" }); 10 | }; 11 | 12 | export default handler; 13 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250104195833_optional_event_owner/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "Event" DROP CONSTRAINT "Event_ownerId_fkey"; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Event" ALTER COLUMN "ownerId" DROP NOT NULL; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "Event" ADD CONSTRAINT "Event_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | 3 | 4 | 5 | ## Testing 6 | 7 | 8 | 9 | ## Notes 10 | 11 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Chip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Chip } from "@mui/material"; 3 | 4 | interface ChipProps { 5 | label: string; 6 | [key: string]: any; 7 | } 8 | 9 | /** A Chip component is a chip with text inside */ 10 | const CustomChip = ({ label, ...props }: ChipProps) => { 11 | return ; 12 | }; 13 | 14 | export default CustomChip; 15 | -------------------------------------------------------------------------------- /backend/.env.template: -------------------------------------------------------------------------------- 1 | # PostgreSQL database 2 | DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/postgres?schema=public" 3 | 4 | # Firebase Admin 5 | TYPE = "" 6 | PROJECT_ID = "" 7 | PRIVATE_KEY_ID = "" 8 | PRIVATE_KEY = "" 9 | CLIENT_EMAIL = "" 10 | CLIENT_ID = "" 11 | AUTH_URI = "" 12 | TOKEN_URI = "" 13 | AUTH_PROVIDER_x509_CERT_URL = "" 14 | CLIENT_x509_CERT_URL = "" 15 | 16 | # SendGrid 17 | SENDGRID_API_KEY = "" -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | corePlugins: { 5 | preflight: false, 6 | }, 7 | important: "#__next", 8 | theme: { 9 | extend: { 10 | colors: { 11 | primary: { 12 | 100: "#E5E9E0", 13 | 200: "#568124", 14 | }, 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/.env.template: -------------------------------------------------------------------------------- 1 | # Firebase 2 | NEXT_PUBLIC_API_KEY = "" 3 | NEXT_PUBLIC_AUTH_DOMAIN = "" 4 | NEXT_PUBLIC_PROJECT_ID = "" 5 | NEXT_PUBLIC_STORAGE_BUCKET = "" 6 | NEXT_PUBLIC_MESSAGING_SENDER_ID = "" 7 | NEXT_PUBLIC_APP_ID = "" 8 | NEXT_PUBLIC_MEASUREMENT_ID = "" 9 | 10 | # URLs 11 | NEXT_PUBLIC_BASE_URL = "http://localhost:8000" 12 | NEXT_PUBLIC_BASE_WEBSOCKETS_URL = "ws://localhost:8080" 13 | NEXT_PUBLIC_BASE_URL_CLIENT = "http://localhost:3000" 14 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Backdrop from "@mui/material/Backdrop"; 3 | import CircularProgress from "@mui/material/CircularProgress"; 4 | 5 | function Loading() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default Loading; 14 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .env 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | /config 27 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/LinearProgress.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MuiLinearProgress from "@mui/material/LinearProgress"; 3 | 4 | interface LinearProgressProps { 5 | value: number; 6 | } 7 | 8 | const LinearProgress = ({ value }: LinearProgressProps) => { 9 | return ( 10 | 15 | ); 16 | }; 17 | 18 | export default LinearProgress; 19 | -------------------------------------------------------------------------------- /frontend/src/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 3 | import About from "@/components/organisms/About"; 4 | import Head from "next/head"; 5 | 6 | const AboutPage = () => { 7 | return ( 8 | <> 9 | 10 | FAQ - LFBI Volunteer Platform 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | export default AboutPage; 19 | -------------------------------------------------------------------------------- /frontend/src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import LoginForm from "@/components/organisms/LoginForm"; 2 | import React from "react"; 3 | import WelcomeTemplate from "@/components/templates/WelcomeTemplate"; 4 | import Head from "next/head"; 5 | 6 | /** A Login page */ 7 | const Login = () => { 8 | return ( 9 | <> 10 | 11 | Log In - LFBI Volunteer Platform 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Login; 21 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240316044752_about/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `AboutPage` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "AboutPage"; 9 | 10 | -- CreateTable 11 | CREATE TABLE "About" ( 12 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | "updatedAt" TIMESTAMP(3) NOT NULL, 14 | "id" TEXT NOT NULL, 15 | "content" TEXT NOT NULL, 16 | 17 | CONSTRAINT "About_pkey" PRIMARY KEY ("id") 18 | ); 19 | -------------------------------------------------------------------------------- /frontend/src/pages/website.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 3 | import ManageWebsite from "@/components/organisms/ManageWebsite"; 4 | import Head from "next/head"; 5 | 6 | const Website = () => { 7 | return ( 8 | <> 9 | 10 | Manage Website - LFBI Volunteer Platform 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default Website; 20 | -------------------------------------------------------------------------------- /frontend/src/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import WelcomeTemplate from "@/components/templates/WelcomeTemplate"; 3 | import SignupForm from "@/components/organisms/SignupForm"; 4 | import Head from "next/head"; 5 | 6 | /** A Signup page */ 7 | const Signup = () => { 8 | return ( 9 | <> 10 | 11 | Sign Up - LFBI Volunteer Platform 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default Signup; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/verify.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import WelcomeTemplate from "@/components/templates/WelcomeTemplate"; 3 | import VerifyEmailForm from "@/components/organisms/VerifyEmailForm"; 4 | import Head from "next/head"; 5 | 6 | const VerifyEmailPage = () => { 7 | return ( 8 | <> 9 | 10 | Verify Email - LFBI Volunteer Platform 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default VerifyEmailPage; 20 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/IconText.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ReactNode } from "react"; 2 | 3 | interface IconTextProps { 4 | icon: ReactElement; 5 | children: ReactNode; 6 | } 7 | 8 | /** A IconText component is a small line of text prefaced by an icon */ 9 | const IconText = ({ icon, children }: IconTextProps) => { 10 | return ( 11 |
12 |
{icon}
13 |
{children}
14 |
15 | ); 16 | }; 17 | 18 | export default IconText; 19 | -------------------------------------------------------------------------------- /frontend/src/pages/events/view.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ViewEvents from "../../components/organisms/ViewEvents"; 3 | import DefaultTemplate from "../../components/templates/DefaultTemplate"; 4 | import Head from "next/head"; 5 | 6 | /** A ViewEventsPage page */ 7 | const ViewEventsPage = () => { 8 | return ( 9 | <> 10 | 11 | My Events - LFBI Volunteer Platform 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default ViewEventsPage; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/users/view.tsx: -------------------------------------------------------------------------------- 1 | import ManageUsers from "@/components/organisms/ManageUsers"; 2 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 3 | import Head from "next/head"; 4 | import React from "react"; 5 | 6 | /** A Manage Users page */ 7 | const ManageUsersPage = () => { 8 | return ( 9 | <> 10 | 11 | Manage Members - LFBI Volunteer Platform 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default ManageUsersPage; 21 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | First, run the development server: 3 | 4 | ```bash 5 | yarn dev 6 | ``` 7 | 8 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 9 | 10 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 11 | 12 | ## Learn More 13 | 14 | To learn more about Next.js, take a look at the following resources: 15 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 16 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 17 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/EventCardCancelConfirmation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Card from "../molecules/Card"; 3 | 4 | interface EventCardCancelConfirmationProps {} 5 | 6 | const EventCardCancelConfirmation = ({}: EventCardCancelConfirmationProps) => { 7 | return ( 8 | 9 |
You are no longer registered
10 |
11 | We're sorry you can't make it! Thank you for letting us know. 12 |
13 |
14 | ); 15 | }; 16 | 17 | export default EventCardCancelConfirmation; 18 | -------------------------------------------------------------------------------- /frontend/src/pages/password/forgot.tsx: -------------------------------------------------------------------------------- 1 | import ForgotPasswordForm from "@/components/organisms/ForgotPasswordForm"; 2 | import React from "react"; 3 | import WelcomeTemplate from "@/components/templates/WelcomeTemplate"; 4 | import Head from "next/head"; 5 | 6 | /** A ForgotPassword page */ 7 | const ForgotPassword = () => { 8 | return ( 9 | <> 10 | 11 | Forgot Password - LFBI Volunteer Platform 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default ForgotPassword; 21 | -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/pages/events/create.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import EventForm from "@/components/organisms/EventForm"; 3 | import CenteredTemplate from "@/components/templates/CenteredTemplate"; 4 | import Card from "@/components/molecules/Card"; 5 | import Head from "next/head"; 6 | 7 | /** A CreateEvent page */ 8 | const CreateEvent = () => { 9 | return ( 10 | <> 11 | 12 | Create Event - LFBI Volunteer Platform 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default CreateEvent; 24 | -------------------------------------------------------------------------------- /frontend/src/pages/password/[oobCode]/reset.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | // import { useRouter } from "next/router"; 3 | import ResetPasswordForm from "@/components/organisms/ResetPasswordForm"; 4 | import WelcomeTemplate from "@/components/templates/WelcomeTemplate"; 5 | import { useRouter } from "next/router"; 6 | import Head from "next/head"; 7 | 8 | /** A ResetPassword page */ 9 | const ResetPassword = () => { 10 | return ( 11 | <> 12 | 13 | Reset Password - LFBI Volunteer Platform 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default ResetPassword; 23 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 2 | 3 | # Set environment variables to prevent interactive prompts during installation 4 | ENV DEBIAN_FRONTEND=noninteractive 5 | 6 | # Install postgres 7 | RUN apt update && \ 8 | apt install -y postgresql && \ 9 | service postgresql start && \ 10 | su - postgres sh -c "psql -U postgres -d postgres -c \"ALTER USER postgres WITH PASSWORD 'postgres'\"" 11 | 12 | # Install node and yarn 13 | RUN apt install -y git vim wget && \ 14 | touch ~/.bashrc && \ 15 | chmod +x ~/.bashrc && \ 16 | wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash && \ 17 | . ~/.bashrc && \ 18 | nvm install --lts && \ 19 | npm install --global yarn 20 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import FormGroup from "@mui/material/FormGroup"; 3 | import FormControlLabel from "@mui/material/FormControlLabel"; 4 | import Checkbox from "@mui/material/Checkbox"; 5 | 6 | interface CheckboxProps { 7 | label: string; 8 | checked?: boolean; 9 | [key: string]: any; 10 | } 11 | 12 | /** A custom checkbox */ 13 | const CustomCheckbox = ({ label, checked, ...props }: CheckboxProps) => { 14 | return ( 15 | 16 | } 18 | label={label} 19 | {...props} 20 | /> 21 | 22 | ); 23 | }; 24 | 25 | export default CustomCheckbox; 26 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "singleAttributePerLine": false, 8 | "bracketSameLine": false, 9 | "jsxBracketSameLine": false, 10 | "jsxSingleQuote": false, 11 | "printWidth": 80, 12 | "proseWrap": "preserve", 13 | "quoteProps": "as-needed", 14 | "requirePragma": false, 15 | "semi": true, 16 | "singleQuote": false, 17 | "tabWidth": 2, 18 | "trailingComma": "es5", 19 | "useTabs": false, 20 | "vueIndentScriptAndStyle": false, 21 | "filepath": "/Users/archit/Downloads/mern-base/backend/src/index.ts", 22 | "parser": "typescript" 23 | } -------------------------------------------------------------------------------- /backend/prisma/migrations/20230405215058_/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "Event_id_key"; 3 | 4 | -- DropIndex 5 | DROP INDEX "EventEnrollment_eventId_key"; 6 | 7 | -- DropIndex 8 | DROP INDEX "EventEnrollment_userId_key"; 9 | 10 | -- DropIndex 11 | DROP INDEX "EventTags_id_key"; 12 | 13 | -- DropIndex 14 | DROP INDEX "Permission_userId_key"; 15 | 16 | -- DropIndex 17 | DROP INDEX "Profile_userId_key"; 18 | 19 | -- DropIndex 20 | DROP INDEX "User_id_key"; 21 | 22 | -- DropIndex 23 | DROP INDEX "UserPreferences_userId_key"; 24 | 25 | -- AlterTable 26 | ALTER TABLE "Profile" ADD CONSTRAINT "Profile_pkey" PRIMARY KEY ("userId"); 27 | 28 | -- AlterTable 29 | ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_pkey" PRIMARY KEY ("userId"); 30 | -------------------------------------------------------------------------------- /frontend/src/utils/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from "firebase/app"; 2 | import { getAuth } from "firebase/auth"; 3 | import { getAnalytics } from "firebase/analytics"; 4 | 5 | const firebaseConfig = { 6 | apiKey: process.env.NEXT_PUBLIC_API_KEY, 7 | authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN, 8 | projectId: process.env.NEXT_PUBLIC_PROJECT_ID, 9 | storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET, 10 | messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_SENDER_ID, 11 | appId: process.env.NEXT_PUBLIC_APP_ID, 12 | measurementId: process.env.NEXT_PUBLIC_MEASUREMENT_ID, 13 | }; 14 | const app = initializeApp(firebaseConfig); 15 | 16 | /** 17 | * Returns the Auth instance associated with the created Firebase App 18 | */ 19 | export const auth = getAuth(app); 20 | -------------------------------------------------------------------------------- /backend/src/about/controllers.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../client"; 2 | 3 | /** 4 | * Gets all about pages 5 | * @returns the contents of every about page 6 | */ 7 | const getAboutPageContent = async () => { 8 | return prisma.about.findFirst(); 9 | }; 10 | 11 | /** 12 | * Updates the about page's content 13 | * @param pageid about page id 14 | * @param pageContent: new content to be updated 15 | * @returns promise with updated about page or error 16 | */ 17 | const updateAboutPageContent = async (pageid: string, newContent: string) => { 18 | return prisma.about.update({ 19 | where: { id: pageid }, 20 | data: { 21 | content: newContent, 22 | }, 23 | }); 24 | }; 25 | 26 | export default { getAboutPageContent, updateAboutPageContent }; 27 | -------------------------------------------------------------------------------- /frontend/src/pages/password/[oobCode]/recover.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import WelcomeTemplate from "@/components/templates/WelcomeTemplate"; 4 | import RecoverEmailConfirmation from "@/components/organisms/RecoverEmailConfirmation"; 5 | import Head from "next/head"; 6 | 7 | const RecoverEmailConfirmationPage = () => { 8 | const router = useRouter(); 9 | const oobCode = router.query.oobCode as string; 10 | 11 | return ( 12 | <> 13 | 14 | Recover Email - LFBI Volunteer Platform 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default RecoverEmailConfirmationPage; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.env 3 | secrets/ 4 | node_modules/ 5 | .vscode 6 | /config 7 | /frontend/src/config 8 | 9 | 10 | # Optional npm cache directory 11 | .npm 12 | 13 | # TypeScript cache 14 | *.tsbuildinfo 15 | 16 | # Optional eslint cache 17 | .eslintcache 18 | 19 | # Output of 'npm pack' 20 | *.tgz 21 | 22 | # dotenv environment variable files 23 | 24 | .env.development.local 25 | .env.test.local 26 | .env.production.local 27 | .env.production 28 | .env.local 29 | 30 | # Yarn Integrity file 31 | .yarn-integrity 32 | 33 | # Next.js build output 34 | .next 35 | out 36 | 37 | # Stores VSCode versions used for testing VSCode extensions 38 | .vscode-test 39 | 40 | # yarn v2 41 | .yarn/cache 42 | .yarn/unplugged 43 | .yarn/build-state.yml 44 | .yarn/install-state.gz 45 | .pnp.* 46 | 47 | dist/ -------------------------------------------------------------------------------- /frontend/src/pages/password/[oobCode]/verify.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import WelcomeTemplate from "@/components/templates/WelcomeTemplate"; 4 | import VerifyEmailConfirmation from "@/components/organisms/VerifyEmailConfirmation"; 5 | import Head from "next/head"; 6 | 7 | const VerifyEmailConfirmationPage = () => { 8 | const router = useRouter(); 9 | const oobCode = router.query.oobCode as string; 10 | 11 | return ( 12 | <> 13 | 14 | Verify Email Confirmation - LFBI Volunteer Platform 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default VerifyEmailConfirmationPage; 24 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250102030506_event_status/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The values [DRAFT,COMPLETED] on the enum `EventStatus` will be removed. If these variants are still used in the database, this will fail. 5 | 6 | */ 7 | -- AlterEnum 8 | BEGIN; 9 | CREATE TYPE "EventStatus_new" AS ENUM ('ACTIVE', 'CANCELED'); 10 | ALTER TABLE "Event" ALTER COLUMN "status" DROP DEFAULT; 11 | ALTER TABLE "Event" ALTER COLUMN "status" TYPE "EventStatus_new" USING ("status"::text::"EventStatus_new"); 12 | ALTER TYPE "EventStatus" RENAME TO "EventStatus_old"; 13 | ALTER TYPE "EventStatus_new" RENAME TO "EventStatus"; 14 | DROP TYPE "EventStatus_old"; 15 | ALTER TABLE "Event" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; 16 | COMMIT; 17 | 18 | -- AlterTable 19 | ALTER TABLE "Event" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; 20 | -------------------------------------------------------------------------------- /frontend/src/pages/password/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useRouter } from "next/router"; 3 | import Loading from "@/components/molecules/Loading"; 4 | 5 | const Password = () => { 6 | const router = useRouter(); 7 | const { mode, oobCode } = router.query; 8 | 9 | useEffect(() => { 10 | switch (mode) { 11 | case "resetPassword": 12 | router.push(`/password/${oobCode}/reset`); 13 | break; 14 | case "verifyEmail": 15 | router.push(`/password/${oobCode}/verify`); 16 | break; 17 | case "recoverEmail": 18 | router.push(`/password/${oobCode}/recover`); 19 | break; 20 | default: 21 | router.push("/login"); 22 | } 23 | }, [mode, oobCode, router]); 24 | 25 | return ( 26 |
27 | 28 |
29 | ); 30 | }; 31 | 32 | export default Password; 33 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Card.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | 3 | interface CardProps { 4 | children: ReactNode; 5 | color?: "white" | "inherit"; 6 | size?: "small" | "medium" | "table"; 7 | className?: string; 8 | } 9 | 10 | const Card = ({ 11 | children, 12 | color = "white", 13 | size = "small", 14 | className = "", 15 | }: CardProps) => { 16 | // Set color 17 | let bg; 18 | if (color === "white") { 19 | bg = "bg-white"; 20 | } 21 | 22 | // Set size 23 | let styles; 24 | switch (size) { 25 | case "small": 26 | styles = "p-6 rounded-2xl"; 27 | break; 28 | case "medium": 29 | styles = "p-6 sm:p-12 rounded-3xl"; 30 | break; 31 | case "table": 32 | styles = "py-1 px-3 rounded-2xl"; 33 | break; 34 | } 35 | return
{children}
; 36 | }; 37 | 38 | export default Card; 39 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/IconTextHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, ReactNode } from "react"; 2 | 3 | interface IconTextHeaderProps { 4 | icon: ReactElement; 5 | header?: ReactNode; 6 | body?: ReactNode; 7 | } 8 | 9 | /** A IconTextHeader component is a small line of text prefaced by an icon */ 10 | const IconTextHeader = ({ icon, header, body }: IconTextHeaderProps) => { 11 | return ( 12 |
13 |
14 |
15 | {icon} 16 |
17 |
18 |
19 |
20 |
{header}
21 |
{body}
22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default IconTextHeader; 29 | -------------------------------------------------------------------------------- /backend/src/website/views.ts: -------------------------------------------------------------------------------- 1 | import { Router, RequestHandler, Request, Response } from "express"; 2 | import websiteController from "./controllers"; 3 | import { 4 | auth, 5 | setVolunteerCustomClaims, 6 | NoAuth, 7 | authIfAdmin, 8 | } from "../middleware/auth"; 9 | import { attempt } from "../utils/helpers"; 10 | const websiteRouter = Router(); 11 | 12 | let useAuth: RequestHandler; 13 | let useAdminAuth: RequestHandler; 14 | 15 | process.env.NODE_ENV === "test" 16 | ? ((useAuth = NoAuth as RequestHandler), 17 | (useAdminAuth = authIfAdmin as RequestHandler)) 18 | : ((useAuth = auth as RequestHandler), 19 | (useAdminAuth = authIfAdmin as RequestHandler)); 20 | 21 | websiteRouter.get( 22 | "/download", 23 | useAdminAuth, 24 | async (req: Request, res: Response) => { 25 | // #swagger.tags = ['Website'] 26 | attempt(res, 200, () => websiteController.downloadAllWebsiteData()); 27 | } 28 | ); 29 | 30 | export default websiteRouter; 31 | -------------------------------------------------------------------------------- /backend/src/swagger.ts: -------------------------------------------------------------------------------- 1 | import swaggerAutogen from "swagger-autogen"; 2 | 3 | const doc = { 4 | info: { 5 | version: "1.0.0", 6 | title: "My API", 7 | description: 8 | "Documentation automatically generated by the swagger-autogen module.", 9 | }, 10 | host: "localhost:8000", 11 | basePath: "/", 12 | schemes: ["http", "https"], 13 | consumes: ["application/json"], 14 | produces: ["application/json"], 15 | tags: [ 16 | { 17 | name: "User", 18 | description: "Endpoints", 19 | }, 20 | ], 21 | securityDefinitions: { 22 | apiKeyAuth: { 23 | type: "apiKey", 24 | in: "header", // can be "header", "query" or "cookie" 25 | name: "X-API-KEY", // name of the header, query parameter or cookie 26 | description: "any description...", 27 | }, 28 | }, 29 | }; 30 | 31 | const outputFile = "../api-spec.json"; 32 | const endpointsFiles = ["./server.ts"]; 33 | 34 | swaggerAutogen()(outputFile, endpointsFiles, doc); 35 | -------------------------------------------------------------------------------- /frontend/src/components/templates/CenteredTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import NavBar from "@/components/organisms/NavBar"; 3 | import { useAuth } from "@/utils/AuthContext"; 4 | import Loading from "../molecules/Loading"; 5 | import Footer from "../molecules/Footer"; 6 | 7 | /** A CenteredTemplate page */ 8 | interface CenteredTemplateProps { 9 | children: ReactNode; 10 | } 11 | 12 | const CenteredTemplate = ({ children }: CenteredTemplateProps) => { 13 | const { loading, isAuthenticated } = useAuth(); 14 | const hideContent = loading || !isAuthenticated; 15 | 16 | return ( 17 |
18 |
19 | 20 |
21 | {hideContent ? : children} 22 |
23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | export default CenteredTemplate; 30 | -------------------------------------------------------------------------------- /frontend/src/components/templates/DefaultTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import NavBar from "@/components/organisms/NavBar"; 3 | import { useAuth } from "@/utils/AuthContext"; 4 | import Loading from "../molecules/Loading"; 5 | import Footer from "../molecules/Footer"; 6 | 7 | interface DefaultTemplateProps { 8 | children: ReactNode; 9 | } 10 | 11 | /** A DefaultTemplate page */ 12 | const DefaultTemplate = ({ children }: DefaultTemplateProps) => { 13 | const { loading, isAuthenticated } = useAuth(); 14 | const hideContent = loading || !isAuthenticated; 15 | 16 | return ( 17 |
18 |
19 | 20 |
21 | {hideContent ? : children} 22 |
23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | export default DefaultTemplate; 30 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/MultilineTextField.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref } from "react"; 2 | import TextField from "@mui/material/TextField"; 3 | 4 | interface MultilineTextFieldProps { 5 | label: string; 6 | error?: string; 7 | [key: string]: any; 8 | } 9 | 10 | /** A text field with multiple lines */ 11 | const MultilineTextField = forwardRef( 12 | ( 13 | { label, error = "", ...props }: Omit, 14 | ref: Ref 15 | ) => { 16 | return ( 17 |
18 |
{label}
19 | 32 |
{error}
33 |
34 | ); 35 | } 36 | ); 37 | 38 | export default MultilineTextField; 39 | -------------------------------------------------------------------------------- /frontend/src/components/templates/WelcomeTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { useAuth } from "@/utils/AuthContext"; 3 | 4 | /** A WelcomeTemplate page */ 5 | interface WelcomeTemplateProps { 6 | children: ReactNode; 7 | } 8 | 9 | const WelcomeTemplate = ({ children }: WelcomeTemplateProps) => { 10 | const { loading, isAuthenticated } = useAuth(); 11 | const hideContent = loading || !isAuthenticated; 12 | 13 | return ( 14 |
15 | {/* Left */} 16 |
17 |
{hideContent ?
: children}
18 |
19 | 20 | {/* Right */} 21 |
22 | splash 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default WelcomeTemplate; 33 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MuiAvatar from "@mui/material/Avatar"; 3 | import PersonIcon from "@mui/icons-material/Person"; 4 | import { stringAvatar } from "@/utils/helpers"; 5 | 6 | interface AvatarProps { 7 | name: string; 8 | image?: string; 9 | startDate: Date; 10 | email?: string; 11 | phone?: string; 12 | } 13 | 14 | const Avatar = ({ image, name, startDate, email, phone }: AvatarProps) => { 15 | return ( 16 |
17 |
18 | 19 |
20 |
21 |
{name}
22 |
23 | {/* Nigeria uses DD/MM/YY */} 24 | Joined {startDate.toLocaleDateString("en-GB")} 25 |
26 |
{email}
27 |
{phone}
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default Avatar; 34 | -------------------------------------------------------------------------------- /frontend/public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/CardList.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, Children } from "react"; 2 | 3 | interface CardListProps { 4 | children: ReactNode; 5 | } 6 | 7 | /** 8 | * A CardList component is a responsive list of cards that wraps all the cards 9 | * contained within 10 | */ 11 | const CardList = ({ children }: CardListProps) => { 12 | const cards = Children.toArray(children); 13 | return ( 14 | <> 15 | {/* Show only on large screens */} 16 |
17 | {cards.map((card, index) => { 18 | return ( 19 |
20 | {card} 21 |
22 | ); 23 | })} 24 |
25 | 26 | {/* Show only on small screens */} 27 |
28 | {cards.map((card, index) => { 29 | return ( 30 |
31 | {card} 32 |
33 | ); 34 | })} 35 |
36 | 37 | ); 38 | }; 39 | 40 | export default CardList; 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hack4Impact Cornell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230207055813_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Profile` table. If the table is not empty, all the data it contains will be lost. 5 | - Added the required column `updatedAt` to the `Post` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE "Post" DROP CONSTRAINT "Post_authorId_fkey"; 10 | 11 | -- DropForeignKey 12 | ALTER TABLE "Profile" DROP CONSTRAINT "Profile_userId_fkey"; 13 | 14 | -- AlterTable 15 | ALTER TABLE "Post" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL, 16 | ADD COLUMN "viewCount" INTEGER NOT NULL DEFAULT 0, 17 | ALTER COLUMN "title" SET DATA TYPE TEXT, 18 | ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMP(3), 19 | ALTER COLUMN "authorId" DROP NOT NULL; 20 | 21 | -- AlterTable 22 | ALTER TABLE "User" ALTER COLUMN "name" SET DATA TYPE TEXT, 23 | ALTER COLUMN "email" SET DATA TYPE TEXT; 24 | 25 | -- DropTable 26 | DROP TABLE "Profile"; 27 | 28 | -- AddForeignKey 29 | ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; 30 | -------------------------------------------------------------------------------- /backend/src/website/controllers.ts: -------------------------------------------------------------------------------- 1 | // We are using one connection to prisma client to prevent multiple connections 2 | import prisma from "../../client"; 3 | 4 | const downloadAllWebsiteData = async () => { 5 | const getAllUsers = await prisma.user.findMany({ 6 | include: { 7 | profile: true, 8 | preferences: true, 9 | events: true, 10 | }, 11 | }); 12 | 13 | const getAlEvents = await prisma.event.findMany({}); 14 | 15 | const getAllEnrollments = await prisma.eventEnrollment.findMany({ 16 | include: { 17 | event: { 18 | select: { 19 | name: true, 20 | description: true, 21 | location: true, 22 | startDate: true, 23 | endDate: true, 24 | }, 25 | }, 26 | user: { 27 | select: { 28 | email: true, 29 | profile: { 30 | select: { 31 | firstName: true, 32 | lastName: true, 33 | phoneNumber: true, 34 | }, 35 | }, 36 | }, 37 | }, 38 | }, 39 | }); 40 | 41 | return Promise.all([getAllUsers, getAlEvents, getAllEnrollments]); 42 | }; 43 | 44 | export default { downloadAllWebsiteData }; 45 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TextField.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref } from "react"; 2 | import MuiTextField from "@mui/material/TextField"; 3 | 4 | interface TextFieldProps { 5 | label: string; 6 | type?: string; 7 | error?: string; 8 | [key: string]: any; 9 | } 10 | 11 | /** A simple text field */ 12 | const TextField = forwardRef( 13 | ( 14 | { label, type = "text", error = "", ...props }: Omit, 15 | ref: Ref 16 | ) => { 17 | return ( 18 |
19 |
{label}
20 | 37 |
{error}
38 |
39 | ); 40 | } 41 | ); 42 | 43 | export default TextField; 44 | -------------------------------------------------------------------------------- /frontend/src/pages/events/[eventid]/attendees.tsx: -------------------------------------------------------------------------------- 1 | import ManageAttendees from "@/components/organisms/ManageAttendees"; 2 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 3 | import { api } from "@/utils/api"; 4 | import { useQuery } from "@tanstack/react-query"; 5 | import Head from "next/head"; 6 | import { useRouter } from "next/router"; 7 | import React from "react"; 8 | 9 | /** A page for managing attendees */ 10 | const ManageAttendeesPage = () => { 11 | const router = useRouter(); 12 | const eventid = router.query.eventid as string; 13 | 14 | /** Tanstack query for fetching event name */ 15 | const { data, isLoading, isError, error } = useQuery({ 16 | queryKey: ["title", eventid], 17 | queryFn: async () => { 18 | const { data } = await api.get(`/events/${eventid}`); 19 | return data["data"]; 20 | }, 21 | }); 22 | 23 | return ( 24 | <> 25 | {data?.name && ( 26 | 27 | 28 | {data.name} - Manage Attendees - LFBI Volunteer Platform 29 | 30 | 31 | )} 32 | 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default ManageAttendeesPage; 40 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240117194255_on_delete/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "EventEnrollment" DROP CONSTRAINT "EventEnrollment_eventId_fkey"; 3 | 4 | -- DropForeignKey 5 | ALTER TABLE "EventEnrollment" DROP CONSTRAINT "EventEnrollment_userId_fkey"; 6 | 7 | -- DropForeignKey 8 | ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; 9 | 10 | -- DropForeignKey 11 | ALTER TABLE "UserPreferences" DROP CONSTRAINT "UserPreferences_userId_fkey"; 12 | 13 | -- AlterTable 14 | ALTER TABLE "Profile" ADD COLUMN "phoneNumber" TEXT; 15 | 16 | -- AddForeignKey 17 | ALTER TABLE "UserPreferences" ADD CONSTRAINT "UserPreferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 18 | 19 | -- AddForeignKey 20 | ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 21 | 22 | -- AddForeignKey 23 | ALTER TABLE "EventEnrollment" ADD CONSTRAINT "EventEnrollment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; 24 | 25 | -- AddForeignKey 26 | ALTER TABLE "EventEnrollment" ADD CONSTRAINT "EventEnrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 27 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Snackbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import MuiSnackbar from "@mui/material/Snackbar"; 3 | import Alert from "./Alert"; 4 | import Slide, { SlideProps } from "@mui/material/Slide"; 5 | 6 | type TransitionProps = Omit; 7 | 8 | interface SnackbarProps { 9 | children: ReactNode; 10 | variety: "success" | "error"; 11 | open: boolean; 12 | onClose: () => void; 13 | [key: string]: any; 14 | } 15 | 16 | const TransitionDown = (props: TransitionProps) => { 17 | return ; 18 | }; 19 | 20 | /** A Snackbar floats alerts on top of the screen */ 21 | const Snackbar = ({ 22 | children, 23 | variety, 24 | open, 25 | onClose, 26 | ...props 27 | }: SnackbarProps) => { 28 | return ( 29 | null }} 36 | {...props} 37 | > 38 | 39 | {children} 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default Snackbar; 46 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | 4 | const Footer = () => { 5 | return ( 6 |
7 |
8 |
9 |
© 2025 Hack4Impact Cornell
10 |
11 | 15 | Terms of Service 16 | {" "} 17 | •{" "} 18 | 22 | Privacy Policy 23 | {" "} 24 | •{" "} 25 | 29 | Contact Us 30 | 31 |
32 |
33 |
34 |
35 | ); 36 | }; 37 | 38 | export default Footer; 39 | -------------------------------------------------------------------------------- /frontend/src/pages/users/[userid]/manage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ManageUserProfile from "@/components/organisms/ManageUserProfile"; 3 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 4 | import Head from "next/head"; 5 | import { useRouter } from "next/router"; 6 | import { useQuery } from "@tanstack/react-query"; 7 | import { api } from "@/utils/api"; 8 | 9 | /** A Manage User Profile page */ 10 | const ManageUserProfilePage = () => { 11 | const router = useRouter(); 12 | const userid = router.query.userid as string; 13 | 14 | /** Tanstack query for fetching event name */ 15 | const { data, isLoading, isError, error } = useQuery({ 16 | queryKey: ["title", userid], 17 | queryFn: async () => { 18 | const { data } = await api.get(`/users/${userid}`); 19 | console.log(data); 20 | return data["data"]["profile"]; 21 | }, 22 | }); 23 | 24 | return ( 25 | <> 26 | {data && ( 27 | 28 | 29 | {data.firstName} {data.lastName} - Manage User Profile - LFBI 30 | Volunteer Platform 31 | 32 | 33 | )} 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default ManageUserProfilePage; 42 | -------------------------------------------------------------------------------- /backend/prisma/migrations/0_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" SERIAL NOT NULL, 4 | "title" VARCHAR(255) NOT NULL, 5 | "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | "content" TEXT, 7 | "published" BOOLEAN NOT NULL DEFAULT false, 8 | "authorId" INTEGER NOT NULL, 9 | 10 | CONSTRAINT "Post_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "Profile" ( 15 | "id" SERIAL NOT NULL, 16 | "bio" TEXT, 17 | "userId" INTEGER NOT NULL, 18 | 19 | CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "User" ( 24 | "id" SERIAL NOT NULL, 25 | "name" VARCHAR(255), 26 | "email" VARCHAR(255) NOT NULL, 27 | 28 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 29 | ); 30 | 31 | -- CreateIndex 32 | CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId"); 33 | 34 | -- CreateIndex 35 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 36 | 37 | -- AddForeignKey 38 | ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 39 | 40 | -- AddForeignKey 41 | ALTER TABLE "Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 42 | 43 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/about/views.ts: -------------------------------------------------------------------------------- 1 | import { Router, RequestHandler, Request, Response } from "express"; 2 | import { 3 | auth, 4 | NoAuth, 5 | authIfAdmin, 6 | authIfSupervisorOrAdmin, 7 | } from "../middleware/auth"; 8 | import { attempt } from "../utils/helpers"; 9 | import aboutController from "./controllers"; 10 | 11 | const aboutRouter = Router(); 12 | 13 | let useAuth: RequestHandler; 14 | let useAdminAuth: RequestHandler; 15 | let useSupervisorAuth: RequestHandler; 16 | 17 | process.env.NODE_ENV === "test" 18 | ? ((useAuth = NoAuth as RequestHandler), 19 | (useAdminAuth = NoAuth as RequestHandler), 20 | (useSupervisorAuth = NoAuth as RequestHandler)) 21 | : ((useAuth = auth as RequestHandler), 22 | (useAdminAuth = authIfAdmin as RequestHandler), 23 | (useSupervisorAuth = authIfSupervisorOrAdmin as RequestHandler)); 24 | 25 | aboutRouter.get("/", useAuth, async (req: Request, res: Response) => { 26 | attempt(res, 200, () => aboutController.getAboutPageContent()); 27 | }); 28 | 29 | aboutRouter.patch( 30 | "/:pageid", 31 | useAdminAuth, 32 | async (req: Request, res: Response) => { 33 | const { newContent } = req.body; 34 | attempt(res, 200, () => 35 | aboutController.updateAboutPageContent(req.params.pageid, newContent) 36 | ); 37 | } 38 | ); 39 | 40 | export default aboutRouter; 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Your workflow name. 2 | name: Deploy to heroku. 3 | 4 | # Run workflow on every push to master branch. 5 | on: 6 | push: 7 | branches: [main] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Setup Node.js environment to run migrations 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: "18.x" 23 | 24 | - name: Install dependencies and run migrations 25 | working-directory: backend/ 26 | env: 27 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 28 | run: | 29 | yarn install 30 | npx prisma migrate deploy 31 | 32 | - name: Build, Push and Release a Docker container to Heroku. 33 | env: 34 | ENV_FILE: ${{ secrets.ENV_FILE }} 35 | uses: gonuit/heroku-docker-deploy@v1.3.3 36 | with: 37 | email: ${{ secrets.HEROKU_EMAIL }} 38 | 39 | heroku_api_key: ${{ secrets.HEROKU_API_KEY }} 40 | 41 | heroku_app_name: ${{ secrets.HEROKU_APP_NAME }} 42 | 43 | dockerfile_directory: backend/ 44 | 45 | dockerfile_name: Dockerfile.deploy 46 | 47 | docker_options: "--platform linux/amd64 --env-file <(echo '$ENV_FILE')" 48 | 49 | process_type: web 50 | -------------------------------------------------------------------------------- /frontend/src/components/templates/EventTemplate.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import DefaultTemplate from "./DefaultTemplate"; 3 | 4 | interface EventTemplateProps { 5 | header: ReactNode; 6 | body: ReactNode; 7 | img: ReactNode; 8 | card: ReactNode; 9 | } 10 | 11 | /** 12 | * A EventTemplate page. Note: this template only includes the layout unique to 13 | * the event registration page. In order to get the background gradient and navbar, 14 | * wrap the entire component in a DefaultTemplate 15 | */ 16 | const EventTemplate = ({ header, body, img, card }: EventTemplateProps) => { 17 | return ( 18 |
19 | {/* DESKTOP VIEW */} 20 |
21 | {/* Left column */} 22 |
23 | {header} 24 | {body} 25 |
26 | 27 | {/* Right column */} 28 |
29 |
{img}
30 |
{card}
31 |
32 |
33 | 34 | {/* MOBILE VIEW */} 35 |
36 |
{header}
37 |
{img}
38 |
{card}
39 |
{body}
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default EventTemplate; 46 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Modal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Modal, Grid, Backdrop, Fade, Box, Typography } from "@mui/material"; 3 | 4 | interface CustomModalProps { 5 | open: boolean; 6 | handleClose: () => void; 7 | children: React.ReactElement; 8 | } 9 | 10 | const style = { 11 | position: "absolute" as "absolute", 12 | top: "50%", 13 | left: "50%", 14 | transform: "translate(-50%, -50%)", 15 | bgcolor: "background.paper", 16 | borderRadius: "1rem", 17 | fontweight: "bold", 18 | boxShadow: 4, 19 | }; 20 | 21 | const styleSmall = { 22 | overflow: "scroll", 23 | maxHeight: "80%", 24 | width: "80%", 25 | padding: 4, 26 | display: { xs: "block", md: "none" }, 27 | }; 28 | 29 | const styleLarge = { 30 | overflow: "scroll", 31 | maxHeight: "80%", 32 | width: 500, 33 | padding: "4rem 6rem", 34 | display: { xs: "none", md: "block" }, 35 | }; 36 | 37 | /** 38 | * A Modal component is a styled modal that takes in a body component 39 | */ 40 | const CustomModal = ({ open, handleClose, children }: CustomModalProps) => { 41 | return ( 42 | 49 |
50 | {children} 51 | {children} 52 |
53 |
54 | ); 55 | }; 56 | 57 | export default CustomModal; 58 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lfbi", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "setup": "yarn install", 7 | "start": "yarn run dev", 8 | "dev": "next dev", 9 | "build": "next build", 10 | "start:local": "next start", 11 | "lint:local": "next lint" 12 | }, 13 | "dependencies": { 14 | "@emotion/react": "^11.11.3", 15 | "@emotion/styled": "^11.11.0", 16 | "@mdxeditor/editor": "^3.20.0", 17 | "@mui/icons-material": "^5.15.8", 18 | "@mui/lab": "^5.0.0-alpha.164", 19 | "@mui/material": "^5.15.7", 20 | "@mui/x-data-grid": "^6.19.3", 21 | "@mui/x-date-pickers": "^6.19.3", 22 | "@tanstack/react-query": "^5.18.1", 23 | "@types/autosuggest-highlight": "^3.2.3", 24 | "@types/node": "^20.11.16", 25 | "@types/react": "^18.2.55", 26 | "@types/react-dom": "^18.2.19", 27 | "@types/react-html-parser": "^2.0.6", 28 | "autosuggest-highlight": "^3.3.4", 29 | "date-fns": "^3.3.1", 30 | "dayjs": "^1.11.10", 31 | "eslint": "^8.56.0", 32 | "eslint-config-next": "^14.1.0", 33 | "firebase": "^10.8.0", 34 | "jszip": "^3.10.1", 35 | "next": "^14.1.0", 36 | "papaparse": "^5.4.1", 37 | "react": "^18.2.0", 38 | "react-dom": "^18.2.0", 39 | "react-firebase-hooks": "^5.1.1", 40 | "react-hook-form": "^7.50.1", 41 | "react-markdown": "^9.0.1", 42 | "react-use-websocket": "^4.8.1", 43 | "typescript": "^5.3.3" 44 | }, 45 | "devDependencies": { 46 | "@types/papaparse": "^5.3.14", 47 | "autoprefixer": "^10.4.17", 48 | "postcss": "^8.4.35", 49 | "tailwindcss": "^3.4.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/pages/events/[eventid]/register.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import CenteredTemplate from "@/components/templates/CenteredTemplate"; 4 | import { useAuth } from "@/utils/AuthContext"; 5 | import { fetchUserIdFromDatabase, formatDateTimeRange } from "@/utils/helpers"; 6 | import Loading from "@/components/molecules/Loading"; 7 | import { EventData } from "@/utils/types"; 8 | import { api } from "@/utils/api"; 9 | import { useQuery } from "@tanstack/react-query"; 10 | import FetchDataError from "@/components/organisms/FetchDataError"; 11 | import ViewEventDetails from "@/components/organisms/ViewEventDetails"; 12 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 13 | import Head from "next/head"; 14 | 15 | /** An EventRegistration page */ 16 | const EventRegistration = () => { 17 | const router = useRouter(); 18 | const eventid = router.query.eventid as string; 19 | 20 | /** Tanstack query for fetching event name */ 21 | const { data, isLoading, isError, error } = useQuery({ 22 | queryKey: ["title", eventid], 23 | queryFn: async () => { 24 | const { data } = await api.get(`/events/${eventid}`); 25 | return data["data"]; 26 | }, 27 | }); 28 | 29 | return ( 30 | <> 31 | {data?.name && ( 32 | 33 | 34 | {data.name} - Event Registration - LFBI Volunteer Platform 35 | 36 | 37 | )} 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default EventRegistration; 46 | -------------------------------------------------------------------------------- /backend/src/server.ts: -------------------------------------------------------------------------------- 1 | import express, { Application } from "express"; 2 | import bodyParser from "body-parser"; 3 | import userRouter from "./users/views"; 4 | import aboutRouter from "./about/views"; 5 | import eventRouter from "./events/views"; 6 | import websiteRouter from "./website/views"; 7 | import swaggerUI from "swagger-ui-express"; 8 | import spec from "../api-spec.json"; 9 | import cors from "cors"; 10 | import cron from "node-cron"; 11 | import { deleteUnverifiedUsers } from "./utils/helpers"; 12 | import { WebSocketServer } from "ws"; 13 | 14 | export const app: Application = express(); 15 | export const wss = new WebSocketServer({ port: 8080 }); 16 | 17 | // Scheduled cron jobs 18 | 19 | if (process.env.NODE_ENV !== `test`) { 20 | cron.schedule("0 0 * * *", () => { 21 | console.log("running a task every 24 hours"); 22 | // deleteUnverifiedUsers(); 23 | }); 24 | } 25 | 26 | // Middleware to parse json request bodies 27 | app.use(bodyParser.json()); 28 | app.use("/api-docs", swaggerUI.serve, swaggerUI.setup(spec)); 29 | 30 | // Middleware to allow cross-origin requests 31 | app.use(cors()); 32 | 33 | /** 34 | * Sub-routers for our main router, we should have one sub-router per "entity" in the application 35 | */ 36 | app.use("/users", userRouter); 37 | app.use("/events", eventRouter); 38 | app.use("/website", websiteRouter); 39 | app.use("/about", aboutRouter); 40 | 41 | // Root Url 42 | app.get("/", (req, res) => { 43 | res.send("Hello World!").status(200); 44 | }); 45 | 46 | // Default route for endpoints not defined 47 | app.get("*", (req, res) => { 48 | res.send("You have reached a route not defined in this API"); 49 | }); 50 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import { app, wss } from "./server"; 2 | import admin from "firebase-admin"; 3 | import sgMail from "@sendgrid/mail"; 4 | 5 | // WebSocket server 6 | wss.on("connection", (ws) => { 7 | // Error handling 8 | ws.on("error", console.error); 9 | 10 | // What happens when the server receives data 11 | ws.on("message", (data) => { 12 | console.log("received: %s", data); 13 | ws.send("" + data); 14 | }); 15 | 16 | // Default message to send when connected 17 | ws.send("something"); 18 | }); 19 | 20 | // Express server 21 | export const server = app.listen(process.env.PORT || 8000); 22 | 23 | if (process.env.NODE_ENV !== `test`) { 24 | const serviceAccount = { 25 | type: process.env.TYPE, 26 | project_id: process.env.PROJECT_ID, 27 | private_key_id: process.env.PRIVATE_KEY_ID, 28 | private_key: process.env.PRIVATE_KEY?.replace(/\\n/g, "\n"), 29 | client_email: process.env.CLIENT_EMAIL, 30 | client_id: process.env.CLIENT_ID, 31 | auth_uri: process.env.AUTH_URI, 32 | token_uri: process.env.TOKEN_URI, 33 | auth_provider_x509_cert_url: process.env.AUTH_PROVIDER_x509_CERT_URL, 34 | client_x509_cert_url: process.env.CLIENT_x509_CERT_URL, 35 | }; 36 | 37 | admin.initializeApp({ 38 | credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), 39 | }); 40 | 41 | sgMail.setApiKey(process.env.SENDGRID_API_KEY as string); 42 | } 43 | 44 | server.on("listening", () => { 45 | console.log("✅ Server is up and running at http://localhost:8000"); 46 | }); 47 | 48 | server.on("error", (error) => { 49 | console.log("❌ Server failed to start due to error: %s", error); 50 | }); 51 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Alert.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, forwardRef, Ref } from "react"; 2 | import MuiAlert from "@mui/material/Alert"; 3 | import CheckCircleIcon from "@mui/icons-material/CheckCircle"; 4 | import CancelIcon from "@mui/icons-material/Cancel"; 5 | import ErrorIcon from "@mui/icons-material/Error"; 6 | 7 | interface AlertProps { 8 | children: ReactNode; 9 | variety: "success" | "error" | "warning"; 10 | onClose: () => void; 11 | [key: string]: any; 12 | } 13 | 14 | /** A simple Alert component */ 15 | const Alert = forwardRef( 16 | ( 17 | { children, variety, onClose, ...props }: Omit, 18 | ref: Ref 19 | ) => { 20 | // Set alert variety 21 | let bgcolor = ""; 22 | let icon; 23 | switch (variety) { 24 | case "success": 25 | bgcolor = "primary.light"; 26 | icon = ; 27 | break; 28 | case "error": 29 | bgcolor = "error.light"; 30 | icon = ; 31 | break; 32 | case "warning": 33 | bgcolor = "warning.light"; 34 | icon = ; 35 | } 36 | 37 | return ( 38 | 51 | {children} 52 | 53 | ); 54 | } 55 | ); 56 | 57 | export default Alert; 58 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/DatePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref } from "react"; 2 | import { DatePicker } from "@mui/x-date-pickers/DatePicker"; 3 | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; 4 | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 5 | import dayjs from "dayjs"; 6 | 7 | interface DatePickerProps { 8 | label: string; 9 | value?: string; 10 | error?: string; 11 | [key: string]: any; 12 | } 13 | 14 | /** 15 | * A DatePicker component is an input field that allows selecting a specific 16 | * date through a calendar popup 17 | */ 18 | const CustomDatePicker = forwardRef( 19 | ( 20 | { label, error = "", value, ...props }: Omit, 21 | ref: Ref 22 | ) => { 23 | return ( 24 |
25 |
{label}
26 | 27 | 45 | 46 |
{error}
47 |
48 | ); 49 | } 50 | ); 51 | 52 | export default CustomDatePicker; 53 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import AppBar from "@/components/molecules/AppBar"; 3 | import { useAuth } from "@/utils/AuthContext"; 4 | import { auth } from "@/utils/firebase"; 5 | import { useRouter } from "next/router"; 6 | import { useQueryClient } from "@tanstack/react-query"; 7 | 8 | const NavBar = () => { 9 | const { user, role, loading, error, signOutUser } = useAuth(); 10 | const queryClient = useQueryClient(); 11 | const router = useRouter(); 12 | 13 | const handleSignOut = async () => { 14 | try { 15 | localStorage.setItem("seeAllEvents", "false"); // clear local storage setting 16 | await signOutUser(); 17 | queryClient.clear(); // Clear cache for react query 18 | router.replace("/login"); 19 | } catch (error) { 20 | console.log(error); 21 | } 22 | }; 23 | 24 | const [loginStatus, setLoginStatus] = useState(false); 25 | 26 | useEffect(() => { 27 | setLoginStatus(!(auth.currentUser == null)); 28 | }); 29 | 30 | let navs = [ 31 | { label: "My Events", link: "/events/view" }, 32 | { label: "FAQ", link: "/about" }, 33 | { label: "Profile", link: "/profile" }, 34 | ]; 35 | 36 | if (role === "Admin") { 37 | navs = [ 38 | { label: "My Events", link: "/events/view" }, 39 | { label: "Manage Members", link: "/users/view" }, 40 | { label: "Manage Website", link: "/website" }, 41 | { label: "FAQ", link: "/about" }, 42 | { label: "Profile", link: "/profile" }, 43 | ]; 44 | } 45 | 46 | const buttons = [{ label: "Log Out", onClick: handleSignOut }]; 47 | 48 | return ; 49 | }; 50 | 51 | export default NavBar; 52 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TimePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref } from "react"; 2 | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; 3 | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; 4 | import { TimePicker } from "@mui/x-date-pickers/TimePicker"; 5 | import dayjs from "dayjs"; 6 | 7 | interface TimePickerProps { 8 | label: string; 9 | value?: string; 10 | error?: string; 11 | disablePast?: boolean; 12 | [key: string]: any; 13 | } 14 | 15 | /** 16 | * A TimePicker component is an input field that allows selecting different 17 | * times of day. 18 | */ 19 | const CustomTimePicker = forwardRef( 20 | ( 21 | { 22 | label, 23 | value, 24 | error = "", 25 | disablePast = false, 26 | ...props 27 | }: Omit, 28 | ref: Ref 29 | ) => { 30 | return ( 31 |
32 |
{label}
33 | 34 | 50 | 51 |
{error}
52 |
53 | ); 54 | } 55 | ); 56 | 57 | export default CustomTimePicker; 58 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "main": "src/index.ts", 5 | "dependencies": { 6 | "@prisma/client": "^5.9.1", 7 | "@sendgrid/mail": "^8.1.0", 8 | "@types/cors": "^2.8.17", 9 | "@types/express": "^4.17.21", 10 | "@types/jest": "^29.5.12", 11 | "@types/node": "^20.11.16", 12 | "@types/swagger-ui-express": "^4.1.6", 13 | "@types/ws": "^8.5.10", 14 | "body-parser": "^1.20.2", 15 | "cors": "^2.8.5", 16 | "date-fns": "^3.3.1", 17 | "dotenv": "^16.4.1", 18 | "express": "^4.18.2", 19 | "firebase-admin": "^12.0.0", 20 | "jest": "^29.3.1", 21 | "node-cron": "^3.0.3", 22 | "nodemon": "^3.0.3", 23 | "prisma": "^5.9.1", 24 | "supertest": "^6.3.4", 25 | "swagger-autogen": "^2.23.7", 26 | "swagger-ui-express": "^5.0.0", 27 | "ts-jest": "^29.1.2", 28 | "ts-node": "^10.9.2", 29 | "typescript": "^5.3.3", 30 | "ws": "^8.16.0", 31 | "zeptomail": "^6.2.1" 32 | }, 33 | "scripts": { 34 | "setup": "yarn install && npx prisma migrate dev && yarn prisma db seed && yarn swagger", 35 | "test": "yarn run setup && NODE_ENV=test jest", 36 | "test:ci": "NODE_ENV=test prisma db seed && jest -i", 37 | "start": "npx prisma studio --browser none & yarn run backend", 38 | "backend": "nodemon -r dotenv/config src/index.ts", 39 | "swagger": "ts-node -r dotenv/config src/swagger.ts", 40 | "build": "tsc", 41 | "start:local": "ts-node -r dotenv/config src/index.ts" 42 | }, 43 | "prisma": { 44 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 45 | }, 46 | "devDependencies": { 47 | "@faker-js/faker": "^8.4.0", 48 | "@types/node-cron": "^3.0.11", 49 | "@types/supertest": "^6.0.2" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Select.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import MuiSelect from "@mui/material/Select"; 3 | 4 | interface SelectProps { 5 | children: ReactNode; 6 | label?: string; 7 | error?: string; 8 | size?: "small" | "medium"; 9 | [key: string]: any; 10 | } 11 | 12 | /** A simple Select component */ 13 | const Select = ({ 14 | children, 15 | label, 16 | error = "", 17 | size = "medium", 18 | ...props 19 | }: SelectProps) => { 20 | // Set size 21 | let height = ""; 22 | switch (size) { 23 | case "small": 24 | height = "35px"; 25 | break; 26 | case "medium": 27 | height = "42px"; 28 | break; 29 | } 30 | 31 | return ( 32 |
33 | {label &&
{label}
} 34 | 67 | {children} 68 | 69 |
{error}
70 |
71 | ); 72 | }; 73 | 74 | export default Select; 75 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, FormEvent } from "react"; 2 | import { Box, TextField, InputAdornment, IconButton } from "@mui/material"; 3 | import SearchIcon from "@mui/icons-material/Search"; 4 | import ClearIcon from "@mui/icons-material/Clear"; 5 | 6 | interface SearchBarProps { 7 | onSubmit: any; 8 | [key: string]: any; 9 | } 10 | 11 | /** A simple searchbar */ 12 | const SearchBar = ({ onSubmit, ...props }: SearchBarProps) => { 13 | return ( 14 |
15 | 29 | {props.showCancelButton && ( 30 |
31 | { 36 | props.resetSearch(); 37 | }} 38 | > 39 | 40 | 41 |
|
42 |
43 | )} 44 | 45 | 46 | 47 | 48 | ), 49 | }} 50 | {...props} 51 | /> 52 | 53 | ); 54 | }; 55 | 56 | export default SearchBar; 57 | -------------------------------------------------------------------------------- /.github/workflows/jestci.yml: -------------------------------------------------------------------------------- 1 | name: Jest CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: ["backend/**"] 7 | pull_request: 8 | branches: ["main"] 9 | paths: ["backend/**"] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [18.x, 20.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | services: 20 | postgres: 21 | image: postgres:latest 22 | env: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_DB: mydb 26 | ports: 27 | - 5433:5432 28 | options: >- 29 | --health-cmd pg_isready 30 | --health-interval 10s 31 | --health-timeout 5s 32 | --health-retries 5 33 | 34 | steps: 35 | - name: Checkout repository code 36 | uses: actions/checkout@v2 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v3 39 | with: 40 | cache-dependency-path: ./backend/yarn.lock 41 | node-version: ${{ matrix.node-version }} 42 | cache: "yarn" 43 | - name: Setup Dotenv, Dependencies . 44 | working-directory: ./backend/ 45 | run: | 46 | echo 'NODE_ENV = "test"' > .env 47 | yarn install 48 | - name: Generate Prisma Client, migrations and seed 49 | working-directory: ./backend/ 50 | env: 51 | DATABASE_URL: postgres://postgres:postgres@localhost:5433/mydb 52 | run: | 53 | npx prisma migrate deploy 54 | 55 | - name: Run Jest Tests 56 | working-directory: ./backend/ 57 | env: 58 | DATABASE_URL: postgres://postgres:postgres@localhost:5433/mydb 59 | run: yarn test:ci 60 | -------------------------------------------------------------------------------- /frontend/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | // TODO: Combine this with EventData and EventDTO (THESE THREE SHOULD BE THE SAME) 2 | export type ViewEventsEvent = { 3 | id: string; 4 | name: string; 5 | location: string; 6 | actions?: Action[]; 7 | startDate: string; 8 | endDate: string; 9 | role: string; 10 | hours: number; 11 | ownerId?: string; 12 | description?: string; 13 | capacity?: number; 14 | status?: EventStatus; 15 | imageURL?: string; 16 | attendeeStatus?: string; 17 | }; 18 | 19 | export type EventData = { 20 | eventid: string; 21 | location: string; 22 | locationLink: string; 23 | datetime: string; 24 | supervisors: string[]; 25 | capacity: number; 26 | hours: number; 27 | image_src: string; 28 | tags: string[] | undefined; 29 | description: string; 30 | name: string; 31 | event_status: string; 32 | }; 33 | 34 | export type EventDTO = { 35 | name: string; 36 | subtitle?: string; 37 | location: string; 38 | description: string; 39 | imageURL?: string; 40 | startDate: Date; 41 | endDate: Date; 42 | mode?: EventMode; 43 | status?: EventStatus; 44 | capacity: number; 45 | }; 46 | 47 | export type EventMode = "VIRTUAL" | "IN_PERSON"; 48 | 49 | export type EventStatus = "DRAFT" | "COMPLETED" | "CANCELED"; 50 | 51 | export type UserStatus = "ACTIVE" | "INACTIVE" | "HOLD"; 52 | 53 | export type UserRole = "VOLUNTEER" | "SUPERVISOR" | "ADMIN"; 54 | 55 | export type Action = 56 | | "rsvp" 57 | | "cancel rsvp" 58 | | "publish" 59 | | "manage attendees" 60 | | "edit"; 61 | 62 | export type UserData = { 63 | id: string; 64 | email: string; 65 | firstName: string; 66 | lastName: string; 67 | nickname: string; 68 | role?: string; 69 | status?: string; 70 | createdAt?: string; 71 | verified?: boolean; 72 | disciplinaryNotices?: number; 73 | imageUrl?: string; 74 | }; 75 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/VerifyEmailConfirmation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Link from "next/link"; 3 | import CheckCircleIcon from "@mui/icons-material/CheckCircle"; 4 | import { useQuery } from "@tanstack/react-query"; 5 | import { auth } from "@/utils/firebase"; 6 | import { applyActionCode } from "firebase/auth"; 7 | import CancelIcon from "@mui/icons-material/Cancel"; 8 | import Loading from "@/components/molecules/Loading"; 9 | 10 | interface VerifyEmailConfirmationProps { 11 | oobCode: string; 12 | } 13 | 14 | const VerifyEmailConfirmation = ({ oobCode }: VerifyEmailConfirmationProps) => { 15 | const [success, setSuccess] = useState(false); 16 | 17 | /** Tanstack query mutation */ 18 | const { 19 | isLoading, 20 | isError, 21 | error: queryError, 22 | } = useQuery({ 23 | queryKey: ["verify", oobCode], 24 | queryFn: async () => { 25 | try { 26 | await applyActionCode(auth, oobCode); 27 | // refresh firebase tokens 28 | await auth.currentUser?.reload(); 29 | setSuccess(true); 30 | } catch (error) { 31 | setSuccess(false); 32 | } 33 | return true; 34 | }, 35 | }); 36 | 37 | /** Handle loading */ 38 | if (isLoading) return ; 39 | 40 | return ( 41 |
42 | {success ? ( 43 |
44 | 45 |

46 | Your account has been verified! You can close this page. 47 |

48 |
49 | ) : ( 50 |
51 |
52 | 53 |
54 | 55 |

56 | Your link may be expired or invalid. Please try again. 57 |

58 |
59 | )} 60 |
61 | ); 62 | }; 63 | 64 | export default VerifyEmailConfirmation; 65 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import "@mdxeditor/editor/style.css"; 3 | import { FC } from "react"; 4 | 5 | // Basic editor 6 | import { 7 | MDXEditor, 8 | MDXEditorMethods, 9 | headingsPlugin, 10 | listsPlugin, 11 | linkPlugin, 12 | linkDialogPlugin, 13 | markdownShortcutPlugin, 14 | } from "@mdxeditor/editor"; 15 | 16 | // Toolbar 17 | import { 18 | Separator, 19 | UndoRedo, 20 | BoldItalicUnderlineToggles, 21 | ListsToggle, 22 | BlockTypeSelect, 23 | CreateLink, 24 | toolbarPlugin, 25 | } from "@mdxeditor/editor"; 26 | 27 | interface EditorProps { 28 | markdown: string; 29 | onChange: (e: any) => void; 30 | editorRef?: React.MutableRefObject; 31 | } 32 | 33 | /** 34 | * Extend this Component further with the necessary plugins or props you need. 35 | * proxying the ref is necessary. Next.js dynamically imported components don't support refs. 36 | */ 37 | const Editor: FC = ({ markdown, onChange, editorRef }) => { 38 | return ( 39 |
40 | ( 53 | <> 54 | {" "} 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ), 66 | }), 67 | ]} 68 | /> 69 |
70 | ); 71 | }; 72 | 73 | export default Editor; 74 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Button.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import MuiButton from "@mui/material/Button"; 3 | import CircularProgress from "@mui/material/CircularProgress"; 4 | 5 | interface ButtonProps { 6 | children: ReactNode; 7 | icon?: ReactNode; 8 | variety?: "primary" | "secondary" | "tertiary" | "error" | "mainError"; 9 | size?: "small" | "medium"; 10 | loading?: boolean; 11 | [key: string]: any; 12 | } 13 | 14 | /** A simple Button component */ 15 | const Button = ({ 16 | children, 17 | icon, 18 | variety = "primary", 19 | size = "medium", 20 | loading = false, 21 | ...props 22 | }: ButtonProps) => { 23 | // Set button size 24 | let height = ""; 25 | switch (size) { 26 | case "small": 27 | height = "35px"; 28 | break; 29 | case "medium": 30 | height = "42px"; 31 | break; 32 | } 33 | 34 | // Set button variety 35 | let variant: "contained" | "outlined" | "text"; 36 | let color: "primary" | "secondary" | "error"; 37 | let textColor = ""; 38 | switch (variety) { 39 | case "primary": 40 | variant = "contained"; 41 | color = "primary"; 42 | break; 43 | case "secondary": 44 | variant = "outlined"; 45 | color = "primary"; 46 | textColor = "black"; 47 | break; 48 | case "tertiary": 49 | variant = "text"; 50 | color = "primary"; 51 | break; 52 | case "error": 53 | variant = "outlined"; 54 | color = "error"; 55 | break; 56 | case "mainError": 57 | variant = "contained"; 58 | color = "error"; 59 | } 60 | 61 | return ( 62 | 76 | {loading ? ( 77 | 78 | ) : ( 79 |
{children}
80 | )} 81 |
82 | ); 83 | }; 84 | 85 | export default Button; 86 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "Existing Dockerfile", 5 | "build": { 6 | // Sets the run context to one level up instead of the .devcontainer folder. 7 | "context": "..", 8 | // Update the 'dockerFile' property if you aren't using the standard 'Dockerfile' filename. 9 | "dockerfile": "./Dockerfile" 10 | }, 11 | 12 | // Features to add to the dev container. More info: https://containers.dev/features. 13 | // "features": {}, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | // "forwardPorts": [], 17 | 18 | // Uncomment the next line to run commands after the container is created. 19 | // "postCreateCommand": "", 20 | 21 | // Uncomment the next line to run commands after the container is started. 22 | "postStartCommand": "service postgresql start", 23 | 24 | // Configure tool-specific properties. 25 | "customizations": { 26 | "vscode": { 27 | "settings": { 28 | "[javascript]": { 29 | "editor.defaultFormatter": "esbenp.prettier-vscode" 30 | }, 31 | "[typescript]": { 32 | "editor.defaultFormatter": "esbenp.prettier-vscode" 33 | }, 34 | "[typescriptreact]": { 35 | "editor.defaultFormatter": "esbenp.prettier-vscode" 36 | }, 37 | "[css]": { 38 | "editor.defaultFormatter": "esbenp.prettier-vscode" 39 | }, 40 | "[jsonc]": { 41 | "editor.defaultFormatter": "esbenp.prettier-vscode" 42 | }, 43 | "editor.tabSize": 2, 44 | "editor.rulers": [80], 45 | "editor.formatOnSave": true 46 | }, 47 | "extensions": [ 48 | "formulahendry.auto-rename-tag", 49 | "dbaeumer.vscode-eslint", 50 | "esbenp.prettier-vscode", 51 | "Prisma.prisma", 52 | "bradlc.vscode-tailwindcss" 53 | ] 54 | } 55 | } 56 | 57 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. 58 | // "remoteUser": "devcontainer" 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TextCopy.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Snackbar } from "@mui/material"; 3 | import FileCopyIcon from "@mui/icons-material/FileCopy"; 4 | import IconButton from "@mui/material/IconButton"; 5 | import IconTextHeader from "./IconTextHeader"; 6 | 7 | interface TextCopyProps { 8 | label: string; 9 | text: string; 10 | } 11 | 12 | /** 13 | * A TextCopy component has a label and a text body, along with a copy button. 14 | * Pressing the button copies the text body to the clipboard. 15 | */ 16 | const TextCopy = ({ label, text }: TextCopyProps) => { 17 | const [open, setOpen] = useState(false); 18 | const handleClick = () => { 19 | setOpen(true); 20 | navigator.clipboard.writeText(text); 21 | }; 22 | 23 | return ( 24 | <> 25 | setOpen(false)} 28 | autoHideDuration={2000} 29 | message="Copied to clipboard" 30 | /> 31 |
32 |
33 |
34 | 35 | 36 | 37 |
38 |
39 |
40 |
41 |
{label}
42 |
{text}
43 |
44 |
45 |
46 | 47 | //
48 | //
49 | //
{label}:
50 | //
51 | // 52 | // 53 | // 54 | // setOpen(false)} 57 | // autoHideDuration={2000} 58 | // message="Copied to clipboard" 59 | // /> 60 | //
61 | //
62 | //
{text}
63 | //
64 | ); 65 | }; 66 | 67 | export default TextCopy; 68 | -------------------------------------------------------------------------------- /frontend/src/pages/events/[eventid]/edit.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import EventForm from "@/components/organisms/EventForm"; 4 | import CenteredTemplate from "@/components/templates/CenteredTemplate"; 5 | import Loading from "@/components/molecules/Loading"; 6 | import { api } from "@/utils/api"; 7 | import { useQuery } from "@tanstack/react-query"; 8 | import Card from "@/components/molecules/Card"; 9 | import Head from "next/head"; 10 | 11 | type eventData = { 12 | id: string; 13 | eventName: string; 14 | location: string; 15 | locationLink: string; 16 | volunteerSignUpCap: string; 17 | defaultHoursAwarded: string; 18 | eventDescription: string; 19 | imageURL: string; 20 | rsvpLinkImage: string; 21 | startDate: string; 22 | endDate: string; 23 | startTime: string; 24 | endTime: string; 25 | mode: string; 26 | status: string; 27 | }; 28 | 29 | /** An EditEvent page */ 30 | const EditEvent = () => { 31 | const router = useRouter(); 32 | const eventid = router.query.eventid as string; 33 | 34 | /** Tanstack query for fetching event data */ 35 | const { data, isLoading, isError } = useQuery({ 36 | queryKey: ["event", eventid], 37 | queryFn: async () => { 38 | const { data } = await api.get(`/events/${eventid}`); 39 | return data["data"]; 40 | }, 41 | }); 42 | let event: eventData = { 43 | id: data?.id, 44 | eventName: data?.name, 45 | location: data?.location, 46 | locationLink: data?.locationLink, 47 | volunteerSignUpCap: data?.capacity, 48 | defaultHoursAwarded: data?.hours, 49 | eventDescription: data?.description, 50 | imageURL: data?.imageURL || "", 51 | rsvpLinkImage: data?.rsvpLinkImage || "", 52 | startDate: data?.startDate, 53 | endDate: data?.endDate, 54 | startTime: data?.startDate, 55 | endTime: data?.endDate, 56 | mode: data?.mode, 57 | status: data?.status, 58 | }; 59 | 60 | /** Loading screen */ 61 | if (isLoading) return ; 62 | 63 | return ( 64 | <> 65 | {data?.name && ( 66 | 67 | {data.name} - Edit Event - LFBI Volunteer Platform 68 | 69 | )} 70 | 71 | 72 | 73 | 74 | 75 | 76 | ); 77 | }; 78 | 79 | export default EditEvent; 80 | -------------------------------------------------------------------------------- /frontend/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { ThemeProvider, CssBaseline } from "@mui/material"; 4 | import { createTheme, StyledEngineProvider } from "@mui/material/styles"; 5 | import Layout from "@/components/Layout"; 6 | import { AuthProvider } from "@/utils/AuthContext"; 7 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 8 | import Head from "next/head"; 9 | 10 | const rootElement = () => document.getElementById("__next"); 11 | 12 | export const theme = createTheme({ 13 | typography: { 14 | fontFamily: "Inter, sans-serif", 15 | }, 16 | palette: { 17 | primary: { 18 | main: "#568124", // green 19 | light: "#E5E9E0", // green gray 20 | }, 21 | secondary: { 22 | main: "#8D8D8D", // dark gray 23 | light: "#D9D9D9", // medium gray 24 | }, 25 | warning: { 26 | main: "#D67300", // orange 27 | light: "#F1E8DC", // muted orange 28 | }, 29 | error: { 30 | main: "#CB2F2F", // red 31 | light: "#EDCDCD", // muted red 32 | }, 33 | background: { 34 | default: "#FFFFFF", 35 | }, 36 | }, 37 | components: { 38 | MuiPopover: { 39 | defaultProps: { 40 | container: rootElement, 41 | }, 42 | }, 43 | MuiPopper: { 44 | defaultProps: { 45 | container: rootElement, 46 | }, 47 | }, 48 | MuiDialog: { 49 | defaultProps: { 50 | container: rootElement, 51 | }, 52 | }, 53 | MuiModal: { 54 | defaultProps: { 55 | container: rootElement, 56 | }, 57 | }, 58 | }, 59 | }); 60 | 61 | // Note: default retry is 3 times. 62 | const queryClient = new QueryClient({ 63 | defaultOptions: { 64 | queries: { 65 | refetchOnWindowFocus: false, 66 | }, 67 | }, 68 | }); 69 | 70 | const App = ({ Component, pageProps }: AppProps) => { 71 | return ( 72 | 73 | 74 | 75 | 76 | 77 | 78 | LFBI Volunteer Platform 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ); 87 | }; 88 | 89 | export default App; 90 | -------------------------------------------------------------------------------- /backend/src/utils/script.ts: -------------------------------------------------------------------------------- 1 | // script to seed databse with dummy data 2 | import { faker } from "@faker-js/faker"; 3 | 4 | type Role = "VOLUNTEER" | "ADMIN" | "SUPERVISOR"; 5 | 6 | export interface dummyUser { 7 | email: string; 8 | imageURL: string; 9 | firstName: string; 10 | lastName: string; 11 | nickname: string; 12 | role: Role; 13 | phone?: string; 14 | } 15 | 16 | type Mode = "IN_PERSON" | "VIRTUAL"; 17 | type Status = "DRAFT" | "ACTIVE" | "CANCELED" | "COMPLETED"; 18 | 19 | export interface dummyEvent { 20 | name: string; 21 | description: string; 22 | location: string; 23 | startDate: Date; 24 | endDate: Date; 25 | capacity: number; 26 | imageURL?: string; 27 | status?: string; 28 | mode?: Mode; 29 | tags?: string[]; 30 | } 31 | 32 | export function createRandomUser(): dummyUser { 33 | const email = faker.internet.email().toLocaleLowerCase(); 34 | const imageURL = faker.image.avatar(); 35 | const firstName = faker.person.firstName(); 36 | const lastName = faker.person.lastName(); 37 | const nickname = faker.person.firstName(); 38 | const phone = faker.phone.number(); 39 | const role = faker.helpers.arrayElement([ 40 | "VOLUNTEER", 41 | "ADMIN", 42 | "SUPERVISOR", 43 | ]) as Role; 44 | 45 | return { 46 | email, 47 | imageURL, 48 | firstName, 49 | lastName, 50 | nickname, 51 | role, 52 | phone, 53 | }; 54 | } 55 | 56 | export function createRandomEvent(): dummyEvent { 57 | const name = faker.lorem.words(3); 58 | const description = faker.lorem.paragraph(); 59 | const location = `${ 60 | (faker.location.buildingNumber, faker.location.city()) 61 | }, ${faker.location.state()}`; 62 | const startDate = faker.date.soon(); 63 | const endDate = new Date(startDate); 64 | endDate.setHours( 65 | startDate.getHours() + faker.number.int({ min: 1, max: 12 }) 66 | ); 67 | const capacity = faker.number.int({ min: 10, max: 1000 }); 68 | const imageURL = faker.image.urlLoremFlickr({ category: "people" }); 69 | const status = faker.helpers.arrayElement([ 70 | "DRAFT", 71 | "ACTIVE", 72 | "CANCELLED", 73 | "COMPLETED", 74 | ]) as Status; 75 | const mode = faker.helpers.arrayElement(["IN_PERSON", "VIRTUAL"]) as Mode; 76 | const tags = faker.lorem.words(3).split(" "); 77 | 78 | return { 79 | name, 80 | description, 81 | location, 82 | startDate, 83 | endDate, 84 | capacity, 85 | imageURL, 86 | status, 87 | mode, 88 | tags, 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Table.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | DataGrid, 4 | GridColDef, 5 | GridPaginationModel, 6 | GridSortModel, 7 | // GridSlots, 8 | } from "@mui/x-data-grid"; 9 | import LinearProgress from "@mui/material/LinearProgress"; 10 | 11 | interface TableProps { 12 | /** The columns of the table, following the MUI Data Grid spec */ 13 | columns: GridColDef[]; 14 | /** The table rows represented as an object array */ 15 | rows: Object[]; 16 | /** The length of the entire dataset */ 17 | dataSetLength: number; 18 | /** The pagination model should come from the data layer, the parent component */ 19 | paginationModel: GridPaginationModel; 20 | sortModel?: GridSortModel; 21 | handlePaginationModelChange: (newModel: GridPaginationModel) => void; 22 | handleSortModelChange: (newModel: GridSortModel) => void; 23 | loading?: boolean; 24 | } 25 | 26 | /** A Table component */ 27 | const Table = ({ 28 | columns, 29 | rows, 30 | dataSetLength, 31 | paginationModel, 32 | sortModel, 33 | handlePaginationModelChange, 34 | handleSortModelChange, 35 | loading, 36 | }: TableProps) => { 37 | return ( 38 | ( 44 |
45 | No results found 46 |
47 | ), 48 | }} 49 | rows={rows} 50 | sx={{ 51 | border: 0, 52 | "&.MuiDataGrid-root .MuiDataGrid-cell:focus-within": { 53 | outline: "none !important", 54 | }, 55 | "& .MuiDataGrid-cell:focus": { 56 | outline: "none", 57 | }, 58 | "& .MuiDataGrid-columnHeader:focus": { 59 | outline: "none", 60 | }, 61 | }} 62 | disableRowSelectionOnClick 63 | rowCount={dataSetLength} // number of rows in the entire dataset 64 | paginationModel={paginationModel} // current page and page size 65 | onPaginationModelChange={handlePaginationModelChange} 66 | pageSizeOptions={[]} 67 | paginationMode="server" 68 | disableColumnMenu 69 | sortingMode="server" 70 | sortModel={sortModel} 71 | onSortModelChange={handleSortModelChange} 72 | sortingOrder={["desc", "asc"]} 73 | loading={loading} 74 | /> 75 | ); 76 | }; 77 | 78 | export default Table; 79 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/ManageWebsite.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Button from "../atoms/Button"; 3 | import { api } from "@/utils/api"; 4 | import Card from "../molecules/Card"; 5 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 6 | import Papa from "papaparse"; 7 | import JSZip from "jszip"; 8 | 9 | const ManageWebsite = () => { 10 | const { data, isError, refetch, isRefetching } = useQuery({ 11 | queryKey: ["website"], 12 | queryFn: async () => { 13 | const { data } = await api.get("/website/download"); //new endpoint 14 | return data.data; 15 | }, 16 | enabled: false, 17 | }); 18 | 19 | function flattenObject(ob: any) { 20 | var toReturn: any = {}; 21 | 22 | for (var i in ob) { 23 | if (!ob.hasOwnProperty(i)) continue; 24 | 25 | if (typeof ob[i] == "object" && ob[i] !== null) { 26 | var flatObject = flattenObject(ob[i]); 27 | for (var x in flatObject) { 28 | if (!flatObject.hasOwnProperty(x)) continue; 29 | 30 | toReturn[i + "." + x] = flatObject[x]; 31 | } 32 | } else { 33 | toReturn[i] = ob[i]; 34 | } 35 | } 36 | return toReturn; 37 | } 38 | 39 | const handleDownloadDatabase = async () => { 40 | const { data } = await refetch(); 41 | 42 | const [users, events, enrollments] = data; 43 | const zip = new JSZip(); 44 | const flattenedUsers = users.map((user: any) => flattenObject(user)); 45 | const flattenedEvents = events.map((event: any) => flattenObject(event)); 46 | const flattenedEnrollments = enrollments.map((enrollment: any) => 47 | flattenObject(enrollment) 48 | ); 49 | zip.file("users.csv", Papa.unparse(flattenedUsers)); 50 | zip.file("events.csv", Papa.unparse(flattenedEvents)); 51 | zip.file("enrollments.csv", Papa.unparse(flattenedEnrollments)); 52 | 53 | zip.generateAsync({ type: "blob" }).then(function (content) { 54 | const url = URL.createObjectURL(content); 55 | const a = document.createElement("a"); 56 | a.href = url; 57 | a.download = "website_data.zip"; 58 | a.click(); 59 | URL.revokeObjectURL(url); 60 | }); 61 | }; 62 | 63 | return ( 64 |
65 |
66 | 67 |

Website Download

68 |
69 | Download website data? Data includes all tables volunteer hours, and 70 | user information. 71 |
72 | 75 |
76 |
77 |
78 | ); 79 | }; 80 | 81 | export default ManageWebsite; 82 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240211003708_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The values [VIRTUAL,IN_PERSON] on the enum `EventMode` will be removed. If these variants are still used in the database, this will fail. 5 | - The values [DRAFT,ACTIVE,COMPLETED,CANCELED] on the enum `EventStatus` will be removed. If these variants are still used in the database, this will fail. 6 | - The values [ACTIVE,INACTIVE,HOLD] on the enum `UserStatus` will be removed. If these variants are still used in the database, this will fail. 7 | - The values [ADMIN,VOLUNTEER,SUPERVISOR] on the enum `userRole` will be removed. If these variants are still used in the database, this will fail. 8 | 9 | */ 10 | -- AlterEnum 11 | BEGIN; 12 | CREATE TYPE "EventMode_new" AS ENUM ('Virtual', 'In_Person'); 13 | ALTER TABLE "Event" ALTER COLUMN "mode" DROP DEFAULT; 14 | ALTER TABLE "Event" ALTER COLUMN "mode" TYPE "EventMode_new" USING ("mode"::text::"EventMode_new"); 15 | ALTER TYPE "EventMode" RENAME TO "EventMode_old"; 16 | ALTER TYPE "EventMode_new" RENAME TO "EventMode"; 17 | DROP TYPE "EventMode_old"; 18 | ALTER TABLE "Event" ALTER COLUMN "mode" SET DEFAULT 'In_Person'; 19 | COMMIT; 20 | 21 | -- AlterEnum 22 | BEGIN; 23 | CREATE TYPE "EventStatus_new" AS ENUM ('Draft', 'Active', 'Completed', 'Canceled'); 24 | ALTER TABLE "Event" ALTER COLUMN "status" DROP DEFAULT; 25 | ALTER TABLE "Event" ALTER COLUMN "status" TYPE "EventStatus_new" USING ("status"::text::"EventStatus_new"); 26 | ALTER TYPE "EventStatus" RENAME TO "EventStatus_old"; 27 | ALTER TYPE "EventStatus_new" RENAME TO "EventStatus"; 28 | DROP TYPE "EventStatus_old"; 29 | ALTER TABLE "Event" ALTER COLUMN "status" SET DEFAULT 'Draft'; 30 | COMMIT; 31 | 32 | -- AlterEnum 33 | BEGIN; 34 | CREATE TYPE "UserStatus_new" AS ENUM ('Active', 'Inactive', 'Hold'); 35 | ALTER TABLE "User" ALTER COLUMN "status" DROP DEFAULT; 36 | ALTER TABLE "User" ALTER COLUMN "status" TYPE "UserStatus_new" USING ("status"::text::"UserStatus_new"); 37 | ALTER TYPE "UserStatus" RENAME TO "UserStatus_old"; 38 | ALTER TYPE "UserStatus_new" RENAME TO "UserStatus"; 39 | DROP TYPE "UserStatus_old"; 40 | ALTER TABLE "User" ALTER COLUMN "status" SET DEFAULT 'Active'; 41 | COMMIT; 42 | 43 | -- AlterEnum 44 | BEGIN; 45 | CREATE TYPE "userRole_new" AS ENUM ('Admin', 'Volunteer', 'Supervisor'); 46 | ALTER TABLE "User" ALTER COLUMN "role" DROP DEFAULT; 47 | ALTER TABLE "User" ALTER COLUMN "role" TYPE "userRole_new" USING ("role"::text::"userRole_new"); 48 | ALTER TYPE "userRole" RENAME TO "userRole_old"; 49 | ALTER TYPE "userRole_new" RENAME TO "userRole"; 50 | DROP TYPE "userRole_old"; 51 | ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'Volunteer'; 52 | COMMIT; 53 | 54 | -- AlterTable 55 | ALTER TABLE "Event" ALTER COLUMN "status" SET DEFAULT 'Draft', 56 | ALTER COLUMN "mode" SET DEFAULT 'In_Person'; 57 | 58 | -- AlterTable 59 | ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'Volunteer', 60 | ALTER COLUMN "status" SET DEFAULT 'Active'; 61 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20240217012742_back_to_uppercase/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The values [Virtual,In_Person] on the enum `EventMode` will be removed. If these variants are still used in the database, this will fail. 5 | - The values [Draft,Active,Completed,Canceled] on the enum `EventStatus` will be removed. If these variants are still used in the database, this will fail. 6 | - The values [Active,Inactive,Hold] on the enum `UserStatus` will be removed. If these variants are still used in the database, this will fail. 7 | - The values [Admin,Volunteer,Supervisor] on the enum `userRole` will be removed. If these variants are still used in the database, this will fail. 8 | 9 | */ 10 | -- AlterEnum 11 | BEGIN; 12 | CREATE TYPE "EventMode_new" AS ENUM ('VIRTUAL', 'IN_PERSON'); 13 | ALTER TABLE "Event" ALTER COLUMN "mode" DROP DEFAULT; 14 | ALTER TABLE "Event" ALTER COLUMN "mode" TYPE "EventMode_new" USING ("mode"::text::"EventMode_new"); 15 | ALTER TYPE "EventMode" RENAME TO "EventMode_old"; 16 | ALTER TYPE "EventMode_new" RENAME TO "EventMode"; 17 | DROP TYPE "EventMode_old"; 18 | ALTER TABLE "Event" ALTER COLUMN "mode" SET DEFAULT 'IN_PERSON'; 19 | COMMIT; 20 | 21 | -- AlterEnum 22 | BEGIN; 23 | CREATE TYPE "EventStatus_new" AS ENUM ('DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELED'); 24 | ALTER TABLE "Event" ALTER COLUMN "status" DROP DEFAULT; 25 | ALTER TABLE "Event" ALTER COLUMN "status" TYPE "EventStatus_new" USING ("status"::text::"EventStatus_new"); 26 | ALTER TYPE "EventStatus" RENAME TO "EventStatus_old"; 27 | ALTER TYPE "EventStatus_new" RENAME TO "EventStatus"; 28 | DROP TYPE "EventStatus_old"; 29 | ALTER TABLE "Event" ALTER COLUMN "status" SET DEFAULT 'DRAFT'; 30 | COMMIT; 31 | 32 | -- AlterEnum 33 | BEGIN; 34 | CREATE TYPE "UserStatus_new" AS ENUM ('ACTIVE', 'INACTIVE', 'HOLD'); 35 | ALTER TABLE "User" ALTER COLUMN "status" DROP DEFAULT; 36 | ALTER TABLE "User" ALTER COLUMN "status" TYPE "UserStatus_new" USING ("status"::text::"UserStatus_new"); 37 | ALTER TYPE "UserStatus" RENAME TO "UserStatus_old"; 38 | ALTER TYPE "UserStatus_new" RENAME TO "UserStatus"; 39 | DROP TYPE "UserStatus_old"; 40 | ALTER TABLE "User" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; 41 | COMMIT; 42 | 43 | -- AlterEnum 44 | BEGIN; 45 | CREATE TYPE "userRole_new" AS ENUM ('ADMIN', 'VOLUNTEER', 'SUPERVISOR'); 46 | ALTER TABLE "User" ALTER COLUMN "role" DROP DEFAULT; 47 | ALTER TABLE "User" ALTER COLUMN "role" TYPE "userRole_new" USING ("role"::text::"userRole_new"); 48 | ALTER TYPE "userRole" RENAME TO "userRole_old"; 49 | ALTER TYPE "userRole_new" RENAME TO "userRole"; 50 | DROP TYPE "userRole_old"; 51 | ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'VOLUNTEER'; 52 | COMMIT; 53 | 54 | -- AlterTable 55 | ALTER TABLE "Event" ALTER COLUMN "status" SET DEFAULT 'DRAFT', 56 | ALTER COLUMN "mode" SET DEFAULT 'IN_PERSON'; 57 | 58 | -- AlterTable 59 | ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'VOLUNTEER', 60 | ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; 61 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/RecoverEmailConfirmation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Link from "next/link"; 3 | import CheckCircleIcon from "@mui/icons-material/CheckCircle"; 4 | import { useQuery } from "@tanstack/react-query"; 5 | import { auth } from "@/utils/firebase"; 6 | import { applyActionCode, checkActionCode } from "firebase/auth"; 7 | import CancelIcon from "@mui/icons-material/Cancel"; 8 | import Loading from "@/components/molecules/Loading"; 9 | import { api } from "@/utils/api"; 10 | import { fetchUserIdFromDatabase } from "@/utils/helpers"; 11 | 12 | interface RecoverEmailConfirmationProps { 13 | oobCode: string; 14 | } 15 | 16 | const RecoverEmailConfirmation = ({ 17 | oobCode, 18 | }: RecoverEmailConfirmationProps) => { 19 | const [success, setSuccess] = useState(false); 20 | 21 | /** Tanstack query mutation */ 22 | const { 23 | isLoading, 24 | isError, 25 | error: queryError, 26 | } = useQuery({ 27 | queryKey: ["recover", oobCode], 28 | queryFn: async () => { 29 | try { 30 | const info = await checkActionCode(auth, oobCode); 31 | const previousEmail = info.data.previousEmail; 32 | const restoredEmail = info.data.email; 33 | 34 | const user = auth.currentUser; 35 | 36 | if (previousEmail && restoredEmail) { 37 | // Change email in Firebase 38 | await applyActionCode(auth, oobCode); 39 | 40 | // Change email in local database 41 | await api.patch( 42 | `/users/email/recover`, 43 | { oldEmail: previousEmail }, 44 | false 45 | ); 46 | 47 | // refresh firebase tokens 48 | await auth.currentUser?.reload(); 49 | 50 | setSuccess(true); 51 | } else { 52 | throw "No email found"; 53 | } 54 | } catch (error) { 55 | console.log(error); 56 | setSuccess(false); 57 | } 58 | return true; 59 | }, 60 | }); 61 | 62 | /** Handle loading */ 63 | if (isLoading) return ; 64 | 65 | return ( 66 |
67 | {success ? ( 68 |
69 | 70 |

71 | Your email address has been reverted! You can close this page. We 72 | recommend resetting your password if you believe your account was 73 | compromised. 74 |

75 |
76 | ) : ( 77 |
78 |
79 | 80 |
81 | 82 |

83 | Your link may be expired or invalid. Please try again. 84 |

85 |
86 | )} 87 |
88 | ); 89 | }; 90 | 91 | export default RecoverEmailConfirmation; 92 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20250109213408_remove_columns/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The values [HOLD] on the enum `UserStatus` will be removed. If these variants are still used in the database, this will fail. 5 | - You are about to drop the column `subtitle` on the `Event` table. All the data in the column will be lost. 6 | - You are about to drop the column `canceled` on the `EventEnrollment` table. All the data in the column will be lost. 7 | - You are about to drop the column `showedUp` on the `EventEnrollment` table. All the data in the column will be lost. 8 | - You are about to drop the column `disciplinaryNotices` on the `Profile` table. All the data in the column will be lost. 9 | - You are about to drop the column `nickname` on the `Profile` table. All the data in the column will be lost. 10 | - You are about to drop the column `hours` on the `User` table. All the data in the column will be lost. 11 | - You are about to drop the column `verified` on the `User` table. All the data in the column will be lost. 12 | - You are about to drop the column `sendPromotions` on the `UserPreferences` table. All the data in the column will be lost. 13 | - You are about to drop the `EventTags` table. If the table is not empty, all the data it contains will be lost. 14 | - You are about to drop the `Permission` table. If the table is not empty, all the data it contains will be lost. 15 | - You are about to drop the `_EventToEventTags` table. If the table is not empty, all the data it contains will be lost. 16 | 17 | */ 18 | -- AlterEnum 19 | BEGIN; 20 | CREATE TYPE "UserStatus_new" AS ENUM ('ACTIVE', 'INACTIVE'); 21 | ALTER TABLE "User" ALTER COLUMN "status" DROP DEFAULT; 22 | ALTER TABLE "User" ALTER COLUMN "status" TYPE "UserStatus_new" USING ("status"::text::"UserStatus_new"); 23 | ALTER TYPE "UserStatus" RENAME TO "UserStatus_old"; 24 | ALTER TYPE "UserStatus_new" RENAME TO "UserStatus"; 25 | DROP TYPE "UserStatus_old"; 26 | ALTER TABLE "User" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; 27 | COMMIT; 28 | 29 | -- DropForeignKey 30 | ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; 31 | 32 | -- DropForeignKey 33 | ALTER TABLE "_EventToEventTags" DROP CONSTRAINT "_EventToEventTags_A_fkey"; 34 | 35 | -- DropForeignKey 36 | ALTER TABLE "_EventToEventTags" DROP CONSTRAINT "_EventToEventTags_B_fkey"; 37 | 38 | -- AlterTable 39 | ALTER TABLE "Event" DROP COLUMN "subtitle"; 40 | 41 | -- AlterTable 42 | ALTER TABLE "EventEnrollment" DROP COLUMN "canceled", 43 | DROP COLUMN "showedUp"; 44 | 45 | -- AlterTable 46 | ALTER TABLE "Profile" DROP COLUMN "disciplinaryNotices", 47 | DROP COLUMN "nickname"; 48 | 49 | -- AlterTable 50 | ALTER TABLE "User" DROP COLUMN "hours", 51 | DROP COLUMN "verified"; 52 | 53 | -- AlterTable 54 | ALTER TABLE "UserPreferences" DROP COLUMN "sendPromotions"; 55 | 56 | -- DropTable 57 | DROP TABLE "EventTags"; 58 | 59 | -- DropTable 60 | DROP TABLE "Permission"; 61 | 62 | -- DropTable 63 | DROP TABLE "_EventToEventTags"; 64 | -------------------------------------------------------------------------------- /frontend/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import React, { useEffect, useState } from "react"; 3 | import { Button, Grid, Stack } from "@mui/material"; 4 | import { useAuth } from "@/utils/AuthContext"; 5 | import { BASE_URL } from "@/utils/constants"; 6 | import { auth } from "@/utils/firebase"; 7 | import { useRouter } from "next/router"; 8 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 9 | 10 | const Home = () => { 11 | const { user, loading, error, signOutUser } = useAuth(); 12 | const [userDetails, setUserDetails] = useState(null); 13 | 14 | const router = useRouter(); 15 | 16 | const handleSignOut = async () => { 17 | try { 18 | await signOutUser(); 19 | router.replace("/login"); 20 | } catch (error) { 21 | console.log(error); 22 | } 23 | }; 24 | 25 | const fetchUserDetails = async () => { 26 | try { 27 | const url = BASE_URL as string; 28 | 29 | // Get the user's token. Notice that auth is imported from firebase file 30 | const userId = auth.currentUser?.uid; 31 | const token = await auth.currentUser?.getIdToken(); 32 | const fetchUrl = `${url}/users/${userId}`; 33 | 34 | const response = await fetch(fetchUrl, { 35 | method: "GET", 36 | headers: { 37 | Authorization: `Bearer ${token}`, 38 | }, 39 | }); 40 | const data = await response.json(); 41 | setUserDetails(data); 42 | console.log(data); 43 | } catch (error) { 44 | console.log(error); 45 | } 46 | }; 47 | 48 | return ( 49 | 50 |
51 | 52 | // <> 53 | // 54 | // Volunteer Platform 55 | // 56 | // 57 | // 58 | // 59 | //
60 | // 67 | //

Using Material UI with Next.js

68 | // 69 | // 70 | // 73 | // 76 | // 79 | 80 | // {/* */} 81 | // 82 | //
83 | //
84 | // 85 | ); 86 | }; 87 | 88 | export default Home; 89 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/ManageProvidersForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useAuth } from "@/utils/AuthContext"; 3 | import Button from "../atoms/Button"; 4 | import EmailIcon from "@mui/icons-material/Email"; 5 | import IconText from "@/components/atoms/IconText"; 6 | import GoogleIcon from "@mui/icons-material/Google"; 7 | import Snackbar from "../atoms/Snackbar"; 8 | import { unlink } from "firebase/auth"; 9 | 10 | const ManageProvidersForm = () => { 11 | /** State variables for the notification popups */ 12 | const [successNotificationOpen, setSuccessNotificationOpen] = useState(false); 13 | const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); 14 | 15 | /** Error message state */ 16 | const [errorMessage, setErrorMessage] = useState(""); 17 | 18 | const { user } = useAuth(); 19 | const hasEmailProvider = user?.providerData.some( 20 | (x) => x.providerId === "password" 21 | ); 22 | const hasGoogleProvider = user?.providerData.some( 23 | (x) => x.providerId === "google.com" 24 | ); 25 | 26 | /** Unlinks the Google provider from the user account */ 27 | const handleUnlinkGoogle = async (providerId: string) => { 28 | try { 29 | if (user) { 30 | await unlink(user, providerId); 31 | setSuccessNotificationOpen(true); 32 | } else { 33 | throw new Error("User not found"); 34 | } 35 | } catch (error: any) { 36 | setErrorMessage(error.message); 37 | setErrorNotificationOpen(true); 38 | } 39 | }; 40 | return ( 41 |
42 | {/* Profile update error snackbar */} 43 | setErrorNotificationOpen(false)} 47 | > 48 | Error: {errorMessage} 49 | 50 | 51 | {/* Profile update success snackbar */} 52 | setSuccessNotificationOpen(false)} 56 | > 57 | Success: Account has been unlinked! 58 | 59 | 60 |

My authentication providers

61 | {hasEmailProvider && ( 62 |
63 | }>Email and password 64 |
65 | )} 66 | {hasGoogleProvider && ( 67 |
68 |
69 | }>Google 70 |
71 | {hasEmailProvider ? ( 72 | 80 | ) : ( 81 | 84 | )} 85 |
86 |
87 |
88 | )} 89 |
90 | ); 91 | }; 92 | 93 | export default ManageProvidersForm; 94 | -------------------------------------------------------------------------------- /backend/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | // Metadata 15 | id String @id @default(cuid()) 16 | createdAt DateTime @default(now()) 17 | updatedAt DateTime @updatedAt 18 | email String @unique 19 | 20 | // User info 21 | role userRole? @default(VOLUNTEER) 22 | profile Profile? 23 | status UserStatus? @default(ACTIVE) 24 | createdEvents Event[] @relation(name: "EventOwner") 25 | events EventEnrollment[] 26 | preferences UserPreferences? 27 | legacyHours Int @default(0) 28 | } 29 | 30 | model Profile { 31 | // Profile info 32 | firstName String? 33 | lastName String? 34 | imageURL String? 35 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 36 | userId String 37 | phoneNumber String? 38 | 39 | @@id([userId]) 40 | } 41 | 42 | model UserPreferences { 43 | // User settings info 44 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 45 | userId String 46 | sendEmailNotification Boolean @default(true) 47 | 48 | @@id([userId]) 49 | } 50 | 51 | model Event { 52 | // Metadata 53 | id String @id @default(cuid()) 54 | createdAt DateTime @default(now()) 55 | updatedAt DateTime @updatedAt 56 | 57 | // Event info 58 | name String 59 | location String 60 | locationLink String? 61 | description String 62 | imageURL String? 63 | startDate DateTime 64 | endDate DateTime 65 | mode EventMode? @default(IN_PERSON) 66 | status EventStatus? @default(ACTIVE) 67 | owner User? @relation(fields: [ownerId], references: [id], name: "EventOwner") 68 | ownerId String? 69 | attendees EventEnrollment[] 70 | capacity Int 71 | hours Int @default(0) 72 | } 73 | 74 | model EventEnrollment { 75 | // Metadata 76 | createdAt DateTime @default(now()) 77 | updatedAt DateTime @updatedAt 78 | 79 | // Attendees info 80 | eventId String 81 | userId String 82 | event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) 83 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 84 | cancelationMessage String? 85 | attendeeStatus EnrollmentStatus @default(PENDING) 86 | customHours Int? 87 | 88 | @@id([userId, eventId]) 89 | } 90 | 91 | model About { 92 | //Metadata 93 | createdAt DateTime @default(now()) 94 | updatedAt DateTime @updatedAt 95 | 96 | id String @id @default(cuid()) 97 | content String 98 | } 99 | 100 | enum userRole { 101 | ADMIN 102 | VOLUNTEER 103 | SUPERVISOR 104 | } 105 | 106 | enum UserStatus { 107 | ACTIVE 108 | INACTIVE 109 | } 110 | 111 | enum EventStatus { 112 | ACTIVE 113 | CANCELED 114 | } 115 | 116 | enum EventMode { 117 | VIRTUAL 118 | IN_PERSON 119 | } 120 | 121 | enum EnrollmentStatus { 122 | PENDING 123 | CHECKED_IN 124 | CHECKED_OUT 125 | REMOVED 126 | CANCELED 127 | } 128 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/VerifyEmailForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | import { auth } from "@/utils/firebase"; 4 | import { onAuthStateChanged, updateCurrentUser } from "firebase/auth"; 5 | import Button from "@/components/atoms/Button"; 6 | import { useSendEmailVerification } from "react-firebase-hooks/auth"; 7 | import { useQueryClient } from "@tanstack/react-query"; 8 | import { useAuth } from "@/utils/AuthContext"; 9 | import Snackbar from "@/components/atoms/Snackbar"; 10 | 11 | const VerifyEmailForm = () => { 12 | const router = useRouter(); 13 | const [sendEmailVerification, sending, emailError] = 14 | useSendEmailVerification(auth); 15 | 16 | const { user, loading, error, signOutUser } = useAuth(); 17 | const queryClient = useQueryClient(); 18 | 19 | const handleSignOut = async () => { 20 | try { 21 | await signOutUser(); 22 | queryClient.clear(); // Clear cache for react query 23 | router.replace("/login"); 24 | } catch (error) { 25 | console.log(error); 26 | } 27 | }; 28 | 29 | // State for managing the snackbar 30 | const [notifOpenOnSuccess, setNotifOpenOnSuccess] = useState(false); 31 | const [notifOpenOnError, setNotifOpenOnError] = useState(false); 32 | 33 | const handleResendEmail = async () => { 34 | // Implement logic to resend verification email 35 | try { 36 | const res = await sendEmailVerification(); 37 | if (res) { 38 | setNotifOpenOnSuccess(true); 39 | } 40 | } catch (error) { 41 | setNotifOpenOnError(true); 42 | } 43 | }; 44 | 45 | useEffect(() => { 46 | if (emailError) { 47 | setNotifOpenOnError(true); 48 | } 49 | }, [emailError]); 50 | 51 | useEffect(() => { 52 | // Watch for authentication state changes 53 | const unsubscribe = onAuthStateChanged(auth, (user) => { 54 | if (user) { 55 | // Periodically reload the user's state 56 | const interval = setInterval(async () => { 57 | await user.reload(); 58 | const refreshedUser = auth.currentUser; 59 | if (refreshedUser?.emailVerified) { 60 | clearInterval(interval); 61 | } 62 | }, 5000); // Check every 5 seconds 63 | 64 | return () => clearInterval(interval); 65 | } 66 | }); 67 | 68 | // Clean up listener on component unmount 69 | return () => unsubscribe(); 70 | }, [auth]); 71 | 72 | return ( 73 | <> 74 | {/* Notifications */} 75 | setNotifOpenOnSuccess(false)} 79 | > 80 | Verification email sent successfully! 81 | 82 | setNotifOpenOnError(false)} 86 | > 87 | Error sending verification email. Please try again later. 88 | 89 |
90 |
91 | 92 |
Verify Your Email
93 |

94 | We've sent a verification email to your email address. Please check 95 | your inbox and click on the verification link to verify your email 96 | address. 97 |

98 |

99 | If you haven't received the email within a few minutes, please check 100 | your spam folder. 101 |

102 | 109 | 116 |
117 |
118 | 119 | ); 120 | }; 121 | 122 | export default VerifyEmailForm; 123 | -------------------------------------------------------------------------------- /frontend/src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import { BASE_URL } from "@/utils/constants"; 2 | import { auth } from "@/utils/firebase"; 3 | 4 | /** 5 | * Retrieves the Firebase token of the current user session 6 | * @returns a promise of the user token 7 | */ 8 | const retrieveToken = () => { 9 | if (auth.currentUser) { 10 | return auth.currentUser.getIdToken(); 11 | } else { 12 | return Promise.reject("Failed to retrieve token: user is null"); 13 | } 14 | }; 15 | 16 | /** 17 | * Performs a GET request to the specified URL 18 | * @param url is the resource url 19 | * @param token is whether a user token is needed for the request 20 | * @returns the response 21 | */ 22 | const get = (url: string, token = true) => { 23 | return handleRequest("GET", url, token); 24 | }; 25 | 26 | /** 27 | * Performs a POST request to the specified URL 28 | * @param url is the resource url 29 | * @param body is the request body 30 | * @param token is whether a user token is needed for the request 31 | * @returns the response 32 | */ 33 | const post = (url: string, body: object, token = true) => { 34 | const headers = { "Content-Type": "application/json" }; 35 | return handleRequest("POST", url, token, headers, body); 36 | }; 37 | 38 | /** 39 | * Performs a PUT request to the specified URL 40 | * @param url is the resource url 41 | * @param body is the request body 42 | * @param token is whether a user token is needed for the request 43 | * @returns the response 44 | */ 45 | const put = (url: string, body: object, token = true) => { 46 | const headers = { "Content-Type": "application/json" }; 47 | return handleRequest("PUT", url, token, headers, body); 48 | }; 49 | 50 | /** 51 | * Performs a PATCH request to the specified URL 52 | * @param url is the resource url 53 | * @param body is the request body 54 | * @param token is whether a user token is needed for the request 55 | * @returns the response 56 | */ 57 | const patch = (url: string, body: object, token = true) => { 58 | const headers = { "Content-Type": "application/json" }; 59 | return handleRequest("PATCH", url, token, headers, body); 60 | }; 61 | 62 | /** 63 | * Performs a DELETE request to the specified URL 64 | * @param url is the resource url 65 | * @param token is whether a user token is needed for the request 66 | * @returns the response 67 | */ 68 | const del = (url: string, token = true) => { 69 | return handleRequest("DELETE", url, token); 70 | }; 71 | 72 | /** 73 | * Handles the request of a fetch request 74 | * @param method is the request method 75 | * @param url is the resource url 76 | * @param headers are the request headers 77 | * @param body is the request body 78 | * @param requiresToken is whether a user token is needed for the request 79 | * @returns the response data 80 | */ 81 | const handleRequest = async ( 82 | method: string, 83 | url: string, 84 | requiresToken: boolean, 85 | headers?: { [key: string]: string }, 86 | body?: object 87 | ) => { 88 | let options; 89 | if (requiresToken) { 90 | const token = await retrieveToken(); 91 | options = { 92 | method: method, 93 | headers: { Authorization: `Bearer ${token}`, ...headers }, 94 | body: JSON.stringify(body), 95 | }; 96 | } else { 97 | options = { 98 | method: method, 99 | headers: headers, 100 | body: JSON.stringify(body), 101 | }; 102 | } 103 | const response = await fetch(BASE_URL + url, options); 104 | return handleResponse(response); 105 | }; 106 | 107 | /** 108 | * Handles the response of a fetch request 109 | * @param response is the response 110 | * @returns the response data 111 | */ 112 | const handleResponse = async (response: Response) => { 113 | if (response.ok) { 114 | let data; 115 | const content = response.headers.get("Content-Type"); 116 | if (content?.includes("application/json")) { 117 | data = await response.json(); 118 | } else { 119 | data = await response.blob(); 120 | } 121 | return Promise.resolve({ response: response, data: data }); 122 | } else { 123 | const responseWithErrors = await response.json(); 124 | return Promise.reject({ 125 | message: responseWithErrors.error, 126 | status: response.status, 127 | }); 128 | } 129 | }; 130 | 131 | export const api = { get, post, put, patch, delete: del }; 132 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/ForgotPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import Button from "../atoms/Button"; 3 | import TextField from "../atoms/TextField"; 4 | import Link from "next/link"; 5 | import { useForm, SubmitHandler } from "react-hook-form"; 6 | import { useSendPasswordResetEmail } from "react-firebase-hooks/auth"; 7 | import { auth } from "@/utils/firebase"; 8 | import Snackbar from "../atoms/Snackbar"; 9 | import { BASE_URL_CLIENT } from "@/utils/constants"; 10 | import { useMutation } from "@tanstack/react-query"; 11 | 12 | type FormValues = { 13 | email: string; 14 | }; 15 | 16 | /** A ForgotPasswordForm page */ 17 | const ForgotPasswordForm = () => { 18 | const [sendPasswordResetEmail, sending, error] = 19 | useSendPasswordResetEmail(auth); 20 | 21 | const [errorMessage, setErrorMessage] = React.useState(""); 22 | const [success, setSuccess] = React.useState(false); 23 | const [errorSnackbarOpen, setErrorSnackbarOpen] = 24 | React.useState(false); 25 | const { 26 | register, 27 | handleSubmit, 28 | watch, 29 | formState: { errors }, 30 | } = useForm(); 31 | 32 | const handleErrors = (errors: any) => { 33 | // Firebase reset password email error codes are weird. Need to parse it 34 | const parsedError = errors.split("/")[1].slice(0, -2); 35 | switch (parsedError) { 36 | case "invalid-email": 37 | return "Invalid email address format."; 38 | case "user-disabled": 39 | return "User with this email has been disabled."; 40 | case "user-not-found": 41 | return "There is no user with this email address."; 42 | default: 43 | return "Something went wrong."; 44 | } 45 | }; 46 | 47 | const handleForgotPassword: SubmitHandler = async (data) => { 48 | const { email } = data; 49 | 50 | const resetPassword = await sendPasswordResetEmail(email); 51 | if (resetPassword) { 52 | setSuccess(true); 53 | } 54 | }; 55 | 56 | useEffect(() => { 57 | if (error) { 58 | setErrorSnackbarOpen(true); 59 | } 60 | }, [error]); 61 | 62 | return ( 63 | <> 64 | {/* ForgotPasswordErrorComponent */} 65 | setErrorSnackbarOpen(false)} 69 | > 70 | Error: {error && handleErrors(error.message)} 71 | 72 | 73 | {/* ForgotPasswordSuccessComponent */} 74 | setSuccess(false)} 78 | > 79 | Success: Password reset email sent. Please check your inbox. 80 | 81 |
82 | 83 |
Forgot Password
84 |
85 | After verifying your email, you will receive instructions on how to 86 | reset your password. If you continue to experience issues, please 87 | contact our support team for assistance. 88 |
89 |
90 | 102 |
103 |
104 | 107 |
108 |
109 |
Have an account? 
110 | 114 | Log in 115 | 116 |
117 |
118 | 119 | ); 120 | }; 121 | 122 | export default ForgotPasswordForm; 123 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/EventCardRegister.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Card from "../molecules/Card"; 3 | import CustomCheckbox from "../atoms/Checkbox"; 4 | import Button from "../atoms/Button"; 5 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 6 | import { api } from "@/utils/api"; 7 | import { useAuth } from "@/utils/AuthContext"; 8 | 9 | interface EventRegisterCardProps { 10 | eventId: string; 11 | overCapacity: boolean; 12 | attendeeId: string; 13 | date: Date; 14 | eventCanceled: boolean; 15 | attendeeBlacklisted: boolean; 16 | } 17 | 18 | const EventCardRegister = ({ 19 | eventId, 20 | overCapacity, 21 | attendeeId, 22 | date, 23 | eventCanceled, 24 | attendeeBlacklisted, 25 | }: EventRegisterCardProps) => { 26 | const { user } = useAuth(); 27 | const queryClient = useQueryClient(); 28 | 29 | // State for checkbox 30 | const [isChecked, setIsChecked] = useState(false); 31 | 32 | /** Handles clicking the Register button */ 33 | const { 34 | mutate: handleEventResgistration, 35 | isPending, 36 | isError, 37 | } = useMutation({ 38 | mutationKey: ["event", eventId], 39 | mutationFn: async () => { 40 | await api.post(`/events/${eventId}/attendees`, { 41 | attendeeid: attendeeId, 42 | }); 43 | }, 44 | onSuccess: () => { 45 | queryClient.invalidateQueries({ queryKey: ["event", eventId] }); 46 | queryClient.invalidateQueries({ 47 | queryKey: ["eventAttendance", eventId, attendeeId], 48 | }); 49 | }, 50 | }); 51 | 52 | /** Register button should be disabled if event is in the past */ 53 | const currentDate = new Date(); 54 | const eventInPast = date < currentDate; 55 | 56 | // If volunteer has been blacklisted 57 | if (attendeeBlacklisted) { 58 | return ( 59 | 60 |
Register for this event
61 |
62 | You have been blacklisted. You are no longer able to register for any 63 | event. 64 |
65 | 66 |
67 | ); 68 | } 69 | 70 | // If the event has been canceled 71 | else if (eventCanceled) { 72 | return ( 73 | 74 |
Register for this event
75 |
76 | The event has been canceled. You are no longer able to register. 77 |
78 | 79 |
80 | ); 81 | } 82 | 83 | // If the event has been concluded 84 | else if (eventInPast) { 85 | return ( 86 | 87 |
Register for this event
88 |
89 | The event has concluded. You are no longer able to register. 90 |
91 | 92 |
93 | ); 94 | } 95 | 96 | // If the event is over capacity 97 | else if (overCapacity) { 98 | return ( 99 | 100 |
Register for this event
101 |
102 | The event has reached capacity. You are no longer able to register. 103 |
104 | 105 |
106 | ); 107 | } 108 | 109 | // If the volunteer is eligible to register for the event 110 | else { 111 | return ( 112 | 113 |
114 | Register for this event 115 |
116 |
117 |
Terms and conditions
118 |
119 | By registering, I commit to attending the event. If I'm unable to 120 | participate, I will cancel my registration at least 24 hours before 121 | the event starts. Failure to do so may affect my volunteer status. 122 |
123 | setIsChecked(!isChecked)} 126 | /> 127 |
128 | 135 |
136 |
137 |
138 | ); 139 | } 140 | }; 141 | 142 | export default EventCardRegister; 143 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Table of Contents 3 | - [Table of Contents](#table-of-contents) 4 | - [Getting Started](#getting-started) 5 | - [Swagger](#swagger) 6 | - [Prisma](#prisma) 7 | - [Using Docker](#using-docker) 8 | 9 | ## Getting Started 10 | 11 | Start off by copying `.envtemplate` and renaming the copy to `.env`. Configure the node env and databse dev/prod URI's based on your current environment and secrets. See Prisma documentation for more information of database URL. 12 | 13 | If you don't have yarn and/or node on your system, run `brew install yarn` if you have homebrew installed. Alternatively, if you already have node you can also run `sudo npm i -g yarn`. 14 | 15 | Now, run `yarn install` to install all dependencies. Once that finishes, you're all set to run the backend! 16 | 17 | Simply use `yarn backend` to begin running the dev server locally, or `yarn build` to create a production build that can be run using `yarn start` 18 | 19 | This backend is centered around the MVC (model-view-controller) design pattern, intended to decouple code and reduce potential errors with the system. 20 | 21 | The backend also comes preconfigured with a jest test suite that will execute on PR creation. The jest suite is integrated with supertest and is located in `backend/tests` and can be run using `yarn test`. 22 | 23 | > Note: The backend is currently configured to run on port 8000. Ensure you kill this server before you run `yarn test` to avoid port conflicts. 24 | 25 | The backend is generally structured as follows: 26 | - `backend/src` -- contains all of the source code for the backend 27 | - `backend/prisma` -- contains all of the prisma schema and migration files 28 | - `backend/tests` -- contains all of the jest test suite 29 | 30 | In `backend/src` you will find the the initialization of express app, the routes, and the controllers. The controllers are where the bulk of the logic for the backend is located. The folder names are self-explanatory, and the files within them are named based on the route and controllers they are associated with. 31 | 32 | ## Swagger 33 | 34 | `backend/src` also contains swagger, which is currently configured to automically generate documentation for the backend. To view the documentation, run `yarn swagger` and then navigate to `localhost:8000/api-docs` in your browser. This step is important becuase it will auto-generate the swagger.json file that is used to generate the documentation. 35 | 36 | In order to structure our swagger documentation properly, ensure that you add the `#swagger.tags = ['name of section']` comment to the top of each function in the controller file. This will ensure that the swagger documentation is properly organized. For example, if you have a controller file that contains all of the routes for the `users` entity, you should add the following comment to the top of each method: 37 | ```js 38 | // #swagger.tags = ['Users'] 39 | ``` 40 | Swagger is useful for us because we can then import the swagger.json file into Postman and use it to generate a collection of requests that we can use to test our backend. To do this, simply import the api-docs.json file into Postman and it will automatically generate a collection of requests for you. 41 | 42 | ## Prisma 43 | This backend makes use of Prisma, a database toolkit that allows us to easily create and manage our database schema. To learn more about Prisma, check out their documentation. The documentation is really well written and easy to follow. 44 | 45 | The major components we are using are: 46 | - Prisma Client -- a type-safe database client that allows us to easily query our database 47 | - Prisma Migrate -- a database migration tool that allows us to easily create and manage our database schema 48 | - Prisma Studio -- a GUI that allows us to easily view and edit our database schema 49 | 50 | The Prisma schema is located in `backend/prisma/schema.prisma`. This file contains all of the models and relationships between them. To learn more about the Prisma schema, check out this page on the Prisma website. 51 | 52 | To create a new migration, run `npx prisma migrate dev --name `. This will create a new migration file in `backend/prisma/migrations`. To apply the migration to your database, run `yarn prisma migrate deploy`. To view the current state of the database, run `yarn prisma studio`. 53 | 54 | `backend/prisma/seed.ts` contains a script that will seed the database with some dummy data. To run this script, run `yarn prisma db seed`. 55 | 56 | 57 | ## Using Docker 58 | This backend can also be easily dockerized. Once you've created your env file, simply run `docker compose up -d` or `docker compose up` to start up the application (make sure the docker daemon is running). 59 | 60 | If this fails, try running `docker system prune`. The application should begin running at port 8000 on your local machine. 61 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Dropzone.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import Button from "./Button"; 3 | 4 | interface DropzoneProps { 5 | setError: React.Dispatch>; 6 | selectedFile: File | null; 7 | setSelectedFile: React.Dispatch>; 8 | label?: string; 9 | [key: string]: any; 10 | } 11 | 12 | /** 13 | * Dropzone component that allows uploading files. Requires a setState to be 14 | * passed in to handle file upload errors. 15 | */ 16 | const Dropzone = ({ 17 | setError, 18 | selectedFile, 19 | setSelectedFile, 20 | label, 21 | ...props 22 | }: DropzoneProps) => { 23 | const fileInputRef = useRef(null); // Ref for the file input 24 | const allowedFileTypes = ["image/jpg", "image/jpeg", "image/png"]; 25 | const maxFileSize = 50 * 1024 * 1024; // 50 MB 26 | 27 | const handleFileChange = (event: any) => { 28 | const file = event.target.files[0]; 29 | 30 | // Check for invalid file type 31 | if (!allowedFileTypes.includes(file.type)) { 32 | setError("Invalid file type. Please select a valid file."); 33 | event.target.value = null; 34 | setSelectedFile(null); 35 | } 36 | 37 | // Check if file exceeds max size 38 | else if (file.size > maxFileSize) { 39 | let maxSizeString = maxFileSize / (1024 * 1024); 40 | setError(`File size exceeds the maximum limit of ${maxSizeString} MB.`); 41 | event.target.value = null; 42 | setSelectedFile(null); 43 | } 44 | 45 | // Set file 46 | else { 47 | setSelectedFile(file); 48 | } 49 | }; 50 | 51 | const clearFile = (event: any) => { 52 | event.preventDefault(); 53 | event.target.value = null; 54 | setSelectedFile(null); 55 | 56 | if (fileInputRef.current) { 57 | fileInputRef.current.value = ""; // Reset file input 58 | } 59 | }; 60 | 61 | return ( 62 | <> 63 |
64 |
{label}
65 |
66 | 75 |
76 |
77 |
78 | 129 |
130 | 131 | ); 132 | }; 133 | 134 | export default Dropzone; 135 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import AppBar from "@mui/material/AppBar"; 3 | import Box from "@mui/material/Box"; 4 | import Toolbar from "@mui/material/Toolbar"; 5 | import Typography from "@mui/material/Typography"; 6 | import IconButton from "@mui/material/IconButton"; 7 | import MenuIcon from "@mui/icons-material/Menu"; 8 | import Drawer from "@mui/material/Drawer"; 9 | import List from "@mui/material/List"; 10 | import ListItem from "@mui/material/ListItem"; 11 | import ListItemButton from "@mui/material/ListItemButton"; 12 | import ListItemText from "@mui/material/ListItemText"; 13 | import Button from "@mui/material/Button"; 14 | import Link from "next/link"; 15 | 16 | interface AppBarProps { 17 | /** A list of nav labels and links in order of display */ 18 | navs: { label: string; link: string }[]; 19 | /** A list of components to display on the right of the appbar */ 20 | buttons: { label: string; onClick: () => void }[]; 21 | } 22 | 23 | const drawerWidth = 240; 24 | 25 | const DrawerAppBar = ({ navs, buttons }: AppBarProps) => { 26 | const [mobileOpen, setMobileOpen] = React.useState(false); 27 | 28 | const handleDrawerToggle = () => { 29 | setMobileOpen((prevState) => !prevState); 30 | }; 31 | 32 | const drawer = ( 33 | 34 | 35 | {navs.map((nav, index) => ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ))} 44 | {buttons.map((button, index) => ( 45 | 46 | 50 | 51 | 52 | 53 | ))} 54 | 55 | 56 | ); 57 | 58 | return ( 59 | 60 | 67 | 73 | 80 | {/* LFBI navbar brand */} 81 |
82 | 83 |
84 | 85 | {/* Navbar items */} 86 | 87 | {navs.map((nav, index) => ( 88 | 89 | 92 | 93 | ))} 94 | 95 | {/* Right aligned components */} 96 |
97 | {buttons.map((button, index) => ( 98 | 105 | ))} 106 |
107 |
108 | 114 | 115 | 116 |
117 |
118 | 119 | document.getElementById("__next")} 121 | variant="temporary" 122 | open={mobileOpen} 123 | onClose={handleDrawerToggle} 124 | anchor="right" 125 | ModalProps={{ 126 | keepMounted: true, // Better open performance on mobile. 127 | }} 128 | className="block md:hidden" 129 | sx={{ 130 | "& .MuiDrawer-paper": { 131 | boxSizing: "border-box", 132 | width: drawerWidth, 133 | }, 134 | }} 135 | > 136 | {drawer} 137 | 138 | 139 |
140 |
141 | ); 142 | }; 143 | 144 | export default DrawerAppBar; 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | 5 | Logo 6 | 7 | 8 |

Lagos Food Bank Initiative

9 | 10 |

11 | Lagos Food Bank Initiative is a non-profit organization that aims to reduce food waste and hunger in Lagos, Nigeria. 12 |
13 |

14 |
15 | 16 | 17 |
18 | Table of Contents 19 |
    20 |
  1. 21 | About The Project 22 | 25 |
  2. 26 |
  3. 27 | Getting Started 28 | 32 |
  4. 33 |
34 |
35 | 36 | 37 | 38 | ## About the Project 39 | 40 | This project aims to develop a volunteer management system for Lagos Food Bank Initiative. The system will allow volunteers to sign up for shifts, and for LFBI to manage volunteers and hours. 41 | 42 | ### Built With 43 | 44 | - [![Next][Next.js]][Next-url] 45 | - [![React][React.js]][React-url] 46 | - [![Express][Express.js]][Express-url] 47 | - [![Postresql][Prisma.io]][Prisma-url] 48 | 49 | 50 | 51 | ## Getting Started 52 | 53 | > Folder structure 54 | 55 | . 56 | ├── frontend # Next.js client 57 | ├── backend # Express server 58 | └── README.md 59 | 60 | ### Prerequisites 61 | 62 | - Nodejs 63 | - PostreSQL 64 | - Docker 65 | 66 | ### Installation 67 | 68 | 1. Install Docker 69 | 70 | - [Install Docker Desktop for macOS](https://docs.docker.com/desktop/install/mac-install/) 71 | - [Install Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/) 72 | 73 | 2. Install Dev Containers 74 | 75 | - In VS Code, install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) 76 | 77 | 3. Clone the Git repository 78 | 79 | ```sh 80 | git clone https://github.com/cornellh4i/lagos-volunteers.git 81 | ``` 82 | 83 | 4. Open the repository in VS Code. A button should appear in the bottom right corner asking to reopen the folder in a Dev Container. Click **Yes**. 84 | 85 | 5. Add necessary environment variables. In the `backend` folder, create a copy of `.env.template` and rename it as `.env`. In the `frontend` folder, create a copy of `.env.template` and rename is as `.env.local`. Fill out all the necessary details. Note that `.env` files **should never be committed to GitHub**. 86 | 87 | 6. Start the client and server 88 | 89 | ```sh 90 | # Run yarn setup in the root folder to build both the backend and the frontend 91 | yarn setup 92 | 93 | # Run yarn start in the root folder to start both the backend and the frontend 94 | yarn start 95 | 96 | # Run yarn test in the root folder to run Jest tests for the backend 97 | yarn test 98 | ``` 99 | 100 | > Note: See individual project files for more information on how to build and deploy the project. 101 | 102 | ## Contributors 103 | 104 | ### Fall 2024 105 | 106 | - Akinfolami Akin-Alamu 107 | - Jason Zheng 108 | 109 | ### Spring 2024 110 | 111 | - Leads 112 | - Akinfolami Akin-Alamu 113 | - Jason Zheng 114 | - Developers 115 | - Arushi Aggarwal 116 | - Owen Chen 117 | - Hubert He 118 | - Trung-Nghia Le 119 | - Brandon Lerit 120 | - Diego Marques 121 | - Tanvi Mavani 122 | - David Valarezo 123 | 124 | ### Fall 2023 125 | 126 | - Leads 127 | - Akinfolami Akin-Alamu 128 | - Jason Zheng 129 | - Developers 130 | - Sneha Rajaraman 131 | - Daniel Thorne 132 | - Louis Valencia 133 | - Sophie Wang 134 | - Yichen Yao 135 | - Hannah Zhang 136 | - Designers 137 | - Ella Keen Allee 138 | - Bella Besuud 139 | - Mika Labadan 140 | 141 | ### Spring 2023 142 | 143 | - Leads 144 | - Akinfolami Akin-Alamu 145 | - Jason Zheng 146 | - Developers 147 | - Jiayi Bai 148 | - Daniel Botros 149 | - Sneha Rajaraman 150 | - Sophie Wang 151 | - Yichen Yao 152 | - Hannah Zhang 153 | - Designers 154 | - Bella Besuud 155 | - Mika Labadan 156 | - Julia Papp 157 | 158 | 159 | 160 | 161 | [Next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white 162 | [Next-url]: https://nextjs.org/ 163 | [React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB 164 | [React-url]: https://reactjs.org/ 165 | [Prisma.io]: https://img.shields.io/badge/Prisma-3982CE?style=for-the-badge&logo=Prisma&logoColor=white 166 | [Express.js]: https://img.shields.io/badge/express.js-%23404d59.svg?style=for-the-badge&logo=express&logoColor=%2361DAFB 167 | [Express-url]: https://expressjs.com/ 168 | [Prisma-url]: https://www.prisma.io/ 169 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/EventCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Card from "../molecules/Card"; 3 | import IconText from "../atoms/IconText"; 4 | import FmdGoodIcon from "@mui/icons-material/FmdGood"; 5 | import Button from "../atoms/Button"; 6 | import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; 7 | import { ViewEventsEvent } from "@/utils/types"; 8 | import { format } from "date-fns"; 9 | import Link from "next/link"; 10 | import { displayDateInfo } from "@/utils/helpers"; 11 | import Chip from "../atoms/Chip"; 12 | 13 | interface EventCardProps { 14 | event: ViewEventsEvent; 15 | } 16 | 17 | const EventCardContent = ({ event }: EventCardProps) => { 18 | const formattedStartTime = format(new Date(event.startDate), "hh:mm a"); 19 | const formattedEndTime = format(new Date(event.endDate), "hh:mm a"); 20 | const timeRange = `${formattedStartTime} - ${formattedEndTime}`; 21 | const date = new Date(event.startDate); 22 | const dateInfo = 23 | event.status === "CANCELED" ? ( 24 | 25 | ) : ( 26 | displayDateInfo(date) 27 | ); 28 | const url = 29 | event.role === "Supervisor" 30 | ? `/events/${event.id}/attendees` 31 | : `/events/${event.id}/register`; 32 | const buttonText = 33 | event.role === "Supervisor" ? "Manage event" : "View event details"; 34 | 35 | return ( 36 |
37 |
38 |
43 | }> 44 | {dateInfo} 45 | 46 |
47 |
{timeRange}
48 |
49 |
{event.name}
50 | }> 51 | {event.location} 52 | 53 |
54 | {/* Bad UX behavior: Looks like button is as wide as length of location so it looks different with different event. */} 55 | 56 | 66 | 67 |
68 | ); 69 | }; 70 | 71 | const EventCard = ({ event }: EventCardProps) => { 72 | // Date formatting shenanigans 73 | const formattedStartDate = format(new Date(event.startDate), "d MMMM yyyy"); 74 | const weekdayStartDate = format(new Date(event.startDate), "EEEE"); 75 | 76 | return ( 77 |
78 | {/* Mobile view */} 79 |
80 | {/* Header */} 81 |
82 |
{weekdayStartDate}
83 |
84 | {formattedStartDate} 85 |
86 |
87 | 88 | {/* Divider */} 89 |
90 |
91 | 92 | {/* Event card */} 93 | 94 | 95 | 96 |
97 | 98 | {/* Desktop view */} 99 |
100 | {/* Left header */} 101 |
102 |
{formattedStartDate}
103 |

{weekdayStartDate}

104 |
105 | 106 | {/* Middle divider */} 107 |
108 |
109 |
110 | 111 | {/* Event card */} 112 |
113 | 114 |
115 | {/* Card left content */} 116 |
117 | 118 |
119 | 120 | {/* Card right image */} 121 |
122 |
123 | 131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 | ); 139 | }; 140 | 141 | export default EventCard; 142 | -------------------------------------------------------------------------------- /backend/prisma/migrations/20230217202647_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. 5 | - You are about to drop the column `name` on the `User` table. All the data in the column will be lost. 6 | - You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost. 7 | - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. 8 | 9 | */ 10 | -- CreateEnum 11 | CREATE TYPE "Role" AS ENUM ('ADMIN', 'SUPERVISOR', 'VOLUNTEER'); 12 | 13 | -- CreateEnum 14 | CREATE TYPE "Status" AS ENUM ('ACTIVE', 'INACTIVE', 'HOLD'); 15 | 16 | -- CreateEnum 17 | CREATE TYPE "Mode" AS ENUM ('VIRTUAL', 'IN_PERSON'); 18 | 19 | -- CreateEnum 20 | CREATE TYPE "EventStatus" AS ENUM ('DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'); 21 | 22 | -- DropForeignKey 23 | ALTER TABLE "Post" DROP CONSTRAINT "Post_authorId_fkey"; 24 | 25 | -- AlterTable 26 | ALTER TABLE "User" DROP CONSTRAINT "User_pkey", 27 | DROP COLUMN "name", 28 | ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | ADD COLUMN "firstName" TEXT, 30 | ADD COLUMN "hours" INTEGER DEFAULT 0, 31 | ADD COLUMN "image" TEXT DEFAULT 'https://via.placeholder.com/150', 32 | ADD COLUMN "lastName" TEXT, 33 | ADD COLUMN "nickname" TEXT, 34 | ADD COLUMN "role" "Role" NOT NULL DEFAULT 'VOLUNTEER', 35 | ADD COLUMN "status" "Status" NOT NULL DEFAULT 'ACTIVE', 36 | ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL, 37 | ADD COLUMN "verified" BOOLEAN NOT NULL DEFAULT false, 38 | ALTER COLUMN "id" DROP DEFAULT, 39 | ALTER COLUMN "id" SET DATA TYPE TEXT, 40 | ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id"); 41 | DROP SEQUENCE "User_id_seq"; 42 | 43 | -- DropTable 44 | DROP TABLE "Post"; 45 | 46 | -- CreateTable 47 | CREATE TABLE "Event" ( 48 | "id" TEXT NOT NULL, 49 | "name" TEXT NOT NULL, 50 | "subtitle" TEXT, 51 | "description" TEXT NOT NULL, 52 | "image" TEXT DEFAULT 'https://via.placeholder.com/500', 53 | "location" TEXT NOT NULL, 54 | "StartDate" TIMESTAMP(3) NOT NULL, 55 | "EndDate" TIMESTAMP(3) NOT NULL, 56 | "StartTime" TIMESTAMP(3) NOT NULL, 57 | "EndTime" TIMESTAMP(3) NOT NULL, 58 | "capacity" INTEGER NOT NULL, 59 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 60 | "updatedAt" TIMESTAMP(3) NOT NULL, 61 | "mode" "Mode" NOT NULL, 62 | "disciplinaryNotices" INTEGER, 63 | "status" "EventStatus" NOT NULL DEFAULT 'DRAFT', 64 | "ownerId" TEXT NOT NULL, 65 | 66 | CONSTRAINT "Event_pkey" PRIMARY KEY ("id") 67 | ); 68 | 69 | -- CreateTable 70 | CREATE TABLE "Preference" ( 71 | "id" TEXT NOT NULL, 72 | "userId" TEXT NOT NULL, 73 | "sendEmailNotification" BOOLEAN NOT NULL DEFAULT true, 74 | 75 | CONSTRAINT "Preference_pkey" PRIMARY KEY ("id") 76 | ); 77 | 78 | -- CreateTable 79 | CREATE TABLE "Tags" ( 80 | "id" TEXT NOT NULL, 81 | "name" TEXT NOT NULL, 82 | 83 | CONSTRAINT "Tags_pkey" PRIMARY KEY ("id") 84 | ); 85 | 86 | -- CreateTable 87 | CREATE TABLE "eventEnrollment" ( 88 | "role" "Role" NOT NULL, 89 | "userId" TEXT NOT NULL, 90 | "eventId" TEXT NOT NULL, 91 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 92 | "updatedAt" TIMESTAMP(3) NOT NULL, 93 | 94 | CONSTRAINT "eventEnrollment_pkey" PRIMARY KEY ("userId","eventId") 95 | ); 96 | 97 | -- CreateTable 98 | CREATE TABLE "_EventToTags" ( 99 | "A" TEXT NOT NULL, 100 | "B" TEXT NOT NULL 101 | ); 102 | 103 | -- CreateIndex 104 | CREATE UNIQUE INDEX "Event_ownerId_key" ON "Event"("ownerId"); 105 | 106 | -- CreateIndex 107 | CREATE UNIQUE INDEX "Preference_userId_key" ON "Preference"("userId"); 108 | 109 | -- CreateIndex 110 | CREATE UNIQUE INDEX "eventEnrollment_userId_role_key" ON "eventEnrollment"("userId", "role"); 111 | 112 | -- CreateIndex 113 | CREATE UNIQUE INDEX "_EventToTags_AB_unique" ON "_EventToTags"("A", "B"); 114 | 115 | -- CreateIndex 116 | CREATE INDEX "_EventToTags_B_index" ON "_EventToTags"("B"); 117 | 118 | -- AddForeignKey 119 | ALTER TABLE "Event" ADD CONSTRAINT "Event_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 120 | 121 | -- AddForeignKey 122 | ALTER TABLE "Preference" ADD CONSTRAINT "Preference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 123 | 124 | -- AddForeignKey 125 | ALTER TABLE "eventEnrollment" ADD CONSTRAINT "eventEnrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 126 | 127 | -- AddForeignKey 128 | ALTER TABLE "eventEnrollment" ADD CONSTRAINT "eventEnrollment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 129 | 130 | -- AddForeignKey 131 | ALTER TABLE "_EventToTags" ADD CONSTRAINT "_EventToTags_A_fkey" FOREIGN KEY ("A") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; 132 | 133 | -- AddForeignKey 134 | ALTER TABLE "_EventToTags" ADD CONSTRAINT "_EventToTags_B_fkey" FOREIGN KEY ("B") REFERENCES "Tags"("id") ON DELETE CASCADE ON UPDATE CASCADE; 135 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TabContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import Box from "@mui/material/Box"; 3 | import Tab from "@mui/material/Tab"; 4 | import { FormControl, MenuItem, SelectChangeEvent } from "@mui/material"; 5 | import Select from "../atoms/Select"; 6 | import { TabPanel, TabContext, TabList } from "@mui/lab"; 7 | 8 | interface RootTabContainerProps { 9 | /** A list of tab labels and panels in order of display */ 10 | tabs: { label: string; panel: ReactElement }[]; 11 | /** The local storage string to get the default tab from */ 12 | localStorageString: string; 13 | /** The element to align to the right of the tab bar */ 14 | left?: React.ReactElement; 15 | fullWidth?: boolean; 16 | } 17 | 18 | interface TabContainerProps { 19 | /** A list of tab labels and panels in order of display */ 20 | tabs: { label: string; panel: ReactElement }[]; 21 | /** The default tab to focus on */ 22 | value: string; 23 | /** The function to change the tab focus */ 24 | setValue: React.Dispatch>; 25 | /** The local storage string to get the default tab from */ 26 | localStorageString: string; 27 | /** The element to align to the right of the tab bar */ 28 | left?: React.ReactElement; 29 | fullWidth?: boolean; 30 | } 31 | 32 | const HorizontalTabContainer = ({ 33 | tabs, 34 | value, 35 | setValue, 36 | localStorageString, 37 | left, 38 | fullWidth, 39 | }: TabContainerProps) => { 40 | const handleChange = (event: React.SyntheticEvent, newValue: string) => { 41 | setValue(newValue); 42 | localStorage.setItem(localStorageString, newValue); 43 | }; 44 | return ( 45 |
46 | 47 | 48 |
49 | {left} 50 |
51 | 58 | {tabs.map((tab, index) => ( 59 | 66 | ))} 67 | 68 |
69 |
70 |
71 | {tabs.map((tab, index) => ( 72 | 73 | {tab.panel} 74 | 75 | ))} 76 |
77 |
78 |
79 |
80 | ); 81 | }; 82 | 83 | const VerticalTabContainer = ({ 84 | tabs, 85 | value, 86 | setValue, 87 | localStorageString, 88 | left: rightAlignedComponent, 89 | }: TabContainerProps) => { 90 | const handleChange = (event: SelectChangeEvent) => { 91 | setValue(event.target.value); 92 | localStorage.setItem(localStorageString, event.target.value); 93 | }; 94 | return ( 95 |
96 | 97 |
98 | {rightAlignedComponent} 99 | 106 |
107 |
108 |
{tabs[Number(value)].panel}
109 |
110 | ); 111 | }; 112 | 113 | /** 114 | * A TabContainer component appears as a horizontal bar of Tabs in desktop view, 115 | * and an element can be aligned to the right of the tab bar. The component 116 | * contains both the tabs and the panels associated with each tab 117 | */ 118 | const TabContainer = ({ 119 | tabs, 120 | localStorageString, 121 | left, 122 | fullWidth = false, 123 | }: RootTabContainerProps) => { 124 | // Note: value in local storage must be a number in a string, e.g. "0" or "1" 125 | const storageString = localStorage.getItem(localStorageString); 126 | const [value, setValue] = React.useState( 127 | localStorageString && storageString ? storageString : "0" 128 | ); 129 | return ( 130 | <> 131 |
132 | 140 |
141 |
142 | 149 |
150 | 151 | ); 152 | }; 153 | 154 | export default TabContainer; 155 | -------------------------------------------------------------------------------- /frontend/src/pages/privacy.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import DefaultTemplate from "@/components/templates/DefaultTemplate"; 3 | import Head from "next/head"; 4 | 5 | const PrivacyPage = () => { 6 | return ( 7 | <> 8 | 9 | Privacy - LFBI Volunteer Platform 10 | 11 | 12 |

Privacy Policy

13 | 14 |

15 | Lagos Food Bank Initiative operates the Volunteer Platform website, 16 | which provides the Service. 17 |

18 | 19 |

20 | This page is used to inform website visitors regarding our policies 21 | with the collection, use, and disclosure of Personal Information if 22 | anyone decided to use our Service, the Volunteer Platform website. 23 |

24 | 25 |

26 | If you choose to use our Service, then you agree to the collection and 27 | use of information in relation with this policy. The Personal 28 | Information that we collect are used for providing and improving the 29 | Service. We will not use or share your information with anyone except 30 | as described in this Privacy Policy. 31 |

32 | 33 |

34 | The terms used in this Privacy Policy have the same meanings as in our 35 | Terms and Conditions, which is accessible at Website URL, unless 36 | otherwise defined in this Privacy Policy. 37 |

38 | 39 |

Information Collection and Use

40 | 41 |

42 | For a better experience while using our Service, we may require you to 43 | provide us with certain personally identifiable information, including 44 | but not limited to your name, phone number, and email address. The 45 | information that we collect will be used to contact or identify you. 46 |

47 | 48 |

Log Data

49 | 50 |

51 | We want to inform you that whenever you visit our Service, we collect 52 | information that your browser sends to us that is called Log Data. 53 | This Log Data may include information such as your computer's Internet 54 | Protocol (“IP”) address, browser version, pages of our Service that 55 | you visit, the time and date of your visit, the time spent on those 56 | pages, and other statistics. 57 |

58 | 59 |

Cookies

60 | 61 |

62 | Cookies are files with small amount of data that is commonly used an 63 | anonymous unique identifier. These are sent to your browser from the 64 | website that you visit and are stored on your computer's hard drive. 65 |

66 | 67 |

68 | Our website uses these “cookies” to collection information and to 69 | improve our Service. You have the option to either accept or refuse 70 | these cookies, and know when a cookie is being sent to your computer. 71 | If you choose to refuse our cookies, you may not be able to use some 72 | portions of our Service. 73 |

74 | 75 |

Service Providers

76 | 77 |

78 | We may employ third-party companies and individuals due to the 79 | following reasons: 80 |

81 | 82 |
    83 |
  • To facilitate our Service;
  • 84 |
  • To provide the Service on our behalf;
  • 85 |
  • To perform Service-related services; or
  • 86 |
  • To assist us in analyzing how our Service is used.
  • 87 |
88 | 89 |

90 | We want to inform our Service users that these third parties have 91 | access to your Personal Information. The reason is to perform the 92 | tasks assigned to them on our behalf. However, they are obligated not 93 | to disclose or use the information for any other purpose. 94 |

95 | 96 |

Security

97 | 98 |

99 | We value your trust in providing us your Personal Information, thus we 100 | are striving to use commercially acceptable means of protecting it. 101 | But remember that no method of transmission over the internet, or 102 | method of electronic storage is 100% secure and reliable, and we 103 | cannot guarantee its absolute security. 104 |

105 | 106 |

Links to Other Sites

107 | 108 |

109 | Our Service may contain links to other sites. If you click on a 110 | third-party link, you will be directed to that site. Note that these 111 | external sites are not operated by us. Therefore, we strongly advise 112 | you to review the Privacy Policy of these websites. We have no control 113 | over, and assume no responsibility for the content, privacy policies, 114 | or practices of any third-party sites or services. 115 |

116 | 117 |

Changes to This Privacy Policy

118 | 119 |

120 | We may update our Privacy Policy from time to time. Thus, we advise 121 | you to review this page periodically for any changes. We will notify 122 | you of any changes by posting the new Privacy Policy on this page. 123 | These changes are effective immediately, after they are posted on this 124 | page. 125 |

126 | 127 |

Contact Us

128 | 129 |

130 | If you have any questions or suggestions about our Privacy Policy, do 131 | not hesitate to contact us. 132 |

133 |
134 | 135 | ); 136 | }; 137 | export default PrivacyPage; 138 | -------------------------------------------------------------------------------- /frontend/src/utils/useManageUserState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | useQueryClient, 4 | keepPreviousData, 5 | useQuery, 6 | } from "@tanstack/react-query"; 7 | import { api } from "./api"; 8 | import { formatRoleOrStatus, friendlyHours } from "@/utils/helpers"; 9 | import { GridPaginationModel, GridSortModel } from "@mui/x-data-grid"; 10 | 11 | function useManageUserState(state: "ACTIVE" | "INACTIVE") { 12 | const queryClient = useQueryClient(); 13 | 14 | /** Pagination model for the table */ 15 | const [paginationModel, setPaginationModel] = useState({ 16 | page: 0, 17 | pageSize: 10, 18 | }); 19 | 20 | /** Sorting Model for the table */ 21 | const [sortModel, setSortModel] = useState([ 22 | { field: "firstName", sort: "asc" }, 23 | ]); 24 | 25 | /** Search query for the table */ 26 | const [searchQuery, setSearchQuery] = useState(""); 27 | 28 | /** Function to fetch a batch of users with respect to the current pagination 29 | * and sorting states. 30 | * @param cursor is the cursor to fetch the next batch of users 31 | * @param prev is a boolean to determine if the function is being called to fetch the previous page 32 | * Note: If the prev boolean is true, we pass a negative value to the limit parameter to fetch 33 | * data before the current cursor. This is effectively the previous page. 34 | * @returns the current page of users (with respect to the current pagination and sorting states) 35 | */ 36 | const fetchBatchOfUsers = async ( 37 | cursor: string = "", 38 | prev: boolean = false 39 | ) => { 40 | const limit = prev ? -paginationModel.pageSize : paginationModel.pageSize; 41 | let url = `/users?status=${state}&limit=${limit}&after=${cursor}&sort=${sortModel[0].field}:${sortModel[0].sort}&include=hours`; 42 | if (searchQuery !== "") { 43 | url += `&emailOrName=${searchQuery}`; 44 | } 45 | const { response, data } = await api.get(url); 46 | return data; 47 | }; 48 | 49 | /** Tanstack query for fetching users 50 | * This runs initially when the component is rendered. 51 | * The default sorting and pagination states are used. 52 | * Note: The queryKey being used is very specific to the sorting and pagination states. 53 | * This is important because the queryKey will determine when cached data becomes stale. 54 | */ 55 | const { data, isPending, error, refetch, isPlaceholderData } = useQuery({ 56 | queryKey: [ 57 | `users_${state}`, 58 | paginationModel.page, 59 | sortModel[0].sort, 60 | sortModel[0].field, 61 | searchQuery, 62 | ], 63 | queryFn: async () => { 64 | return await fetchBatchOfUsers(); 65 | }, 66 | placeholderData: keepPreviousData, 67 | staleTime: 0, 68 | }); 69 | 70 | // TODO: Update type of rows. 71 | const rows: any[] = []; 72 | const totalNumberofData = data?.data.totalItems; 73 | data?.data.result.map((user: any) => { 74 | rows.push({ 75 | id: user.id, 76 | firstName: user.profile?.firstName + " " + user.profile?.lastName, 77 | email: user.email, 78 | phone: user.profile?.phoneNumber, 79 | role: formatRoleOrStatus(user.role), 80 | createdAt: new Date(user.createdAt), 81 | hours: friendlyHours(user.totalHours), 82 | }); 83 | }); 84 | 85 | /** Handles a change in the state - pagination model 86 | * @param newModel is the new pagination model 87 | * If the newModel is greater than the current page, fetch the next page 88 | * If the newModel is less than the current page, fetch the previous page 89 | */ 90 | const handlePaginationModelChange = async (newModel: GridPaginationModel) => { 91 | const currentPage = paginationModel.page; 92 | const nextPageCursor = data?.data.nextCursor; 93 | const prevPageCursor = data?.data.prevCursor; 94 | setPaginationModel(newModel); 95 | 96 | // Fetch Next Page 97 | if (currentPage < newModel.page) { 98 | await queryClient.fetchQuery({ 99 | queryKey: [ 100 | `users_${state}`, 101 | newModel.page, 102 | sortModel[0].sort, 103 | sortModel[0].field, 104 | searchQuery, 105 | ], 106 | queryFn: async () => await fetchBatchOfUsers(nextPageCursor), 107 | staleTime: 0, 108 | }); 109 | // Fetch previous page 110 | } else if (currentPage > newModel.page) { 111 | await queryClient.fetchQuery({ 112 | queryKey: [ 113 | `users_${state}`, 114 | newModel.page, 115 | sortModel[0].sort, 116 | sortModel[0].field, 117 | searchQuery, 118 | ], 119 | queryFn: async () => await fetchBatchOfUsers(prevPageCursor, true), 120 | staleTime: 0, 121 | }); 122 | } 123 | }; 124 | 125 | /** Handles sort model change 126 | * @param newModel is the new sort model 127 | * Note: Updating the sort model with invalidate the cache hence refetching the data 128 | */ 129 | const handleSortModelChange = async (newModel: GridSortModel) => { 130 | setPaginationModel((prev) => ({ ...prev, page: 0 })); 131 | setSortModel(newModel); 132 | }; 133 | 134 | /** Handles a change in the state - search query */ 135 | const handleSearchQuery = async (newQuery: string) => { 136 | setPaginationModel((prev) => ({ ...prev, page: 0 })); 137 | setSortModel([{ field: "firstName", sort: "asc" }]); 138 | setSearchQuery(newQuery); 139 | }; 140 | 141 | return { 142 | rows, 143 | isPending: isPending || isPlaceholderData, 144 | error, 145 | totalNumberofData, 146 | paginationModel, 147 | sortModel, 148 | searchQuery, 149 | handlePaginationModelChange, 150 | handleSortModelChange, 151 | handleSearchQuery, 152 | }; 153 | } 154 | 155 | export default useManageUserState; 156 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/LinkEmailPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Button from "../atoms/Button"; 3 | import TextField from "../atoms/TextField"; 4 | import { useForm, SubmitHandler } from "react-hook-form"; 5 | import Snackbar from "../atoms/Snackbar"; 6 | import { useAuth } from "@/utils/AuthContext"; 7 | import { 8 | updatePassword, 9 | reauthenticateWithPopup, 10 | GoogleAuthProvider, 11 | reload, 12 | } from "firebase/auth"; 13 | 14 | type FormValues = { 15 | password: string; 16 | confirmPassword: string; 17 | }; 18 | 19 | const LinkEmailPasswordForm = ({ 20 | setSuccessNotificationOpen, 21 | }: { 22 | setSuccessNotificationOpen: any; 23 | }) => { 24 | /** State variables for the notification popups */ 25 | const [errorNotificationOpen, setErrorNotificationOpen] = useState(false); 26 | 27 | const [errorMessage, setErrorMessage] = useState(""); 28 | const { user } = useAuth(); 29 | 30 | const { 31 | register, 32 | handleSubmit, 33 | watch, 34 | formState: { errors }, 35 | } = useForm(); 36 | 37 | const handleErrors = (errors: any) => { 38 | const errorParsed = errors?.split("/")[1]?.slice(0, -2); 39 | switch (errorParsed) { 40 | case "invalid-email": 41 | return "Invalid email address format."; 42 | case "user-disabled": 43 | return "User with this email has been disabled."; 44 | case "user-not-found": 45 | return "There is no user with this email address."; 46 | case "wrong-password": 47 | return "Old password is incorrect."; 48 | case "weak-password": 49 | return "Password must be at least 6 characters."; 50 | case "invalid-password": 51 | return "Invalid password."; 52 | case "requires-recent-login": 53 | return "Please reauthenticate to change your password."; 54 | case "too-many-requests": 55 | return "You have made too many requests to change your password. Please try again later."; 56 | case "provider-already-linked": 57 | return "Your provider has already been linked."; 58 | case "user-mismatch": 59 | return "The user email you signed in with does not match the user email here. Please sign in with the correct account."; 60 | default: 61 | return "Something went wrong. Please try again"; 62 | } 63 | }; 64 | 65 | const handleSubmitForm: SubmitHandler = async (data) => { 66 | const { password, confirmPassword } = data; 67 | try { 68 | if (password !== confirmPassword) { 69 | throw new Error("Passwords do not match"); 70 | } else if (user) { 71 | // Reauthenticate with Google and set password, then refresh page 72 | const provider = new GoogleAuthProvider(); 73 | const result = await reauthenticateWithPopup(user, provider); 74 | updatePassword(user, password); 75 | 76 | reload(user); 77 | setSuccessNotificationOpen(true); 78 | } 79 | } catch (error: any) { 80 | setErrorMessage(handleErrors(error.message)); 81 | setErrorNotificationOpen(true); 82 | } 83 | }; 84 | return ( 85 | <> 86 | {/* Profile update error snackbar */} 87 | setErrorNotificationOpen(false)} 91 | > 92 | Error: {errorMessage} 93 | 94 | 95 |

Set up a password

96 |
97 | Passwords should meet the following requirements: 98 |
    99 |
  • At least 6 characters in length
  • 100 |
  • Contain a mix of uppercase and lowercase letters
  • 101 |
  • Include at least one number and one special character
  • 102 |
103 |
104 |
105 |
106 | 118 | /.*[A-Z].*/.test(value) || 119 | "Password must contain at least one uppercase letter", 120 | hasLower: (value) => 121 | /.*[a-z].*/.test(value) || 122 | "Password must contain at least one lowercase letter", 123 | hasNumber: (value) => 124 | /.*[0-9].*/.test(value) || 125 | "Password must contain at least one number", 126 | hasSpecialChar: (value) => 127 | /.*[\W_].*/.test(value) || 128 | "Password must contain at least one special character", 129 | }, 130 | })} 131 | /> 132 |
133 |
134 | 142 | value === watch("password") || "Passwords do not match", 143 | }, 144 | })} 145 | /> 146 |
147 |
148 | 149 |
150 |
151 | 152 | ); 153 | }; 154 | export default LinkEmailPasswordForm; 155 | -------------------------------------------------------------------------------- /backend/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from "firebase-admin"; 2 | import { NextFunction, Request, Response } from "express"; 3 | import { UserRecord } from "firebase-admin/lib/auth/user-record"; 4 | import { userRole } from "@prisma/client"; 5 | const { getAuth, Error } = require("firebase-admin/auth"); 6 | import admin from "firebase-admin"; 7 | 8 | export interface IGetAuthTokenRequest extends Request { 9 | authToken: string; 10 | authId: string; 11 | } 12 | 13 | /** Retrieves a token from Firebase */ 14 | const getAuthToken = ( 15 | req: IGetAuthTokenRequest, 16 | res: Response, 17 | next: NextFunction 18 | ) => { 19 | if ( 20 | req.headers.authorization && 21 | req.headers.authorization.split(" ")[0] === "Bearer" 22 | ) { 23 | req.authToken = req.headers.authorization.split(" ")[1]; 24 | } else { 25 | req.authToken = " "; 26 | } 27 | next(); 28 | }; 29 | 30 | /** Authorizes a request if a token is present, returning an error otherwise. */ 31 | export const auth = ( 32 | req: IGetAuthTokenRequest, 33 | res: Response, 34 | next: NextFunction 35 | ) => { 36 | getAuthToken(req, res, async () => { 37 | try { 38 | const { authToken } = req; 39 | const userInfo = await firebase.auth().verifyIdToken(authToken); 40 | req.authId = userInfo.uid; 41 | return next(); 42 | } catch (e) { 43 | return res.status(401).send({ 44 | error: "You are not authorized to make this request", 45 | }); 46 | } 47 | }); 48 | }; 49 | 50 | export const NoAuth = ( 51 | req: IGetAuthTokenRequest, 52 | res: Response, 53 | next: NextFunction 54 | ) => { 55 | next(); 56 | }; 57 | 58 | /** 59 | * Authorizes a request if a token is present with the volunteer claim, 60 | * returning an error otherwise. 61 | */ 62 | export const authIfVolunteer = ( 63 | req: IGetAuthTokenRequest, 64 | res: Response, 65 | next: NextFunction 66 | ) => { 67 | getAuthToken(req, res, async () => { 68 | try { 69 | const userInfo = await firebase.auth().verifyIdToken(req.authToken); 70 | if (userInfo.volunteer === true) { 71 | req.authId = userInfo.uid; 72 | return next(); 73 | } 74 | return res.status(401).send({ 75 | error: "You are not a volunteer to make this request", 76 | }); 77 | } catch (e) { 78 | return res.status(401).send({ 79 | error: "You are not authorized to make this request", 80 | }); 81 | } 82 | }); 83 | }; 84 | 85 | /** 86 | * Authorizes a request if a token is present with the supervisor or admin claim, 87 | * returning an error otherwise. 88 | */ 89 | export const authIfSupervisorOrAdmin = ( 90 | req: IGetAuthTokenRequest, 91 | res: Response, 92 | next: NextFunction 93 | ) => { 94 | getAuthToken(req, res, async () => { 95 | try { 96 | const userInfo = await firebase.auth().verifyIdToken(req.authToken); 97 | if (userInfo.supervisor === true || userInfo.admin === true) { 98 | req.authId = userInfo.uid; 99 | return next(); 100 | } 101 | return res.status(401).send({ 102 | error: "You are not a supervisor to make this request", 103 | }); 104 | } catch (e) { 105 | return res.status(401).send({ 106 | error: "You are not authorized to make this request", 107 | }); 108 | } 109 | }); 110 | }; 111 | 112 | /** 113 | * Authorizes a request if a token is present with the admin claim, 114 | * returning an error otherwise. 115 | */ 116 | export const authIfAdmin = ( 117 | req: IGetAuthTokenRequest, 118 | res: Response, 119 | next: NextFunction 120 | ) => { 121 | getAuthToken(req, res, async () => { 122 | try { 123 | const { authToken } = req; 124 | const userInfo = await firebase.auth().verifyIdToken(authToken); 125 | if (userInfo.admin === true) { 126 | req.authId = userInfo.uid; 127 | return next(); 128 | } 129 | return res.status(401).send({ 130 | error: "You are not an admin to make this request", 131 | }); 132 | } catch (e) { 133 | return res.status(401).send({ 134 | error: "You are not authorized to make this request", 135 | }); 136 | } 137 | }); 138 | }; 139 | 140 | /** 141 | * Sets a user's custom claim to a volunteer 142 | * @param email is the user's email 143 | */ 144 | export const setVolunteerCustomClaims = async (email: string) => { 145 | try { 146 | const user = await getAuth().getUserByEmail(email); 147 | const customClaims = { 148 | admin: false, 149 | supervisor: false, 150 | volunteer: true, 151 | }; 152 | await getAuth().setCustomUserClaims(user.uid, customClaims); 153 | } catch (e) { 154 | console.log("Error creating new user:", e); 155 | } 156 | }; 157 | 158 | /** 159 | * Sets a user's custom claim to a supervisor 160 | * @param email is the user's email 161 | */ 162 | export const updateFirebaseUserToSupervisor = async (email: string) => { 163 | // const user = getAuth() 164 | try { 165 | const user = await getAuth().getUserByEmail(email); 166 | const customClaims = { 167 | admin: false, 168 | supervisor: true, 169 | volunteer: true, 170 | }; 171 | await getAuth().setCustomUserClaims(user.uid, customClaims); 172 | const updatedUserRecord = await getAuth().getUserByEmail(email); 173 | } catch (e) { 174 | console.log("Error creating new user:", e); 175 | } 176 | }; 177 | 178 | /** 179 | * Sets a user's custom claim to an admin 180 | * @param email is the user's email 181 | */ 182 | export const updateFirebaseUserToAdmin = async (email: string) => { 183 | try { 184 | const user = await getAuth().getUserByEmail(email); 185 | const customClaims = { 186 | admin: true, 187 | supervisor: true, 188 | volunteer: true, 189 | }; 190 | await getAuth().setCustomUserClaims(user.uid, customClaims); 191 | } catch (e) { 192 | console.log("Error creating new user:", e); 193 | } 194 | }; 195 | 196 | export default { 197 | auth, 198 | setVolunteerCustomClaims, 199 | updateFirebaseUserToSupervisor, 200 | updateFirebaseUserToAdmin, 201 | }; 202 | -------------------------------------------------------------------------------- /frontend/src/utils/useManageAttendeeState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | useQueryClient, 4 | keepPreviousData, 5 | useQuery, 6 | } from "@tanstack/react-query"; 7 | import { api } from "./api"; 8 | import { formatRoleOrStatus, friendlyHours } from "@/utils/helpers"; 9 | import { GridPaginationModel, GridSortModel } from "@mui/x-data-grid"; 10 | 11 | export default function useManageAttendeeState( 12 | state: "PENDING" | "CHECKED_IN" | "CHECKED_OUT" | "REMOVED" | "CANCELED", 13 | eventid: string, 14 | defaultHours: number 15 | ) { 16 | const queryClient = useQueryClient(); 17 | 18 | /** Pagination model for the table */ 19 | const [paginationModel, setPaginationModel] = useState({ 20 | page: 0, 21 | pageSize: 10, 22 | }); 23 | 24 | /** Sorting Model for the table */ 25 | const [sortModel, setSortModel] = useState([ 26 | { field: "firstName", sort: "asc" }, 27 | ]); 28 | 29 | const [searchQuery, setSearchQuery] = useState(""); 30 | 31 | /** Function to fetch a batch of users with respect to the current table pagination 32 | * and sorting states. 33 | * @param cursor is the cursor to fetch the next batch of users 34 | * @param prev is a boolean to determine if the function is being called to fetch the previous page 35 | * Note: If the prev boolean is true, we pass a negative value to the limit parameter to fetch 36 | * data before the current cursor. This is effectively the previous page. 37 | * @returns the current page of users (with respect to the current pagination and sorting states) 38 | */ 39 | const fetchBatchOfUsers = async ( 40 | cursor: string = "", 41 | prev: boolean = false 42 | ) => { 43 | const limit = prev ? -paginationModel.pageSize : paginationModel.pageSize; 44 | let url = `/users?eventId=${eventid}&attendeeStatus=${state}&limit=${limit}&after=${cursor}&sort=${sortModel[0].field}:${sortModel[0].sort}`; 45 | if (searchQuery !== "") { 46 | url += `&emailOrName=${searchQuery}`; 47 | } 48 | const { response, data } = await api.get(url); 49 | return data["data"]; 50 | }; 51 | 52 | // Get attendees for event 53 | const { data: attendees } = useQuery({ 54 | queryKey: ["attendees", eventid], 55 | queryFn: async () => { 56 | const { data } = await api.get(`/events/${eventid}/attendees`); 57 | return data.data; 58 | }, 59 | }); 60 | 61 | /** Tanstack query for fetching users 62 | * This runs initially when the component is rendered. 63 | * The default sorting and pagination states are used. 64 | * Note: The queryKey being used is very specific to the sorting and pagination states. 65 | * This is important because the queryKey will determine when cached data becomes stale. 66 | */ 67 | const { data, isPending, error, refetch, isPlaceholderData } = useQuery({ 68 | queryKey: [ 69 | eventid, 70 | `users_${state}`, 71 | paginationModel.page, 72 | sortModel[0].sort, 73 | sortModel[0].field, 74 | searchQuery, 75 | ], 76 | queryFn: async () => { 77 | return await fetchBatchOfUsers(); 78 | }, 79 | placeholderData: keepPreviousData, 80 | staleTime: 0, 81 | }); 82 | 83 | // TODO: Update type of rows. 84 | const rows: any[] = []; 85 | const totalNumberofData = data?.totalItems || 0; 86 | data?.result.map((user: any) => { 87 | const eventAttendance = attendees?.find( 88 | (attendee: any) => attendee.userId === user.id 89 | ); 90 | 91 | // Define custom hours 92 | let customHours = "N/A"; 93 | if ( 94 | eventAttendance && 95 | eventAttendance.attendeeStatus === "CHECKED_OUT" && 96 | eventAttendance.customHours !== null 97 | ) { 98 | customHours = friendlyHours(eventAttendance.customHours); 99 | } else if ( 100 | eventAttendance && 101 | eventAttendance.attendeeStatus === "CHECKED_OUT" 102 | ) { 103 | customHours = friendlyHours(defaultHours); 104 | } 105 | 106 | rows.push({ 107 | id: user.id, 108 | status: state, 109 | firstName: `${user.profile?.firstName} ${user.profile?.lastName}`, 110 | email: user.email, 111 | role: user.role, 112 | phone: user.profile?.phoneNumber || "N/A", 113 | customHours: customHours, 114 | }); 115 | }); 116 | 117 | const handlePaginationModelChange = async (newModel: GridPaginationModel) => { 118 | const currentPage = paginationModel.page; 119 | const nextPageCursor = data?.nextCursor; 120 | const prevPageCursor = data?.prevCursor; 121 | setPaginationModel(newModel); 122 | 123 | // Fetch Next Page 124 | if (currentPage < newModel.page) { 125 | await queryClient.fetchQuery({ 126 | queryKey: [ 127 | eventid, 128 | `users_${state}`, 129 | newModel.page, 130 | sortModel[0].sort, 131 | sortModel[0].field, 132 | searchQuery, 133 | ], 134 | queryFn: async () => await fetchBatchOfUsers(nextPageCursor), 135 | staleTime: 0, 136 | }); 137 | // Fetch previous page 138 | } else if (currentPage > newModel.page) { 139 | await queryClient.fetchQuery({ 140 | queryKey: [ 141 | eventid, 142 | `users_${state}`, 143 | newModel.page, 144 | sortModel[0].sort, 145 | sortModel[0].field, 146 | searchQuery, 147 | ], 148 | queryFn: async () => await fetchBatchOfUsers(prevPageCursor, true), 149 | staleTime: 0, 150 | }); 151 | } 152 | }; 153 | 154 | const handleSortModelChange = async (newModel: GridSortModel) => { 155 | setPaginationModel((prev) => ({ ...prev, page: 0 })); 156 | setSortModel(newModel); 157 | }; 158 | 159 | const handleSearchQuery = async (newQuery: string) => { 160 | setPaginationModel((prev) => ({ ...prev, page: 0 })); 161 | setSortModel([{ field: "firstName", sort: "asc" }]); 162 | setSearchQuery(newQuery); 163 | }; 164 | 165 | return { 166 | rows, 167 | isPending: isPending || isPlaceholderData, 168 | error, 169 | totalNumberofData, 170 | paginationModel, 171 | sortModel, 172 | searchQuery, 173 | handlePaginationModelChange, 174 | handleSortModelChange, 175 | handleSearchQuery, 176 | }; 177 | } 178 | --------------------------------------------------------------------------------