diff --git a/README.md b/README.md index c065b76..106e26c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ The VTMUNC website is hosted on an Amazon EC2 instance, which serves as the core - [⚙️ Environment Variables](docs/env-file.md) - [📂 Project Overview](docs/project-overview.md) - [📙 Site Overview](docs/site-overview.md) +- [📘 API Reference ](docs/api-reference.md) - [🎨 Style Guide](docs/style-guide.md) - [🌐 Deployment](docs/deployment.md) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..881b7be --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,6 @@ +# 📘 API Reference + +Here are all of the APIs the VTMUNC website uses. Linked are more detailed api documentation, including example headers and responses, as well as which endpoints require authorization. + +## Table of Contents +- [/applicants](/docs/api/applicants.md) \ No newline at end of file diff --git a/docs/api/applicants.md b/docs/api/applicants.md new file mode 100644 index 0000000..944bf04 --- /dev/null +++ b/docs/api/applicants.md @@ -0,0 +1,206 @@ +# /applicants + +## Base URL +`/api/applicants` + +## Overview +The `/api/applicants` endpoint allows for managing applicant data. It supports the following operations: +- `GET`: Retrieve all applicants. +- `POST`: Add a new applicant. +- `DELETE`: Delete an existing applicant by ID. + +## Authorization +- The `GET` and `DELETE` methods require an authorization JWT for an admin user. + +## Endpoints + +### GET `/api/applicants` + +#### Description +Fetches a list of all applicants. + +#### Fields of each applicant +| Field | Type | Description | +|--------------------------|--------|--------------------------------------| +| `advisorPhone` | string | The phone number of the advisor. | +| `delegationSize` | number | The size of the delegation. | +| `headDelegateName` | string | The name of the head delegate. | +| `schoolName` | string | The name of the school. | +| `advisorOtherInformation`| string | Other information about the advisor. | +| `commentsOrQuestions` | string | Any comments or questions. | +| `advisorEmail` | string | The email of the advisor. | +| `advisorRelation` | string | The relation of the advisor. | +| `schoolMailingAddress` | string | The mailing address of the school. | +| `headDelegateEmail` | string | The email of the head delegate. | +| `headDelegatePhone` | string | The phone number of the head delegate.| +| `advisorName` | string | The name of the advisor. | +| `delegateList` | string | A list of delegates. | +| `id` | number | Unique applicant id. | +| `date` | string | When applicant was created (YYYY-MM-DD)| +| `invoiceStatus` | number | Status of invoice (1: Invoice not sent, 2: Payment not received, 3: Payment received) | + + +#### Headers +| Key | Value | +|---------------|------------------------| +| Authorization | Bearer `` | + +#### Response +| Status Code | Description | +|-------------------------|-----------------------------------------| +| 200 OK | Returns a JSON array of applicants. | +| 500 Internal Server Error | An error occurred while fetching applicants. | + +#### Example Request +```http +GET /api/applicants HTTP/1.1 +Host: yourdomain.com +Authorization: Bearer +``` + +#### Example Response +```json +[ + { + "advisorPhone": "123-456-7890", + "delegationSize": 10, + "headDelegateName": "John Doe", + "schoolName": "Example School", + "advisorOtherInformation": "Other information", + "commentsOrQuestions": "Comments", + "advisorEmail": "advisor@example.com", + "advisorRelation": "Relation", + "schoolMailingAddress": "123 Example St", + "headDelegateEmail": "delegate@example.com", + "headDelegatePhone": "123-456-7890", + "advisorName": "Jane Smith", + "delegateList": "Jeffery Visonaire, Adam", + "invoiceStatus": 0, + "id": 12345, + "date": "2024-07-02" + } +] +``` + +### POST `/api/applicants` + +#### Description +Creates a new applicant. + +#### Request Body +| Field | Type | Description | +|--------------------------|--------|--------------------------------------| +| `advisorPhone` | string | The phone number of the advisor. | +| `delegationSize` | number | The size of the delegation. | +| `headDelegateName` | string | The name of the head delegate. | +| `schoolName` | string | The name of the school. | +| `advisorOtherInformation`| string | Other information about the advisor. | +| `commentsOrQuestions` | string | Any comments or questions. | +| `advisorEmail` | string | The email of the advisor. | +| `advisorRelation` | string | The relation of the advisor. | +| `schoolMailingAddress` | string | The mailing address of the school. | +| `headDelegateEmail` | string | The email of the head delegate. | +| `headDelegatePhone` | string | The phone number of the head delegate.| +| `advisorName` | string | The name of the advisor. | +| `delegateList` | string | A list of delegates. | + +#### Response +| Status Code | Description | +|-------------------------|----------------------------------------------| +| 200 OK | Returns the created applicant. | +| 500 Internal Server Error | An error occurred while creating the applicant. | + +#### Example Request +```http +POST /api/applicants HTTP/1.1 +Host: yourdomain.com +Content-Type: application/json + +{ + "advisorPhone": "123-456-7890", + "delegationSize": 10, + "headDelegateName": "John Doe", + "schoolName": "Example School", + "advisorOtherInformation": "Other information", + "commentsOrQuestions": "Comments", + "advisorEmail": "advisor@example.com", + "advisorRelation": "Relation", + "schoolMailingAddress": "123 Example St", + "headDelegateEmail": "delegate@example.com", + "headDelegatePhone": "123-456-7890", + "advisorName": "Jane Smith", + "delegateList": "Jeffery Visonaire, Adam" +} +``` + +#### Example Response +```json +{ + "advisorPhone": "123-456-7890", + "delegationSize": 10, + "headDelegateName": "John Doe", + "schoolName": "Example School", + "advisorOtherInformation": "Other information", + "commentsOrQuestions": "Comments", + "advisorEmail": "advisor@example.com", + "advisorRelation": "Relation", + "schoolMailingAddress": "123 Example St", + "headDelegateEmail": "delegate@example.com", + "headDelegatePhone": "123-456-7890", + "advisorName": "Jane Smith", + "delegateList": "Jeffery Visonaire, Adam", + "invoiceStatus": 0, + "id": 12345, + "date": "2024-07-02" +} +``` + +### DELETE `/api/applicants` + +#### Description +Deletes an existing applicant by ID. + +#### Headers +| Key | Value | +|---------------|------------------------| +| Authorization | Bearer `` | + +#### Request Body +| Field | Type | Description | +|-------|--------|----------------------------| +| `id` | number | The ID of the applicant to delete. | + +#### Response +| Status Code | Description | +|-------------------------|-----------------------------------------| +| 200 OK | Returns a message confirming the deletion. | +| 400 Bad Request | The request body did not contain an ID. | +| 500 Internal Server Error | An error occurred while deleting the applicant. | + +#### Example Request +```http +DELETE /api/applicants HTTP/1.1 +Host: yourdomain.com +Content-Type: application/json +Authorization: Bearer + +{ + "id": 12345 +} +``` + +#### Example Response +```json +{ + "message": "Deleted applicant with id: 12345" +} +``` + +## Error Handling +In case of errors, the API returns appropriate HTTP status codes along with a message indicating the error. + +### Error Responses +| Status Code | Description | +|-------------------------|---------------------------------------------------| +| 500 Internal Server Error | An error occurred while processing the request. | +| 400 Bad Request | The request body did not contain necessary information. | diff --git a/site/app/api/applicants/route.js b/site/app/api/applicants/route.js index 87f8fba..cfef4b2 100644 --- a/site/app/api/applicants/route.js +++ b/site/app/api/applicants/route.js @@ -1,5 +1,11 @@ +/** + * Endpoint: /api/applicants + * + * Reference: /docs/api/applicants.md + */ + import { generateRandomId, getCurrentDate } from "@/app/utils/util"; -import { getApplicants, putApplicant } from "../db/dynamodb"; +import { deleteApplicant, getApplicants, putApplicant } from "../db/dynamodb"; export async function GET() { try { @@ -8,7 +14,7 @@ export async function GET() { } catch (e) { console.log(e); - return new Response("Error with applicants", 500); + return new Response("Error with applicants", {status: 500}); } } @@ -33,12 +39,29 @@ export async function POST(request) { id: generateRandomId(), date: getCurrentDate() } - console.log(applicant); + await putApplicant(applicant); return Response.json(body); } catch (e) { console.log(e); - return new Response("Error with applicants", 500); + return new Response("Error with applicants", {status: 500}); + } +} + +export async function DELETE(request) { + try { + const body = await request.json(); + const id = body.id; + if (!id) { + return new Response("No id sent", {status: 400}) + } + + await deleteApplicant(id); + return new Response(`Deleted applicant with id: ${id}`, {status: 200}); + } + catch (e) { + console.log(e); + return new Response("Error with deletion", {status: 500}); } } \ No newline at end of file diff --git a/site/app/api/db/dynamodb.js b/site/app/api/db/dynamodb.js index ae7121a..6a2d0ad 100644 --- a/site/app/api/db/dynamodb.js +++ b/site/app/api/db/dynamodb.js @@ -24,7 +24,16 @@ export async function putApplicant(applicant) { Item: applicant } - console.log(params); - await dynamoClient.put(params).promise(); } + +export async function deleteApplicant(id) { + const params = { + TableName: TABLE_NAME, + Key: { + id: id + } + } + + await dynamoClient.delete(params).promise(); +} \ No newline at end of file diff --git a/site/app/applicants/DashboardPage.css b/site/app/applicants/DashboardPage.css index c0efdfe..b727066 100644 --- a/site/app/applicants/DashboardPage.css +++ b/site/app/applicants/DashboardPage.css @@ -6,6 +6,7 @@ margin-bottom: 40px; margin-right: 10px; margin-left: 10px; + border-radius: 0px; } .card > h2 { @@ -13,5 +14,14 @@ } .dashboard { - background: var(--secondary-background); + background-color: var(--secondary-background); +} + +.transparent-overlay-delete-confirmation { + background-color: rgba(0, 0, 0, 0.2); +} + +.applicantsLoading { + width: 150px; + height: 150px; } \ No newline at end of file diff --git a/site/app/applicants/DeleteConfirmationModal.jsx b/site/app/applicants/DeleteConfirmationModal.jsx new file mode 100644 index 0000000..de4be07 --- /dev/null +++ b/site/app/applicants/DeleteConfirmationModal.jsx @@ -0,0 +1,71 @@ +import { useRef, useState } from "react"; + +const APPLICANTS_URL = "/api/applicants"; + +export default function DeleteConfirmationModal({applicant, showDeleteConfirmation, setShowDeleteConfirmation, deleteApplicant}) { + + const modalRef = useRef(null); + const [error, setError] = useState(""); + + async function handleDelete() { + try { + const response = await fetch(APPLICANTS_URL, { + method: "DELETE", + mode: "cors", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + id: applicant.id + }) + }); + + if (response.status != 200) { + throw new Error(`Response error code: ${response.status}, Response message: ${response.body}`) + } + + setShowDeleteConfirmation(false); + deleteApplicant(applicant); + } + catch (e) { + + setError(`${e.name}: ${e.message}`); + } + } + + function handleClickOnModal(event) { + if (modalRef && !modalRef.current.contains(event.target)) { + setShowDeleteConfirmation(false); + } + } + + + return ( +
+
+
+
+

