generated from nhcarrigan/template
feat: we have a functional prototype
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
export type DatabasePath = | "appeals"
|
||||
| "commissions"
|
||||
| "contacts"
|
||||
| "events"
|
||||
| "meetings"
|
||||
| "mentorships"
|
||||
| "staff";
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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 ?? "",
|
||||
);
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user