From 56e2b391b272c4d1d0e6f6215e2acd3d2591805c Mon Sep 17 00:00:00 2001 From: Naomi Carrigan <commits@nhcarrigan.com> Date: Thu, 20 Feb 2025 15:40:31 -0800 Subject: [PATCH 1/3] feat: add form for submitting testimonials --- client/src/app/api.service.ts | 15 ++++ client/src/app/app.routes.ts | 3 + .../testimonial/testimonial.component.css | 0 .../testimonial/testimonial.component.html | 12 +++ .../testimonial/testimonial.component.ts | 79 +++++++++++++++++++ client/src/app/home/home.component.html | 3 + packages/types/src/forms/testimonial.ts | 13 +++ packages/types/src/index.ts | 2 + prisma/schema.prisma | 9 +++ .../src/handlers/submit/testimonialHandler.ts | 64 +++++++++++++++ server/src/interfaces/databasePath.ts | 3 +- server/src/modules/validateBody.ts | 6 ++ server/src/routes/submit.ts | 11 +++ 13 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 client/src/app/forms/testimonial/testimonial.component.css create mode 100644 client/src/app/forms/testimonial/testimonial.component.html create mode 100644 client/src/app/forms/testimonial/testimonial.component.ts create mode 100644 packages/types/src/forms/testimonial.ts create mode 100644 server/src/handlers/submit/testimonialHandler.ts 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<Testimonial>, + ): Promise<SuccessResponse | ErrorResponse> { + 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 @@ +<h1>Testimonials</h1> +<p>This form allows past clients and colleagues to submit reviews reflecting their positive experience with our work.</p> +<p>We will respond to these submissions when your testimonial is <a href="https://testimonials.nhcarrigan.com" target="_blank">on our site</a>.</p> +<app-error *ngIf="error" error="{{ error }}"></app-error> +<app-success *ngIf="success"></app-success> +<form *ngIf="!loading"> + <app-userinfo [firstNameControl]="firstName" [lastNameControl]="lastName" [emailControl]="email" [companyControl]="company"></app-userinfo> + <app-multi-line label="Share your experience working with us!" [control]="content"></app-multi-line> + <app-consent [control]="consent"></app-consent> + <button type="button" (click)="submit($event)">Submit</button> +</form> +<a routerLink="/">Back to home</a> \ 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 @@ <a routerLink="/mentorship"> <i class="fa-solid fa-brain" aria-hidden="true"></i> Mentorship Programme </a> +<a routerLink="/testimonial"> + <i class="fa-solid fa-star-half-stroke" aria-hidden="true"></i> Testimonials +</a> 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<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>, + "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<DatabasePath, Array<string>> = { "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); + }, + ); }; }; -- 2.47.2 From 5ea66c56a08a67cddade6d376e9df34899141bcb Mon Sep 17 00:00:00 2001 From: Naomi Carrigan <commits@nhcarrigan.com> Date: Thu, 20 Feb 2025 15:54:42 -0800 Subject: [PATCH 2/3] fix: remaining lint issues --- client/src/app/api.service.ts | 6 ++++-- client/src/app/review/review.component.html | 12 +++++++++++- client/src/app/review/review.component.ts | 9 ++++++--- server/src/modules/genericDataQueries.ts | 19 +++++++++++++++++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/client/src/app/api.service.ts b/client/src/app/api.service.ts index e65726e..07711ae 100644 --- a/client/src/app/api.service.ts +++ b/client/src/app/api.service.ts @@ -165,7 +165,8 @@ export class ApiService { | "events" | "meetings" | "mentorships" - | "staff", + | "staff" + | "testimonials", token: string, ): Promise<DataResponse | ErrorResponse> { const request = await fetch(`${this.url}/list/${type}`, { @@ -187,7 +188,8 @@ export class ApiService { | "events" | "meetings" | "mentorships" - | "staff", + | "staff" + | "testimonials", id: string, token: string, ): Promise<SuccessResponse | ErrorResponse> { diff --git a/client/src/app/review/review.component.html b/client/src/app/review/review.component.html index 6684eac..7983999 100644 --- a/client/src/app/review/review.component.html +++ b/client/src/app/review/review.component.html @@ -58,11 +58,21 @@ > Staff Applications </button> + <button + [disabled]="view === 'testimonials'" + type="button" + (click)="setView('testimonials')" + > + Testimonials + </button> <h2>{{ view }}</h2> <div *ngFor="let datum of data"> <h3>{{ datum.email }}</h3> <div *ngFor="let obj of datum.info"> - <p><strong>{{ obj.key }}</strong>: {{ obj.value }}</p> + <p> + <strong>{{ obj.key }}</strong + >: {{ obj.value }} + </p> </div> <button type="button" (click)="markReviewed(datum.id)"> Mark as Reviewed diff --git a/client/src/app/review/review.component.ts b/client/src/app/review/review.component.ts index 124629f..71b9930 100644 --- a/client/src/app/review/review.component.ts +++ b/client/src/app/review/review.component.ts @@ -41,7 +41,8 @@ export class ReviewComponent { | "events" | "meetings" | "mentorships" - | "staff" = ""; + | "staff" + | "testimonials" = ""; public constructor(private readonly apiService: ApiService) { const storedToken = localStorage.getItem("token"); @@ -98,7 +99,8 @@ export class ReviewComponent { | "events" | "meetings" | "mentorships" - | "staff"; + | "staff" + | "testimonials"; void this.apiService. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know token is not null markReviewed(view, id, this.token.value!).then((data) => { @@ -120,7 +122,8 @@ export class ReviewComponent { | "events" | "meetings" | "mentorships" - | "staff", + | "staff" + | "testimonials", ): void { this.view = view; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know token is not null diff --git a/server/src/modules/genericDataQueries.ts b/server/src/modules/genericDataQueries.ts index 677c491..694a477 100644 --- a/server/src/modules/genericDataQueries.ts +++ b/server/src/modules/genericDataQueries.ts @@ -14,6 +14,7 @@ import type { Mentorships, PrismaClient, Staff, + Testimonials, } from "@prisma/client"; /** @@ -27,10 +28,17 @@ const listUnreviewedSubmissions = async( route: DatabasePath, ): Promise< Array< - Appeals | Commissions | Contacts | Events | Meetings | Mentorships | Staff + | Appeals + | Commissions + | Contacts + | Events + | Meetings + | Mentorships + | Staff + | Testimonials > > => { - const query = { }; + const query = {}; switch (route) { case "appeals": return await database.appeals.findMany(query); @@ -46,6 +54,8 @@ const listUnreviewedSubmissions = async( return await database.mentorships.findMany(query); case "staff": return await database.staff.findMany(query); + case "testimonials": + return await database.testimonials.findMany(query); default: return []; } @@ -79,6 +89,8 @@ const checkSubmissionExists = async( return Boolean(await database.mentorships.findUnique(query)); case "staff": return Boolean(await database.staff.findUnique(query)); + case "testimonials": + return Boolean(await database.testimonials.findUnique(query)); default: return false; } @@ -122,6 +134,9 @@ const markSubmissionReviewed = async( case "staff": await database.staff.delete(update); break; + case "testimonials": + await database.testimonials.delete(update); + break; default: break; } -- 2.47.2 From e6c20328ecb035314ee6c42062db97a597b5468d Mon Sep 17 00:00:00 2001 From: Naomi Carrigan <commits@nhcarrigan.com> Date: Thu, 20 Feb 2025 15:58:32 -0800 Subject: [PATCH 3/3] fix: company prop --- client/src/app/forms/testimonial/testimonial.component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/app/forms/testimonial/testimonial.component.ts b/client/src/app/forms/testimonial/testimonial.component.ts index 7d6656f..cc6ed68 100644 --- a/client/src/app/forms/testimonial/testimonial.component.ts +++ b/client/src/app/forms/testimonial/testimonial.component.ts @@ -38,6 +38,7 @@ export class TestimonialComponent { public firstName = new FormControl(""); public lastName = new FormControl(""); public email = new FormControl(""); + public company = new FormControl(""); public content = new FormControl(""); -- 2.47.2