Confirm Deletion

+ +
+
+

Are you sure you want to delete this applicant?

+
+

Advisor: {applicant.advisorName}

+

Advisor Relation: {applicant.advisorRelation}

+

School Name: {applicant.schoolName}

+

School Address: {applicant.schoolMailingAddress}

+

Delegation Size: {applicant.delegationSize}

+
+
+ + +
+ {error} +
+
+
+ ) +} \ No newline at end of file diff --git a/site/app/applicants/page.jsx b/site/app/applicants/page.jsx index a7b844c..63507f4 100644 --- a/site/app/applicants/page.jsx +++ b/site/app/applicants/page.jsx @@ -1,10 +1,11 @@ -import React from "react"; -import { getApplicants } from "@/app/api/db/dynamodb"; +"use client" + +import React, { useEffect, useState } from "react"; import Dashboard from "./Dashboard"; import Link from "next/link"; import "./DashboardPage.css"; import { invoiceStatusToString } from "@/app/utils/applicantUtils"; -import ErrorPage from "../components/ErrorPage"; +import DeleteConfirmationModal from "./DeleteConfirmationModal"; /** * Page displays some metrics on our current applicants including total number of participants, @@ -13,58 +14,112 @@ import ErrorPage from "../components/ErrorPage"; * * @returns {JSX.Element} dashboard page */ -export default async function DashboardPage() { - try { - const applicants = await getApplicants(); - // Sorts applicants in descending order by date in place - applicants.sort((a, b) => new Date(b.date) - new Date(a.date)); +export default function DashboardPage() { + + // List of applicants + const [applicants, setApplicants] = useState([]); - return ( -
-
-

Delegations

- -
- - - - - - - - - - - - - - { - applicants.map((applicant) => { - return ( - - - - - - - - - - ) - }) - } - -
DateSchoolAdvisor NameAdvisor EmailDelegation SizeInvoice StatusAction
{applicant.date}{applicant.schoolName}{applicant.advisorName}{applicant.advisorEmail}{applicant.delegationSize}{invoiceStatusToString(applicant.invoiceStatus)} - Edit | Details | Delete -
+ // Whether delete popup box is visible + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + + // Applicant to be deleted + const [applicantToDelete, setApplicantToDelete] = useState({}); + + + + // Fetch data for applicants + useEffect(() => { + const getData = async () => { + const res = await fetch("/api/applicants", {cache: "no-store"}); + const newApplicants = await res.json(); + setApplicants(newApplicants); + } + getData(); + }, []); + + // Open delete confirmation + function handleApplicantDelete(applicant) { + setApplicantToDelete(applicant); + setShowDeleteConfirmation(true); + } + + // Delete applicant + function deleteApplicant(applicantToDelete) { + const newApplicants = applicants.filter((applicant) => applicantToDelete.id != applicant.id); + setApplicants(newApplicants); + } -
+ + if (applicants.length == 0) { + return ( +
+
+ Loading...
) - - } - catch (e) { - return } + + // Sorts applicants in descending order by date in place + applicants.sort((a, b) => new Date(b.date) - new Date(a.date)); + + return ( +
+ +
+

Delegations

+ +

Delegation List

+ Manual registration +
+ + + + + + + + + + + + + + + { + applicants.map((applicant) => { + return ( + + + + + + + + + + + ) + }) + } + +
DateSchoolAdvisor NameAdvisor EmailDelegation SizeInvoice StatusActionDelete
{applicant.date}{applicant.schoolName}{applicant.advisorName}{applicant.advisorEmail}{applicant.delegationSize}{invoiceStatusToString(applicant.invoiceStatus)} + Edit | Details + + +
+ +
+
+
+ ) + + } \ No newline at end of file diff --git a/site/app/components/ErrorPage.jsx b/site/app/components/ErrorPage.jsx deleted file mode 100644 index 9cefd04..0000000 --- a/site/app/components/ErrorPage.jsx +++ /dev/null @@ -1,16 +0,0 @@ - -/** - * - * @param {Error} e - * @property {string} name - name of error - * @property {string} message - error message - * @returns {JSX.Element} a pretty error page - */ -export default function ErrorPage(e) { - return ( -
-

Error: {e.name}

-

{e.message}

-
- ) -} \ No newline at end of file diff --git a/site/app/components/Footer.jsx b/site/app/components/Footer.jsx index f5f9714..37344b6 100644 --- a/site/app/components/Footer.jsx +++ b/site/app/components/Footer.jsx @@ -29,7 +29,7 @@ function Footer() {

- +
diff --git a/site/app/globals.css b/site/app/globals.css index 8eda0a2..9cb2dab 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -4,4 +4,4 @@ --primary-background: #EFE9E7; --secondary-background: #F2F1E8; --tertiary: #DEBB8F; -} +} \ No newline at end of file diff --git a/site/app/register/page.jsx b/site/app/register/page.jsx index 93e16e9..8e87d0c 100644 --- a/site/app/register/page.jsx +++ b/site/app/register/page.jsx @@ -10,7 +10,7 @@ export const metadata = { export default function RegisterPage() { return (
-
+

Registration

diff --git a/site/middleware.js b/site/middleware.js index b60f07b..b0e0a69 100644 --- a/site/middleware.js +++ b/site/middleware.js @@ -1,29 +1,44 @@ import { NextResponse } from "next/server"; import { decrypt } from "@/lib"; +/** + * Checks if user is admin + * @param {Request} request + * @returns {bool} true if user is admin, false if it is not + */ +async function isUserAdmin(request) { + // Retrieve the cookie value from the request + const token = request.cookies.get("vtmunc_admin")?.value; + + if (token) { + // If we have cookies we then decrypt + try { + await decrypt(token); + return true; + } catch (error) { + // If any decryption errors then redirect to login + return false; + } + } else { + return false; + } +} + export async function middleware(request) { const { pathname, origin } = request.nextUrl; - const url = request.nextUrl.clone(); - // Takes origin and adds /login which is our login page - const loginUrl = origin + '/login' - // Check if it is protected path '/admin' + // Check if it is protected path '/applicants' if (pathname.toLowerCase() === '/applicants') { + if (!await isUserAdmin(request)) { + return NextResponse.redirect(origin + '/login'); + } + } - // Retrieve the cookie value from the request - const token = request.cookies.get("vtmunc_admin")?.value; - - if (token) { - // If we have cookies we then decrypt - try { - const parsed = await decrypt(token); - } catch (error) { - // If any decryption errors then redirect to login - return NextResponse.redirect(loginUrl); - } - } else { - return NextResponse.redirect(loginUrl); + // Check if it is protected path '/applicants' + if (pathname.toLowerCase() === '/api/applicants' && request.method !== "POST") { + if (!await isUserAdmin(request)) { + return new Response("Unauthorized", {status: 401}); } } diff --git a/site/public/Images/Instagram.svg b/site/public/icons/instagram.svg similarity index 100% rename from site/public/Images/Instagram.svg rename to site/public/icons/instagram.svg diff --git a/site/public/icons/trash.svg b/site/public/icons/trash.svg new file mode 100644 index 0000000..fe25bef --- /dev/null +++ b/site/public/icons/trash.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file