diff --git a/client/src/app/api.service.ts b/client/src/app/api.service.ts index 3d7c009..e65726e 100644 --- a/client/src/app/api.service.ts +++ b/client/src/app/api.service.ts @@ -16,6 +16,7 @@ import type { SuccessResponse, Staff, DataResponse, + Testimonial, } from "@repo/types"; @Injectable({ @@ -142,6 +143,20 @@ export class ApiService { return response; } + public async submitTestimonial( + testimonial: Partial, + ): Promise { + const request = await fetch(`${this.url}/submit/testimonials`, { + body: JSON.stringify(testimonial), + headers: { + "Content-type": "application/json", + }, + method: "POST", + }); + const response = await request.json() as SuccessResponse | ErrorResponse; + return response; + } + public async getData( type: | "appeals" diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 6127667..b5e237b 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -13,6 +13,8 @@ import { MeetingComponent } from "./forms/meeting/meeting.component.js"; import { MentorshipComponent } from "./forms/mentorship/mentorship.component.js"; import { StaffComponent } from "./forms/staff/staff.component.js"; +import { TestimonialComponent } + from "./forms/testimonial/testimonial.component.js"; import { HomeComponent } from "./home/home.component.js"; import { ReviewComponent } from "./review/review.component.js"; import type { Routes } from "@angular/router"; @@ -27,4 +29,5 @@ export const routes: Routes = [ { component: MentorshipComponent, path: "mentorship" }, { component: StaffComponent, path: "staff" }, { component: ReviewComponent, path: "review" }, + { component: TestimonialComponent, path: "testimonial" }, ]; diff --git a/client/src/app/forms/testimonial/testimonial.component.css b/client/src/app/forms/testimonial/testimonial.component.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/app/forms/testimonial/testimonial.component.html b/client/src/app/forms/testimonial/testimonial.component.html new file mode 100644 index 0000000..52c834e --- /dev/null +++ b/client/src/app/forms/testimonial/testimonial.component.html @@ -0,0 +1,12 @@ +

Testimonials

+

This form allows past clients and colleagues to submit reviews reflecting their positive experience with our work.

+

We will respond to these submissions when your testimonial is on our site.

+ + +
+ + + + +
+Back to home \ No newline at end of file diff --git a/client/src/app/forms/testimonial/testimonial.component.ts b/client/src/app/forms/testimonial/testimonial.component.ts new file mode 100644 index 0000000..7d6656f --- /dev/null +++ b/client/src/app/forms/testimonial/testimonial.component.ts @@ -0,0 +1,79 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { RouterModule } from "@angular/router"; +import { ApiService } from "../../api.service.js"; +import { ConsentComponent } from "../../consent/consent.component.js"; +import { ErrorComponent } from "../../error/error.component.js"; +import { MultiLineComponent } + from "../../inputs/multi-line/multi-line.component.js"; +import { SuccessComponent } from "../../success/success.component.js"; +import { UserinfoComponent } from "../../userinfo/userinfo.component.js"; + +@Component({ + imports: [ + RouterModule, + CommonModule, + ConsentComponent, + UserinfoComponent, + ErrorComponent, + ReactiveFormsModule, + MultiLineComponent, + SuccessComponent, + ], + selector: "app-testimonial", + styleUrl: "./testimonial.component.css", + templateUrl: "./testimonial.component.html", +}) +export class TestimonialComponent { + public loading = false; + public error = ""; + public success = false; + + public firstName = new FormControl(""); + public lastName = new FormControl(""); + public email = new FormControl(""); + + public content = new FormControl(""); + + public consent = new FormControl(false); + + public constructor(private readonly apiService: ApiService) {} + + public submit(event: MouseEvent): void { + this.error = ""; + const { form } = event.target as HTMLButtonElement; + const valid = form?.reportValidity(); + if (valid !== true) { + return; + } + this.loading = true; + + this.apiService. + submitTestimonial({ + consent: this.consent.value ?? false, + content: this.content.value ?? undefined, + email: this.email.value ?? undefined, + firstName: this.firstName.value ?? undefined, + lastName: this.lastName.value ?? undefined, + }). + then((response) => { + if ("error" in response) { + this.error = response.error; + this.loading = false; + } else { + this.error = ""; + this.success = true; + } + }). + catch(() => { + this.error = "An error occurred while submitting the form."; + this.loading = false; + }); + } +} diff --git a/client/src/app/home/home.component.html b/client/src/app/home/home.component.html index 3283f75..319abda 100644 --- a/client/src/app/home/home.component.html +++ b/client/src/app/home/home.component.html @@ -29,3 +29,6 @@ Mentorship Programme + + Testimonials + diff --git a/packages/types/src/forms/testimonial.ts b/packages/types/src/forms/testimonial.ts new file mode 100644 index 0000000..d00e97a --- /dev/null +++ b/packages/types/src/forms/testimonial.ts @@ -0,0 +1,13 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +export interface Testimonial { + consent: boolean; + content: string; + email: string; + firstName: string; + lastName: string; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 56b1f88..46158ce 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -11,6 +11,7 @@ import type { Event } from "./forms/event.js"; import type { Meeting } from "./forms/meeting.js"; import type { Mentorship } from "./forms/mentorship.js"; import type { Staff } from "./forms/staff.js"; +import type { Testimonial } from "./forms/testimonial.js"; import type { ReviewRequest } from "./requests/review.js"; import type { DataResponse } from "./responses/data.js"; import type { ErrorResponse } from "./responses/error.js"; @@ -28,4 +29,5 @@ export type { DataResponse, ErrorResponse, SuccessResponse, + Testimonial, }; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cd9b40c..5167760 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,3 +103,12 @@ model Mentorships { paymentUnderstanding Boolean createdAt DateTime @default(now()) } + +model Testimonials { + id String @id @default(auto()) @map("_id") @db.ObjectId + email String @unique + firstName String + lastName String + content String + createdAt DateTime @default(now()) +} diff --git a/server/src/handlers/submit/testimonialHandler.ts b/server/src/handlers/submit/testimonialHandler.ts new file mode 100644 index 0000000..a8d7ad9 --- /dev/null +++ b/server/src/handlers/submit/testimonialHandler.ts @@ -0,0 +1,64 @@ +/** + * @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 { Testimonial, ErrorResponse, SuccessResponse } from "@repo/types"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +/** + *Handles testimonial form submissions. + * @param database - The Prisma database client. + * @param request - The request object. + * @param response - The Fastify reply utility. + */ +export const submitTestimonialHandler = async( + database: PrismaClient, + request: FastifyRequest<{ Body: Testimonial }>, + response: FastifyReply<{ Reply: SuccessResponse | ErrorResponse }>, +): Promise => { + 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, + "testimonials", + ); + 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.testimonials.create({ + data, + }); + await sendMail("testimonials", data); + await response.send({ success: true }); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- we bein' lazy. + await logger.error("/submit/testimonials", error as Error); + await response.status(500).send({ error: error instanceof Error + ? error.message + : "Internal Server Error" }); + } +}; diff --git a/server/src/interfaces/databasePath.ts b/server/src/interfaces/databasePath.ts index a85fa39..4269bda 100644 --- a/server/src/interfaces/databasePath.ts +++ b/server/src/interfaces/databasePath.ts @@ -9,4 +9,5 @@ export type DatabasePath = | "appeals" | "events" | "meetings" | "mentorships" - | "staff"; + | "staff" + | "testimonials"; diff --git a/server/src/modules/validateBody.ts b/server/src/modules/validateBody.ts index 4dbc2ed..4f79f6e 100644 --- a/server/src/modules/validateBody.ts +++ b/server/src/modules/validateBody.ts @@ -70,6 +70,12 @@ const validators: Record> = { "difficultSituation", "leadershipSituation", ], + testimonials: [ + "firstName", + "lastName", + "email", + "content", + ], }; /** diff --git a/server/src/routes/submit.ts b/server/src/routes/submit.ts index 820995f..845c462 100644 --- a/server/src/routes/submit.ts +++ b/server/src/routes/submit.ts @@ -13,6 +13,8 @@ import { submitMeetingHandler } from "../handlers/submit/meetingHandler.js"; import { submitMentorshipHandler } from "../handlers/submit/mentorshipHandler.js"; import { submitStaffHandler } from "../handlers/submit/staffHandler.js"; +import { submitTestimonialHandler } + from "../handlers/submit/testimonialHandler.js"; import type { WrappedRoute } from "../interfaces/wrappedRoute.js"; import type { Appeal, @@ -24,6 +26,7 @@ import type { Staff, ErrorResponse, SuccessResponse, + Testimonial, } from "@repo/types"; /** @@ -31,6 +34,7 @@ import type { * @param database - The Prisma client. * @returns A Fastify plugin. */ +// eslint-disable-next-line max-lines-per-function -- Prisma typings don't allow us to mount these dynamically... export const submitRoutes: WrappedRoute = (database) => { return async(fastify) => { fastify.post<{ Body: Appeal; Reply: SuccessResponse | ErrorResponse }>( @@ -81,5 +85,12 @@ export const submitRoutes: WrappedRoute = (database) => { await submitStaffHandler(database, request, response); }, ); + + fastify.post<{ Body: Testimonial; Reply: SuccessResponse | ErrorResponse }>( + "/submit/testimonials", + async(request, response) => { + await submitTestimonialHandler(database, request, response); + }, + ); }; };