feat: we have a functional prototype

This commit is contained in:
2025-02-17 02:43:15 -08:00
parent 107f54d269
commit 2f08f1ed18
142 changed files with 12012 additions and 96 deletions
@@ -0,0 +1,37 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { listUnreviewedSubmissions } from "../../modules/genericDataQueries.js";
import { logger } from "../../utils/logger.js";
import type { DatabasePath } from "../../interfaces/databasePath.js";
import type { PrismaClient } from "@prisma/client";
import type { FastifyReply } from "fastify";
/**
* Queries the database for a specific submission type (based on
* the route parameter) and returns all unreviewed submissions.).
* @param database - The Prisma client.
* @param route - The type of data to list.
* @param response - The Fastify reply object.
*/
export const listHandler = async(
database: PrismaClient,
route: DatabasePath,
response: FastifyReply,
): Promise<void> => {
try {
const data = await listUnreviewedSubmissions(database, route);
await response.code(200).send({
data: data.sort((a, b) => {
return a.createdAt.getTime() - b.createdAt.getTime();
}),
});
} catch (error) {
await logger.error(`/list/${route}`, error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,55 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
checkSubmissionExists,
markSubmissionReviewed,
} from "../../modules/genericDataQueries.js";
import { logger } from "../../utils/logger.js";
import type { DatabasePath } from "../../interfaces/databasePath.js";
import type { PrismaClient } from "@prisma/client";
import type {
ErrorResponse,
ReviewRequest,
SuccessResponse,
} from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
* Queries the database for a specific submission type (based on
* the route parameter) and returns all unreviewed submissions.).
* @param database - The Prisma client.
* @param route - The type of data to list.
* @param data - The fastify data.
* @param data.request - The request body.
* @param data.response - The Fastify reply object.
*/
export const reviewHandler = async(
database: PrismaClient,
route: DatabasePath,
{
request,
response,
}: {
request: FastifyRequest<{ Body: ReviewRequest }>;
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>;
},
): Promise<void> => {
try {
const { submissionId } = request.body;
const exists = await checkSubmissionExists(database, route, submissionId);
if (!exists) {
response.code(404).send({ error: `${route} submission not found.` });
return;
}
await markSubmissionReviewed(database, route, submissionId);
await response.code(200).send({ success: true });
} catch (error) {
await logger.error(`/review/${route}`, error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,61 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { validateBody } from "../../modules/validateBody.js";
import { logger } from "../../utils/logger.js";
import { sendMail } from "../../utils/mailer.js";
import type { PrismaClient } from "@prisma/client";
import type { Appeal, ErrorResponse, SuccessResponse } from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
*Handles appeal form submissions.
* @param database - The Prisma database client.
* @param request - The request object.
* @param response - The Fastify reply utility.
*/
export const submitAppealHandler = async(
database: PrismaClient,
request: FastifyRequest<{ Body: Appeal }>,
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>,
): Promise<void> => {
try {
const isInvalid = validateBody(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We're passing a narrower type and TS hates that?
request.body as unknown as Record<string, unknown>,
"appeals",
);
if (isInvalid !== null) {
await response.status(400).send({ error: isInvalid });
return;
}
const exists = await database.appeals.findUnique({
where: {
email: request.body.email,
},
});
if (exists !== null) {
await response.status(429).send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"You have already submitted an appeal. Please wait for it to be reviewed.",
});
return;
}
const data = { ...request.body };
// @ts-expect-error -- We're deleting a property here.
delete data.consent;
await database.appeals.create({
data,
});
await sendMail("appeal", data);
await response.send({ success: true });
} catch (error) {
await logger.error("/submit/appeals", error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,63 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { validateBody } from "../../modules/validateBody.js";
import { logger } from "../../utils/logger.js";
import { sendMail } from "../../utils/mailer.js";
import type { PrismaClient } from "@prisma/client";
import type { Commission, ErrorResponse, SuccessResponse } from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
*Handles commission form submissions.
* @param database - The Prisma client.
* @param request - The request object.
* @param response - The Fastify reply utility.
*/
export const submitCommissionHandler = async(
database: PrismaClient,
request: FastifyRequest<{ Body: Commission }>,
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>,
): Promise<void> => {
try {
const isInvalid = validateBody(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We're passing a narrower type and TS hates that?
request.body as unknown as Record<string, unknown>,
"commissions",
);
if (isInvalid !== null) {
await response.status(400).send({ error: isInvalid });
return;
}
const exists = await database.commissions.findUnique({
where: {
email: request.body.email,
},
});
if (exists !== null) {
await response.
status(429).
send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"You have already submitted a commission request. Please wait for it to be reviewed.",
});
return;
}
const data = { ...request.body };
// @ts-expect-error -- We're deleting a property here.
delete data.consent;
await database.commissions.create({
data,
});
await sendMail("commissions", data);
await response.send({ success: true });
} catch (error) {
await logger.error("/submit/commissions", error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,63 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { validateBody } from "../../modules/validateBody.js";
import { logger } from "../../utils/logger.js";
import { sendMail } from "../../utils/mailer.js";
import type { PrismaClient } from "@prisma/client";
import type { Contact, ErrorResponse, SuccessResponse } from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
*Handles contact form submissions.
* @param database - The Prisma database client.
* @param request - The request object.
* @param response - The Fastify reply utility.
*/
export const submitContactHandler = async(
database: PrismaClient,
request: FastifyRequest<{ Body: Contact }>,
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>,
): Promise<void> => {
try {
const isInvalid = validateBody(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We're passing a narrower type and TS hates that?
request.body as unknown as Record<string, unknown>,
"contacts",
);
if (isInvalid !== null) {
await response.status(400).send({ error: isInvalid });
return;
}
const exists = await database.contacts.findUnique({
where: {
email: request.body.email,
},
});
if (exists !== null) {
await response.
status(429).
send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"You have already submitted a contact request. Please wait for it to be reviewed.",
});
return;
}
const data = { ...request.body };
// @ts-expect-error -- We're deleting a property here.
delete data.consent;
await database.contacts.create({
data,
});
await sendMail("contact", data);
await response.send({ success: true });
} catch (error) {
await logger.error("/submit/contacts", error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,63 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { validateBody } from "../../modules/validateBody.js";
import { logger } from "../../utils/logger.js";
import { sendMail } from "../../utils/mailer.js";
import type { PrismaClient } from "@prisma/client";
import type { Event, ErrorResponse, SuccessResponse } from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
*Handles event form submissions.
* @param database - The Prisma database client.
* @param request - The request object.
* @param response - The Fastify reply utility.
*/
export const submitEventHandler = async(
database: PrismaClient,
request: FastifyRequest<{ Body: Event }>,
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>,
): Promise<void> => {
try {
const isInvalid = validateBody(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We're passing a narrower type and TS hates that?
request.body as unknown as Record<string, unknown>,
"events",
);
if (isInvalid !== null) {
await response.status(400).send({ error: isInvalid });
return;
}
const exists = await database.events.findUnique({
where: {
email: request.body.email,
},
});
if (exists !== null) {
await response.
status(429).
send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"You have already submitted an event request. Please wait for it to be reviewed.",
});
return;
}
const data = { ...request.body };
// @ts-expect-error -- We're deleting a property here.
delete data.consent;
await database.events.create({
data,
});
await sendMail("event", data);
await response.send({ success: true });
} catch (error) {
await logger.error("/submit/events", error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,63 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { validateBody } from "../../modules/validateBody.js";
import { logger } from "../../utils/logger.js";
import { sendMail } from "../../utils/mailer.js";
import type { PrismaClient } from "@prisma/client";
import type { Meeting, ErrorResponse, SuccessResponse } from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
*Handles meeting form submissions.
* @param database - The Prisma client.
* @param request - The request object.
* @param response - The Fastify reply utility.
*/
export const submitMeetingHandler = async(
database: PrismaClient,
request: FastifyRequest<{ Body: Meeting }>,
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>,
): Promise<void> => {
try {
const isInvalid = validateBody(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We're passing a narrower type and TS hates that?
request.body as unknown as Record<string, unknown>,
"meetings",
);
if (isInvalid !== null) {
await response.status(400).send({ error: isInvalid });
return;
}
const exists = await database.meetings.findUnique({
where: {
email: request.body.email,
},
});
if (exists !== null) {
await response.
status(429).
send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"You have already submitted a meeting request. Please wait for it to be reviewed.",
});
return;
}
const data = { ...request.body };
// @ts-expect-error -- We're deleting a property here.
delete data.consent;
await database.meetings.create({
data,
});
await sendMail("meeting", data);
await response.send({ success: true });
} catch (error) {
await logger.error("/submit/meetings", error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,63 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { validateBody } from "../../modules/validateBody.js";
import { logger } from "../../utils/logger.js";
import { sendMail } from "../../utils/mailer.js";
import type { PrismaClient } from "@prisma/client";
import type { Mentorship, ErrorResponse, SuccessResponse } from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
*Handles mentorship form submissions.
* @param database - The Prisma client.
* @param request - The request object.
* @param response - The Fastify reply utility.
*/
export const submitMentorshipHandler = async(
database: PrismaClient,
request: FastifyRequest<{ Body: Mentorship }>,
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>,
): Promise<void> => {
try {
const isInvalid = validateBody(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We're passing a narrower type and TS hates that?
request.body as unknown as Record<string, unknown>,
"mentorships",
);
if (isInvalid !== null) {
await response.status(400).send({ error: isInvalid });
return;
}
const exists = await database.mentorships.findUnique({
where: {
email: request.body.email,
},
});
if (exists !== null) {
await response.
status(429).
send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"You have already submitted a mentorship request. Please wait for it to be reviewed.",
});
return;
}
const data = { ...request.body };
// @ts-expect-error -- We're deleting a property here.
delete data.consent;
await database.mentorships.create({
data,
});
await sendMail("mentorship", data);
await response.send({ success: true });
} catch (error) {
await logger.error("/submit/mentorships", error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
@@ -0,0 +1,63 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { validateBody } from "../../modules/validateBody.js";
import { logger } from "../../utils/logger.js";
import { sendMail } from "../../utils/mailer.js";
import type { PrismaClient } from "@prisma/client";
import type { Staff, ErrorResponse, SuccessResponse } from "@repo/types";
import type { FastifyReply, FastifyRequest } from "fastify";
/**
*Handles staff form submissions.
* @param database - The Prisma database client.
* @param request - The request object.
* @param response - The Fastify reply utility.
*/
export const submitStaffHandler = async(
database: PrismaClient,
request: FastifyRequest<{ Body: Staff }>,
response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>,
): Promise<void> => {
try {
const isInvalid = validateBody(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- We're passing a narrower type and TS hates that?
request.body as unknown as Record<string, unknown>,
"staff",
);
if (isInvalid !== null) {
await response.status(400).send({ error: isInvalid });
return;
}
const exists = await database.staff.findUnique({
where: {
email: request.body.email,
},
});
if (exists !== null) {
await response.
status(429).
send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long string.
"You have already submitted a staff application. Please wait for it to be reviewed.",
});
return;
}
const data = { ...request.body };
// @ts-expect-error -- We're deleting a property here.
delete data.consent;
await database.staff.create({
data,
});
await sendMail("staff", data);
await response.send({ success: true });
} catch (error) {
await logger.error("/submit/staff", error);
await response.status(500).send({ error: error instanceof Error
? error.message
: "Internal Server Error" });
}
};
+37
View File
@@ -0,0 +1,37 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { onRequestHookHandler } from "fastify";
/**
* Guards API routes except for "/submit/" and the base routes. Requires
* that the token in the Authorization header matches the API token in the
* environment variables.
* @param request - The request payload from the server.
* @param response - The reply handler from Fastify.
* @returns A Fastify reply if the request is invalid, otherwise undefined.
*/
export const authHook: onRequestHookHandler = async(request, response) => {
if (
request.url.startsWith("/submit")
|| request.url === "/"
|| request.url === "/health"
|| request.url === "/validate-token"
) {
return undefined;
}
const token = request.headers.authorization;
if (token === undefined) {
return await response.
status(400).
send({ error: "API token is required for this request." });
}
if (token !== process.env.API_TOKEN) {
return await response.status(401).send({ error: "Invalid API token." });
}
return undefined;
};
+27
View File
@@ -0,0 +1,27 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { onRequestHookHandler } from "fastify";
/**
* Ensures that form submissions only come from our web application.
* @param request - The request payload from the server.
* @param response - The reply handler from Fastify.
* @returns A Fastify reply if the request is invalid, otherwise undefined.
*/
export const corsHook: onRequestHookHandler = async(request, response) => {
if (!request.url.startsWith("/submit")) {
return undefined;
}
if (request.headers.origin !== "https://forms.nhcarrigan.com") {
return await response.
status(403).
send({
error: "Forms can only be submitted through our website. Thanks.",
});
}
return undefined;
};
+49
View File
@@ -0,0 +1,49 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
import fastify from "fastify";
import { authHook } from "./hooks/auth.js";
import { corsHook } from "./hooks/cors.js";
import { baseRoutes } from "./routes/base.js";
import { listRoutes } from "./routes/list.js";
import { reviewRoutes } from "./routes/review.js";
import { submitRoutes } from "./routes/submit.js";
import { logger } from "./utils/logger.js";
/**
* Starts up a web server for health monitoring.
*/
try {
const database = new PrismaClient();
await database.$connect();
await logger.log("debug", "Connected to the database.");
const server = fastify({
logger: false,
});
server.addHook("preHandler", authHook);
server.addHook("preHandler", corsHook);
server.register(baseRoutes(database));
server.register(listRoutes(database));
server.register(reviewRoutes(database));
server.register(submitRoutes(database));
server.listen({ port: 1234 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
void logger.log("debug", "Server listening on port 1234.");
});
} catch (error) {
if (error instanceof Error) {
void logger.error("instantiate server", error);
} else {
void logger.error("instantiate server", new Error("Unknown error"));
}
}
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export type DatabasePath = | "appeals"
| "commissions"
| "contacts"
| "events"
| "meetings"
| "mentorships"
| "staff";
+17
View File
@@ -0,0 +1,17 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { PrismaClient } from "@prisma/client";
import type {
FastifyReply,
FastifyRequest,
} from "fastify";
export type WrappedHook = (
database: PrismaClient
)=> (
request: FastifyRequest,
response: FastifyReply
)=> Promise<FastifyReply | undefined>;
+9
View File
@@ -0,0 +1,9 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyPluginAsync } from "fastify";
export type WrappedRoute = (database: PrismaClient)=> FastifyPluginAsync;
+134
View File
@@ -0,0 +1,134 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { DatabasePath } from "../interfaces/databasePath.js";
import type {
Appeals,
Commissions,
Contacts,
Events,
Meetings,
Mentorships,
PrismaClient,
Staff,
} from "@prisma/client";
/**
* Generic wrapper to list all unreviewed submissions of a given type.
* @param database - The Prisma client.
* @param route - The type of data to list.
* @returns An array of unreviewed submissions.
*/
const listUnreviewedSubmissions = async(
database: PrismaClient,
route: DatabasePath,
): Promise<
Array<
Appeals | Commissions | Contacts | Events | Meetings | Mentorships | Staff
>
> => {
const query = { };
switch (route) {
case "appeals":
return await database.appeals.findMany(query);
case "commissions":
return await database.commissions.findMany(query);
case "contacts":
return await database.contacts.findMany(query);
case "events":
return await database.events.findMany(query);
case "meetings":
return await database.meetings.findMany(query);
case "mentorships":
return await database.mentorships.findMany(query);
case "staff":
return await database.staff.findMany(query);
default:
return [];
}
};
/**
* Checks if a submission exists in the database.
* @param database - The Prisma client.
* @param route - The type of data to check.
* @param id - The ID of the submission to check.
* @returns A boolean indicating if the submission exists.
*/
const checkSubmissionExists = async(
database: PrismaClient,
route: DatabasePath,
id: string,
): Promise<boolean> => {
const query = { where: { id } };
switch (route) {
case "appeals":
return Boolean(await database.appeals.findUnique(query));
case "commissions":
return Boolean(await database.commissions.findUnique(query));
case "contacts":
return Boolean(await database.contacts.findUnique(query));
case "events":
return Boolean(await database.events.findUnique(query));
case "meetings":
return Boolean(await database.meetings.findUnique(query));
case "mentorships":
return Boolean(await database.mentorships.findUnique(query));
case "staff":
return Boolean(await database.staff.findUnique(query));
default:
return false;
}
};
/**
* Marks a submission as reviewed in the database.
* @param database - The Prisma client.
* @param route - The type of data to mark.
* @param id - The ID of the submission to mark.
*/
const markSubmissionReviewed = async(
database: PrismaClient,
route: DatabasePath,
id: string,
): Promise<void> => {
const update = {
where: {
id,
},
};
switch (route) {
case "appeals":
await database.appeals.delete(update);
break;
case "commissions":
await database.commissions.delete(update);
break;
case "contacts":
await database.contacts.delete(update);
break;
case "events":
await database.events.delete(update);
break;
case "meetings":
await database.meetings.delete(update);
break;
case "mentorships":
await database.mentorships.delete(update);
break;
case "staff":
await database.staff.delete(update);
break;
default:
break;
}
};
export {
listUnreviewedSubmissions,
checkSubmissionExists,
markSubmissionReviewed,
};
+113
View File
@@ -0,0 +1,113 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { DatabasePath } from "../interfaces/databasePath.js";
const validators: Record<DatabasePath, Array<string>> = {
appeals: [
"firstName",
"lastName",
"email",
"understandBinding",
"sanctionType",
"caseNumber",
"sanctionPlatform",
"platformUsername",
"sanctionReason",
"sanctionFair",
"behaviourViolation",
"appealReason",
"behaviourImprove",
],
commissions: [ "firstName", "lastName", "email", "companyName", "request" ],
contacts: [ "firstName", "lastName", "email", "companyName", "request" ],
events: [
"firstName",
"lastName",
"email",
"companyName",
"eventDescription",
"eventTopic",
"eventLocation",
"eventDate",
"eventBudget",
"travelCovered",
"lodgingCovered",
"foodCovered",
],
meetings: [
"firstName",
"lastName",
"email",
"companyName",
"sessionLength",
"sessionGoal",
"paymentUnderstanding",
],
mentorships: [
"firstName",
"lastName",
"email",
"companyName",
"mentorshipGoal",
"currentFocus",
"paymentUnderstanding",
],
staff: [
"firstName",
"lastName",
"email",
"understandVolunteer",
"platform",
"platformUsername",
"whyJoin",
"currentBehaviour",
"priorExperience",
"internalConflict",
"handlingTrauma",
"difficultSituation",
"leadershipSituation",
],
};
/**
* Confirms that all expected properties are present in the body.
* Asserts that the user has consented to receiving communications.
* Validates that values are not falsy.
* @param body - The request body.
* @param type - The type of form being submitted.
* @returns A string if the body is invalid, or null if it is valid.
*/
// eslint-disable-next-line complexity -- No.
export const validateBody = (
body: Record<string, unknown>,
type: DatabasePath,
): string | null => {
if (!("consent" in body) || body.consent !== true) {
// eslint-disable-next-line stylistic/max-len -- This is a long string.
return "You must consent to receiving communications in order to submit our forms.";
}
for (const key of validators[type]) {
if (!(key in body) || body[key] === undefined || body[key] === null) {
return `The ${key} field is required.`;
}
if (typeof body[key] === "string" && body[key].trim() === "") {
return `The ${key} field cannot be empty.`;
}
if (typeof body[key] === "number" && Number.isNaN(body[key])) {
return `The ${key} field must be a number.`;
}
if (typeof body[key] === "boolean" && !body[key]) {
return `The ${key} field must be true.`;
}
if (Array.isArray(body[key]) && body[key].length === 0) {
return `The ${key} field must have at least one item.`;
}
if (typeof body[key] === "object" && Object.keys(body[key]).length === 0) {
return `The ${key} field must have at least one property.`;
}
}
return null;
};
+40
View File
@@ -0,0 +1,40 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { sendMail } from "../utils/mailer.js";
import type { WrappedRoute } from "../interfaces/wrappedRoute.js";
/**
* Handles the root path redirect and the health check endpoint.
* @param _database - The Prisma client.
* @returns A Fastify plugin.
*/
export const baseRoutes: WrappedRoute = (_database) => {
return async(fastify) => {
fastify.get("/", (_request, response) => {
response.redirect("https://forms.nhcarrigan.com", 301);
});
fastify.get("/health", (_request, response) => {
response.status(200).send("API is up!");
});
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify convention.
fastify.post<{ Body: { token: string; ip: string } }>(
"/validate-token",
async(request, response) => {
const { token, ip } = request.body;
if (token === process.env.API_TOKEN) {
response.status(200).send({ valid: true });
await sendMail(`token`, {
message: `URGENT: Token validated by ${ip}. If this was not you, cycle the secrets IMMEDIATELY!`,
});
} else {
response.status(401).send({ valid: false });
}
},
);
};
};
+32
View File
@@ -0,0 +1,32 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { listHandler } from "../handlers/list/generateHandlers.js";
import type { DatabasePath } from "../interfaces/databasePath.js";
import type { WrappedRoute } from "../interfaces/wrappedRoute.js";
/**
* Mounts all of the routes to `/list/{type}` the various form submissions.
* @param database - The Prisma client.
* @returns A Fastify plugin.
*/
export const listRoutes: WrappedRoute = (database) => {
return async(fastify) => {
const routes: Array<DatabasePath> = [
"appeals",
"commissions",
"contacts",
"events",
"meetings",
"mentorships",
"staff",
];
for (const route of routes) {
fastify.get(`/list/${route}`, async(_request, response) => {
await listHandler(database, route, response);
});
}
};
};
+42
View File
@@ -0,0 +1,42 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { reviewHandler } from "../handlers/review/generateHandlers.js";
import type { DatabasePath } from "../interfaces/databasePath.js";
import type { WrappedRoute } from "../interfaces/wrappedRoute.js";
import type {
ErrorResponse,
ReviewRequest,
SuccessResponse,
} from "@repo/types";
/**
* Handles all of the routes to `/review/{type}` a form submission.
* @param database - The Prisma client.
* @returns A Fastify plugin.
*/
export const reviewRoutes: WrappedRoute = (database) => {
return async(fastify) => {
const routes: Array<DatabasePath> = [
"appeals",
"commissions",
"contacts",
"events",
"meetings",
"mentorships",
"staff",
];
for (const route of routes) {
fastify.put<{
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify convention.
Body: ReviewRequest;
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify convention.
Reply: SuccessResponse | ErrorResponse;
}>(`/review/${route}`, async(request, response) => {
await reviewHandler(database, route, { request, response });
});
}
};
};
+85
View File
@@ -0,0 +1,85 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- We're dealing with Fastify's conventions here. */
import { submitAppealHandler } from "../handlers/submit/appealHandler.js";
import { submitCommissionHandler }
from "../handlers/submit/commissionHandler.js";
import { submitContactHandler } from "../handlers/submit/contactHandler.js";
import { submitEventHandler } from "../handlers/submit/eventHandler.js";
import { submitMeetingHandler } from "../handlers/submit/meetingHandler.js";
import { submitMentorshipHandler }
from "../handlers/submit/mentorshipHandler.js";
import { submitStaffHandler } from "../handlers/submit/staffHandler.js";
import type { WrappedRoute } from "../interfaces/wrappedRoute.js";
import type {
Appeal,
Commission,
Contact,
Event,
Meeting,
Mentorship,
Staff,
ErrorResponse,
SuccessResponse,
} from "@repo/types";
/**
* Handles all of the routes to `/submit/` the various forms.
* @param database - The Prisma client.
* @returns A Fastify plugin.
*/
export const submitRoutes: WrappedRoute = (database) => {
return async(fastify) => {
fastify.post<{ Body: Appeal; Reply: SuccessResponse | ErrorResponse }>(
"/submit/appeals",
async(request, response) => {
await submitAppealHandler(database, request, response);
},
);
fastify.post<{ Body: Commission; Reply: SuccessResponse | ErrorResponse }>(
"/submit/commissions",
async(request, response) => {
await submitCommissionHandler(database, request, response);
},
);
fastify.post<{ Body: Contact; Reply: SuccessResponse | ErrorResponse }>(
"/submit/contacts",
async(request, response) => {
await submitContactHandler(database, request, response);
},
);
fastify.post<{ Body: Event; Reply: SuccessResponse | ErrorResponse }>(
"/submit/events",
async(request, response) => {
await submitEventHandler(database, request, response);
},
);
fastify.post<{ Body: Meeting; Reply: SuccessResponse | ErrorResponse }>(
"/submit/meetings",
async(request, response) => {
await submitMeetingHandler(database, request, response);
},
);
fastify.post<{ Body: Mentorship; Reply: SuccessResponse | ErrorResponse }>(
"/submit/mentorships",
async(request, response) => {
await submitMentorshipHandler(database, request, response);
},
);
fastify.post<{ Body: Staff; Reply: SuccessResponse | ErrorResponse }>(
"/submit/staff",
async(request, response) => {
await submitStaffHandler(database, request, response);
},
);
};
};
+12
View File
@@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
export const logger = new Logger(
"Forms API",
process.env.LOG_TOKEN ?? "",
);
+40
View File
@@ -0,0 +1,40 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { createTransport } from "nodemailer";
const mailer = createTransport({
auth: {
pass: process.env.EMAIL_PASS,
user: "noreply@nhcarrigan.com",
},
host: "mail.nhcarrigan.com",
port: 465,
secure: true,
});
/**
* Sends an email to Naomi when a form is submitted.
* @param formName - A SINGULAR, NOT PLURAL, name representing the form.
* @param formData - The data submitted in the form.
*/
export const sendMail = async(
formName: string,
formData: Record<string, string | boolean | number>,
): Promise<void> => {
const text = Object.entries(formData).
map(([ key, value ]) => {
return `${key}: ${String(value)}`;
}).
join("\n");
await mailer.sendMail({
from: "noreply@nhcarrigan.com",
subject: `New ${formName} submission!`,
text: text,
to: "naomi@nhcarrigan.com",
});
};