diff --git a/src/backend/index.ts b/src/backend/index.ts index 88b099ddef..a9853968e3 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -25,6 +25,7 @@ import retrospectiveRouter from './src/routes/retrospective.routes.js'; import partsRouter from './src/routes/parts.routes.js'; import financeRouter from './src/routes/finance.routes.js'; import calendarRouter from './src/routes/calendar.routes.js'; +import prospectiveSponsorRouter from './src/routes/prospective-sponsor.routes.js'; const app = express(); @@ -103,6 +104,7 @@ app.use('/retrospective', retrospectiveRouter); app.use('/parts', partsRouter); app.use('/finance', financeRouter); app.use('/calendar', calendarRouter); +app.use('/prospective-sponsors', prospectiveSponsorRouter); app.use('/', (_req, res) => { res.status(200).json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/finance.controllers.ts b/src/backend/src/controllers/finance.controllers.ts index 8db422b04e..8f6d872974 100644 --- a/src/backend/src/controllers/finance.controllers.ts +++ b/src/backend/src/controllers/finance.controllers.ts @@ -7,31 +7,43 @@ export default class FinanceController { const { name, activeStatus, + valueTypes, sponsorValue, joinDate, activeYears, sponsorTierId, taxExempt, - sponsorContact, + contactName, + contactEmail, + contactPhone, + contactPosition, sponsorTasks, discountCode, - sponsorNotes + sponsorNotes, + stockDescription, + discountDescription } = req.body; const sponsor = await FinanceServices.createSponsor( req.currentUser, name, activeStatus, - sponsorValue, + valueTypes, joinDate, activeYears, - sponsorTierId, + sponsorTierId || undefined, taxExempt, - sponsorContact, + contactName, sponsorTasks, req.organization, + sponsorValue, discountCode, - sponsorNotes + sponsorNotes, + contactEmail, + contactPhone, + contactPosition, + stockDescription, + discountDescription ); res.status(200).json(sponsor); } catch (error: unknown) { @@ -73,7 +85,7 @@ export default class FinanceController { static async editSponsorTask(req: Request, res: Response, next: NextFunction) { try { const { sponsorTaskId } = req.params as Record; - const { dueDate, notes, notifyDate, assigneeUserId } = req.body; + const { dueDate, notes, notifyDate, assigneeUserId, done } = req.body; const updatedSponsorTask = await FinanceServices.editSponsorTask( req.currentUser, @@ -82,7 +94,8 @@ export default class FinanceController { dueDate, notes, notifyDate, - assigneeUserId + assigneeUserId, + done ); res.status(200).json(updatedSponsorTask); } catch (error: unknown) { @@ -323,15 +336,21 @@ export default class FinanceController { const { name, activeStatus, + valueTypes, sponsorValue, joinDate, activeYears, sponsorTierId, - sponsorContact, + contactName, + contactEmail, + contactPhone, + contactPosition, taxExempt, sponsorTasks, discountCode, - sponsorNotes + sponsorNotes, + stockDescription, + discountDescription } = req.body; const updatedSponsor = await FinanceServices.editSponsor( @@ -340,15 +359,21 @@ export default class FinanceController { sponsorId, name, activeStatus, - sponsorValue, + valueTypes, joinDate, activeYears, - sponsorTierId, - sponsorContact, + sponsorTierId || undefined, + contactName, taxExempt, sponsorTasks, + sponsorValue, discountCode, - sponsorNotes + sponsorNotes, + contactEmail, + contactPhone, + contactPosition, + stockDescription, + discountDescription ); res.status(200).json(updatedSponsor); @@ -385,4 +410,18 @@ export default class FinanceController { next(error); } } + + static async toggleSponsorTaskDone(req: Request, res: Response, next: NextFunction) { + try { + const { sponsorTaskId } = req.params as Record; + const updatedTask = await FinanceServices.toggleSponsorTaskDone( + req.currentUser, + req.organization, + sponsorTaskId + ); + res.status(200).json(updatedTask); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/controllers/prospective-sponsor.controllers.ts b/src/backend/src/controllers/prospective-sponsor.controllers.ts new file mode 100644 index 0000000000..de75f008b1 --- /dev/null +++ b/src/backend/src/controllers/prospective-sponsor.controllers.ts @@ -0,0 +1,175 @@ +import { NextFunction, Request, Response } from 'express'; +import ProspectiveSponsorServices from '../services/prospective-sponsor.services.js'; + +export default class ProspectiveSponsorController { + static async createProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { + organizationName, + lastContactDate, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition, + notes, + tasks + } = req.body; + + const prospectiveSponsor = await ProspectiveSponsorServices.createProspectiveSponsor( + req.currentUser, + req.organization, + organizationName, + lastContactDate, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition, + notes, + tasks + ); + res.status(200).json(prospectiveSponsor); + } catch (error: unknown) { + next(error); + } + } + + static async getAllProspectiveSponsors(req: Request, res: Response, next: NextFunction) { + try { + const prospectiveSponsors = await ProspectiveSponsorServices.getAllProspectiveSponsors(req.organization); + res.status(200).json(prospectiveSponsors); + } catch (error: unknown) { + next(error); + } + } + + static async editProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const { + organizationName, + lastContactDate, + status, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition, + notes, + tasks + } = req.body; + + const updatedProspectiveSponsor = await ProspectiveSponsorServices.editProspectiveSponsor( + req.currentUser, + req.organization, + prospectiveSponsorId, + organizationName, + lastContactDate, + status, + firstContactMethod, + contactName, + contactorUserId, + highlightThresholdDays, + contactEmail, + contactPhone, + contactPosition, + notes, + tasks + ); + res.status(200).json(updatedProspectiveSponsor); + } catch (error: unknown) { + next(error); + } + } + + static async deleteProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const deletedProspectiveSponsor = await ProspectiveSponsorServices.deleteProspectiveSponsor( + prospectiveSponsorId, + req.currentUser, + req.organization + ); + res.status(200).json(deletedProspectiveSponsor); + } catch (error: unknown) { + next(error); + } + } + + static async getProspectiveSponsorTasks(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const tasks = await ProspectiveSponsorServices.getProspectiveSponsorTasks( + prospectiveSponsorId, + req.organization.organizationId + ); + res.status(200).json(tasks); + } catch (error: unknown) { + next(error); + } + } + + static async createProspectiveSponsorTask(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const { dueDate, notes, notifyDate, assigneeUserId } = req.body; + + const task = await ProspectiveSponsorServices.createProspectiveSponsorTask( + req.currentUser, + req.organization, + prospectiveSponsorId, + dueDate, + notes, + notifyDate, + assigneeUserId + ); + res.status(200).json(task); + } catch (error: unknown) { + next(error); + } + } + + static async acceptProspectiveSponsor(req: Request, res: Response, next: NextFunction) { + try { + const { prospectiveSponsorId } = req.params as Record; + const { + sponsorTierId, + valueTypes, + sponsorValue, + joinDate, + activeYears, + taxExempt, + discountCode, + sponsorNotes, + stockDescription, + discountDescription + } = req.body; + + const acceptedProspectiveSponsor = await ProspectiveSponsorServices.acceptProspectiveSponsor( + req.currentUser, + req.organization, + prospectiveSponsorId, + sponsorTierId || undefined, + valueTypes, + joinDate, + activeYears, + taxExempt, + sponsorValue, + discountCode, + sponsorNotes, + stockDescription, + discountDescription + ); + res.status(200).json(acceptedProspectiveSponsor); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/prisma-query-args/prospective-sponsor.query-args.ts b/src/backend/src/prisma-query-args/prospective-sponsor.query-args.ts new file mode 100644 index 0000000000..a4bd04f76c --- /dev/null +++ b/src/backend/src/prisma-query-args/prospective-sponsor.query-args.ts @@ -0,0 +1,14 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args.js'; +import { getSponsorTaskQueryArgs } from './sponsor.query.args.js'; + +export type ProspectiveSponsorQueryArgs = ReturnType; + +export const getProspectiveSponsorQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + contactor: getUserQueryArgs(organizationId), + tasks: getSponsorTaskQueryArgs(organizationId), + contact: true + } + }); diff --git a/src/backend/src/prisma-query-args/sponsor.query.args.ts b/src/backend/src/prisma-query-args/sponsor.query.args.ts index db942d43c2..35fab10668 100644 --- a/src/backend/src/prisma-query-args/sponsor.query.args.ts +++ b/src/backend/src/prisma-query-args/sponsor.query.args.ts @@ -9,7 +9,8 @@ export const getSponsorQueryArgs = (organizationId: string) => Prisma.validator()({ include: { sponsorTasks: getSponsorTaskQueryArgs(organizationId), - tier: true + tier: true, + contact: true } }); diff --git a/src/backend/src/prisma/migrations/20260205202908_prospective_sponsors/migration.sql b/src/backend/src/prisma/migrations/20260205202908_prospective_sponsors/migration.sql new file mode 100644 index 0000000000..83d0858ce5 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260205202908_prospective_sponsors/migration.sql @@ -0,0 +1,120 @@ +/* + Warnings: + + - You are about to drop the column `vendorContact` on the `Sponsor` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "Prospective_Sponsor_Status" AS ENUM ('IN_PROGRESS', 'DECLINED', 'NOT_IN_CONTACT', 'NO_RESPONSE', 'ACCEPTED'); + +-- CreateEnum +CREATE TYPE "First_Contact_Method" AS ENUM ('INBOUND_FORM', 'INBOUND_EMAIL', 'OUTBOUND_EMAIL', 'OTHER'); + +-- CreateEnum +CREATE TYPE "Sponsor_Value_Type" AS ENUM ('MONETARY', 'STOCK', 'DISCOUNT'); + +-- DropForeignKey +ALTER TABLE "Sponsor_Task" DROP CONSTRAINT "Sponsor_Task_sponsorId_fkey"; + +-- CreateTable: Sponsor_Contact +CREATE TABLE "Sponsor_Contact" ( + "sponsorContactId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT, + "position" TEXT, + + CONSTRAINT "Sponsor_Contact_pkey" PRIMARY KEY ("sponsorContactId") +); + +-- Add contactId column to Sponsor (nullable initially) +ALTER TABLE "Sponsor" ADD COLUMN "contactId" TEXT; + +-- Create one Sponsor_Contact per Sponsor and link them in one step +WITH new_contacts AS ( + INSERT INTO "Sponsor_Contact" ("sponsorContactId", "name") + SELECT gen_random_uuid(), COALESCE("vendorContact", '') + FROM "Sponsor" + RETURNING "sponsorContactId", "name", ctid +), numbered_contacts AS ( + SELECT "sponsorContactId", ROW_NUMBER() OVER (ORDER BY ctid) AS rn + FROM new_contacts +), numbered_sponsors AS ( + SELECT "sponsorId", ROW_NUMBER() OVER (ORDER BY "sponsorId") AS rn + FROM "Sponsor" +) +UPDATE "Sponsor" s +SET "contactId" = nc."sponsorContactId" +FROM numbered_sponsors ns +JOIN numbered_contacts nc ON ns.rn = nc.rn +WHERE s."sponsorId" = ns."sponsorId"; + +-- Enforce NOT NULL and unique, drop old column +ALTER TABLE "Sponsor" ALTER COLUMN "contactId" SET NOT NULL; +ALTER TABLE "Sponsor" ADD CONSTRAINT "Sponsor_contactId_key" UNIQUE ("contactId"); +ALTER TABLE "Sponsor" DROP COLUMN "vendorContact"; + +-- AddForeignKey: Sponsor -> Sponsor_Contact +ALTER TABLE "Sponsor" ADD CONSTRAINT "Sponsor_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Sponsor_Contact"("sponsorContactId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AlterTable: Sponsor - add value types, stock/discount descriptions, make sponsorValue and sponsorTierId nullable +ALTER TABLE "Sponsor" +ADD COLUMN "valueTypes" "Sponsor_Value_Type"[] DEFAULT ARRAY['MONETARY']::"Sponsor_Value_Type"[], +ADD COLUMN "stockDescription" TEXT, +ADD COLUMN "discountDescription" TEXT; + +ALTER TABLE "Sponsor" ALTER COLUMN "sponsorValue" DROP NOT NULL; +ALTER TABLE "Sponsor" ALTER COLUMN "sponsorTierId" DROP NOT NULL; + +-- AlterTable: Sponsor_Task +ALTER TABLE "Sponsor_Task" ADD COLUMN "done" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "prospectiveSponsorId" TEXT, +ALTER COLUMN "sponsorId" DROP NOT NULL; + +-- CreateTable: Prospective_Sponsor +CREATE TABLE "Prospective_Sponsor" ( + "prospectiveSponsorId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "organizationName" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastContactDate" TIMESTAMP(3) NOT NULL, + "highlightThresholdDays" INTEGER NOT NULL DEFAULT 10, + "status" "Prospective_Sponsor_Status" NOT NULL DEFAULT 'IN_PROGRESS', + "firstContactMethod" "First_Contact_Method" NOT NULL, + "contactorUserId" TEXT NOT NULL, + "contactId" TEXT NOT NULL, + "notes" TEXT, + "dateDeleted" TIMESTAMP(3), + + CONSTRAINT "Prospective_Sponsor_pkey" PRIMARY KEY ("prospectiveSponsorId") +); + +-- CreateIndex +CREATE INDEX "Prospective_Sponsor_organizationId_idx" ON "Prospective_Sponsor"("organizationId"); + +-- CreateIndex +CREATE INDEX "Prospective_Sponsor_contactorUserId_idx" ON "Prospective_Sponsor"("contactorUserId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Prospective_Sponsor_organizationName_organizationId_key" ON "Prospective_Sponsor"("organizationName", "organizationId"); + +-- CreateIndex: unique contactId on Prospective_Sponsor +CREATE UNIQUE INDEX "Prospective_Sponsor_contactId_key" ON "Prospective_Sponsor"("contactId"); + +-- CreateIndex +CREATE INDEX "Sponsor_Task_prospectiveSponsorId_idx" ON "Sponsor_Task"("prospectiveSponsorId"); + +-- AddForeignKey +ALTER TABLE "Sponsor_Task" ADD CONSTRAINT "Sponsor_Task_sponsorId_fkey" FOREIGN KEY ("sponsorId") REFERENCES "Sponsor"("sponsorId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Sponsor_Task" ADD CONSTRAINT "Sponsor_Task_prospectiveSponsorId_fkey" FOREIGN KEY ("prospectiveSponsorId") REFERENCES "Prospective_Sponsor"("prospectiveSponsorId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Prospective_Sponsor" ADD CONSTRAINT "Prospective_Sponsor_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Prospective_Sponsor" ADD CONSTRAINT "Prospective_Sponsor_contactorUserId_fkey" FOREIGN KEY ("contactorUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Prospective_Sponsor" ADD CONSTRAINT "Prospective_Sponsor_contactId_fkey" FOREIGN KEY ("contactId") REFERENCES "Sponsor_Contact"("sponsorContactId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 3d3ba18d35..a5c69a53a9 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -158,6 +158,27 @@ enum Review_Status { APPROVED } +enum Prospective_Sponsor_Status { + IN_PROGRESS + DECLINED + NOT_IN_CONTACT + NO_RESPONSE + ACCEPTED +} + +enum First_Contact_Method { + INBOUND_FORM + INBOUND_EMAIL + OUTBOUND_EMAIL + OTHER +} + +enum Sponsor_Value_Type { + MONETARY + STOCK + DISCOUNT +} + model User { userId String @id @default(uuid()) firstName String @@ -278,6 +299,7 @@ model User { deniedEvents Event[] @relation(name: "deniedEventAttendee") deletedDocuments Document[] @relation(name: "deletedDocuments") createdDocuments Document[] @relation(name: "documentsCreatedBy") + prospectiveSponsorsContacted Prospective_Sponsor[] @relation(name: "prospectiveSponsorContactor") } model Role { @@ -791,25 +813,39 @@ model Vendor { @@index([organizationId]) } +model Sponsor_Contact { + sponsorContactId String @id @default(uuid()) + name String + email String? + phone String? + position String? + sponsor Sponsor? + prospectiveSponsor Prospective_Sponsor? +} + model Sponsor { - sponsorId String @id @default(uuid()) - name String - organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) - dateCreated DateTime @default(now()) - dateDeleted DateTime? - activeStatus Boolean - vendorContact String - tier Sponsor_Tier @relation(fields: [sponsorTierId], references: [sponsorTierId]) - sponsorTierId String - sponsorValue Int - joinDate DateTime - discountCode String? - activeYears Int[] - taxExempt Boolean - sponsorNotes String? - sponsorTasks Sponsor_Task[] - logoImageId String? + sponsorId String @id @default(uuid()) + name String + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + dateCreated DateTime @default(now()) + dateDeleted DateTime? + activeStatus Boolean + contact Sponsor_Contact @relation(fields: [contactId], references: [sponsorContactId]) + contactId String @unique + tier Sponsor_Tier? @relation(fields: [sponsorTierId], references: [sponsorTierId]) + sponsorTierId String? + valueTypes Sponsor_Value_Type[] + sponsorValue Int? + stockDescription String? + discountDescription String? + joinDate DateTime + discountCode String? + activeYears Int[] + taxExempt Boolean + sponsorNotes String? + sponsorTasks Sponsor_Task[] + logoImageId String? @@unique([name, organizationId], name: "uniqueSponsor") @@index([sponsorTierId]) @@ -817,17 +853,44 @@ model Sponsor { } model Sponsor_Task { - sponsorTaskId String @id @default(uuid()) - dueDate DateTime - notifyDate DateTime? - assignee User? @relation(fields: [assigneeUserId], references: [userId], name: "assignedSponsorTasks") - assigneeUserId String? - notes String - sponsor Sponsor @relation(fields: [sponsorId], references: [sponsorId]) - sponsorId String - dateDeleted DateTime? + sponsorTaskId String @id @default(uuid()) + dueDate DateTime + notifyDate DateTime? + assignee User? @relation(fields: [assigneeUserId], references: [userId], name: "assignedSponsorTasks") + assigneeUserId String? + notes String + done Boolean @default(false) + sponsor Sponsor? @relation(fields: [sponsorId], references: [sponsorId]) + sponsorId String? + prospectiveSponsor Prospective_Sponsor? @relation(fields: [prospectiveSponsorId], references: [prospectiveSponsorId]) + prospectiveSponsorId String? + dateDeleted DateTime? @@index([sponsorId]) + @@index([prospectiveSponsorId]) +} + +model Prospective_Sponsor { + prospectiveSponsorId String @id @default(uuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + organizationName String + dateCreated DateTime @default(now()) + lastContactDate DateTime + highlightThresholdDays Int @default(10) + status Prospective_Sponsor_Status @default(IN_PROGRESS) + firstContactMethod First_Contact_Method + contactor User @relation(fields: [contactorUserId], references: [userId], name: "prospectiveSponsorContactor") + contactorUserId String + contact Sponsor_Contact @relation(fields: [contactId], references: [sponsorContactId]) + contactId String @unique + notes String? + dateDeleted DateTime? + tasks Sponsor_Task[] + + @@unique([organizationName, organizationId], name: "uniqueProspectiveSponsor") + @@index([organizationId]) + @@index([contactorUserId]) } model Account_Code { @@ -1027,12 +1090,12 @@ enum DayOfWeek { } model Schedule_Slot { - scheduleSlotId String @id @default(uuid()) - startTime DateTime - endTime DateTime - allDay Boolean @default(false) - eventId String - event Event @relation(fields: [eventId], references: [eventId]) + scheduleSlotId String @id @default(uuid()) + startTime DateTime + endTime DateTime + allDay Boolean @default(false) + eventId String + event Event @relation(fields: [eventId], references: [eventId]) @@index([endTime]) @@index([startTime]) @@ -1088,7 +1151,7 @@ model Event { shops Shop[] machinery Machinery[] workPackages Work_Package[] - documents Document[] + documents Document[] status Event_Status initialDateScheduled DateTime? questionDocumentLink String? @@ -1355,6 +1418,7 @@ model Organization { partTags Part_Tag[] sponsors Sponsor[] sponsorTiers Sponsor_Tier[] + prospectiveSponsors Prospective_Sponsor[] indexCodes Index_Code[] financeDelegates User[] @relation(name: "financeDelegates") guestDefinitions Guest_Definition[] diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 8b641d8659..273d2beece 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -3027,14 +3027,15 @@ const performSeed: () => Promise = async () => { }); const goldSponsorTier = await FinanceServices.createSponsorTier(thomasEmrax, 'Gold', ner, '#9F9156', 3000); - await FinanceServices.createSponsorTier(thomasEmrax, 'Silver', ner, '#C0C0C0', 200); - await FinanceServices.createSponsorTier(thomasEmrax, 'Bronze', ner, '#CD7F32', 10); + const silverSponsorTier = await FinanceServices.createSponsorTier(thomasEmrax, 'Silver', ner, '#C0C0C0', 200); + const bronzeSponsorTier = await FinanceServices.createSponsorTier(thomasEmrax, 'Bronze', ner, '#CD7F32', 10); + // Sponsors const sponsor = await FinanceServices.createSponsor( thomasEmrax, 'Google', true, - 5000, + ['MONETARY'], daysAgo(90), [2024, 2025], goldSponsorTier.sponsorTierId, @@ -3042,7 +3043,10 @@ const performSeed: () => Promise = async () => { 'Bill Gates', [], ner, - 'googlecode' + 5000, + 'googlecode', + undefined, + 'bill@google.com' ); await FinanceServices.createSponsorTask( @@ -3055,6 +3059,390 @@ const performSeed: () => Promise = async () => { thomasEmrax.userId ); + const altiumSponsor = await FinanceServices.createSponsor( + thomasEmrax, + 'Altium', + true, + ['DISCOUNT'], + daysAgo(200), + [2024, 2025, 2026], + silverSponsorTier.sponsorTierId, + false, + 'Rachel Park', + [], + ner, + undefined, + undefined, + undefined, + 'rpark@altium.com', + undefined, + 'Director of Academic Programs', + undefined, + 'Free Altium Designer licenses for all team members' + ); + + const mcmasterSponsor = await FinanceServices.createSponsor( + thomasEmrax, + 'McMaster-Carr', + true, + ['STOCK'], + daysAgo(60), + [2025, 2026], + bronzeSponsorTier.sponsorTierId, + true, + 'James Corrado', + [], + ner, + undefined, + undefined, + 'Provides fasteners and raw materials at no cost', + 'jcorrado@mcmaster.com', + '555-444-3333', + 'Account Representative', + '$500 worth of stock hardware per semester' + ); + + const boseSponsor = await FinanceServices.createSponsor( + thomasEmrax, + 'Bose Corporation', + true, + ['MONETARY', 'STOCK'], + daysAgo(150), + [2025, 2026], + goldSponsorTier.sponsorTierId, + true, + 'Linda Morales', + [], + ner, + 8000, + undefined, + undefined, + 'lmorales@bose.com', + '555-222-1111', + 'Engineering Partnerships', + 'Donates sensors and audio components' + ); + + await FinanceServices.createSponsor( + thomasEmrax, + 'ANSYS', + false, + ['DISCOUNT'], + daysAgo(400), + [2023, 2024], + silverSponsorTier.sponsorTierId, + false, + 'Tom Bradley', + [], + ner, + undefined, + 'ANSYS-NER-2024', + 'Sponsorship ended after 2024 season', + 'tbradley@ansys.com', + undefined, + 'University Partnerships', + undefined, + '50% discount on simulation software suite' + ); + + const neuSponsor = await FinanceServices.createSponsor( + thomasEmrax, + 'Northeastern University COE', + true, + ['MONETARY'], + daysAgo(365), + [2024, 2025, 2026], + goldSponsorTier.sponsorTierId, + true, + 'Dr. Amy Sullivan', + [], + ner, + 15000, + undefined, + 'Annual funding from the College of Engineering', + 'a.sullivan@northeastern.edu', + undefined, + 'Associate Dean of Student Organizations' + ); + + // Prospective sponsors + const prospectiveContact1 = await prisma.sponsor_Contact.create({ + data: { + name: 'Sarah Johnson', + email: 'sarah.johnson@teslamotors.com', + phone: '555-123-4567', + position: 'Partnerships Manager' + } + }); + + const prospectiveContact2 = await prisma.sponsor_Contact.create({ + data: { + name: 'Mike Callahan', + email: 'mcallahan@boeingaero.com', + position: 'University Relations Lead' + } + }); + + const prospectiveContact3 = await prisma.sponsor_Contact.create({ + data: { + name: 'Emily Davis', + email: 'emily.d@solidworks.com', + phone: '555-987-6543', + position: 'Academic Sponsorships' + } + }); + + const prospectiveContact4 = await prisma.sponsor_Contact.create({ + data: { + name: 'Kevin Martinez', + email: 'kmartinez@ti.com', + phone: '555-321-7890', + position: 'University Programs Coordinator' + } + }); + + const prospectiveContact5 = await prisma.sponsor_Contact.create({ + data: { + name: 'Priya Patel', + email: 'priya.patel@3m.com', + position: 'Technical Sponsorships' + } + }); + + const prospectiveContact6 = await prisma.sponsor_Contact.create({ + data: { + name: 'David Romano', + phone: '555-654-0987', + position: 'Owner' + } + }); + + const prospectiveContact7 = await prisma.sponsor_Contact.create({ + data: { + name: 'Amanda Foster', + email: 'afoster@shell.com', + phone: '555-111-2222', + position: 'STEM Outreach Manager' + } + }); + + const prospectiveContact8 = await prisma.sponsor_Contact.create({ + data: { + name: 'Robert Whitfield', + email: 'rwhitfield@mathworks.com', + position: 'Academic Sales' + } + }); + + const teslaProsSpons = await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: 'Tesla Motors', + lastContactDate: daysAgo(5), + highlightThresholdDays: 14, + status: 'IN_PROGRESS', + firstContactMethod: 'OUTBOUND_EMAIL', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact1.sponsorContactId, + notes: 'Reached out about potential parts sponsorship for battery systems' + } + }); + + await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: 'Boeing Aerospace', + lastContactDate: daysAgo(20), + highlightThresholdDays: 10, + status: 'NO_RESPONSE', + firstContactMethod: 'OUTBOUND_EMAIL', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact2.sponsorContactId, + notes: 'Sent initial sponsorship proposal, no reply yet' + } + }); + + const solidworksProsSpons = await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: 'SolidWorks', + lastContactDate: daysAgo(2), + highlightThresholdDays: 7, + status: 'IN_PROGRESS', + firstContactMethod: 'INBOUND_EMAIL', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact3.sponsorContactId, + notes: 'They reached out offering software licenses for the team' + } + }); + + const tiProsSpons = await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: 'Texas Instruments', + lastContactDate: daysAgo(3), + highlightThresholdDays: 10, + status: 'IN_PROGRESS', + firstContactMethod: 'INBOUND_FORM', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact4.sponsorContactId, + notes: 'Filled out our sponsorship interest form, interested in providing microcontrollers and dev boards' + } + }); + + await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: '3M', + lastContactDate: daysAgo(45), + highlightThresholdDays: 14, + status: 'NOT_IN_CONTACT', + firstContactMethod: 'OUTBOUND_EMAIL', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact5.sponsorContactId, + notes: 'Initial contact went well but contact person changed roles, need to find new point of contact' + } + }); + + await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: 'Precision Machine Shop Boston', + lastContactDate: daysAgo(8), + highlightThresholdDays: 10, + status: 'IN_PROGRESS', + firstContactMethod: 'OTHER', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact6.sponsorContactId, + notes: 'Met at local manufacturing expo, interested in providing machining services at reduced cost' + } + }); + + await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: 'Shell Energy', + lastContactDate: daysAgo(30), + highlightThresholdDays: 14, + status: 'DECLINED', + firstContactMethod: 'OUTBOUND_EMAIL', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact7.sponsorContactId, + notes: 'Declined for this year, suggested we reapply next fiscal year in September' + } + }); + + const mathworksProsSpons = await prisma.prospective_Sponsor.create({ + data: { + organizationId, + organizationName: 'MathWorks', + lastContactDate: daysAgo(1), + highlightThresholdDays: 7, + status: 'IN_PROGRESS', + firstContactMethod: 'INBOUND_EMAIL', + contactorUserId: thomasEmrax.userId, + contactId: prospectiveContact8.sponsorContactId, + notes: 'Interested in providing MATLAB/Simulink licenses, scheduling a call next week' + } + }); + + // Sponsor tasks + await FinanceServices.createSponsorTask( + thomasEmrax, + ner, + daysFromNow(14), + 'Renew Altium license agreement for next academic year', + altiumSponsor.sponsorId, + daysFromNow(7), + thomasEmrax.userId + ); + + await FinanceServices.createSponsorTask( + thomasEmrax, + ner, + daysFromNow(60), + 'Send McMaster-Carr updated parts list for spring semester', + mcmasterSponsor.sponsorId, + daysFromNow(45) + ); + + await FinanceServices.createSponsorTask( + thomasEmrax, + ner, + daysAgo(5), + 'Submit Bose quarterly progress report', + boseSponsor.sponsorId, + daysAgo(10), + thomasEmrax.userId + ); + + await FinanceServices.createSponsorTask( + thomasEmrax, + ner, + daysFromNow(90), + 'Prepare annual sponsorship renewal presentation for NEU COE', + neuSponsor.sponsorId, + daysFromNow(60), + thomasEmrax.userId + ); + + await FinanceServices.createSponsorTask( + thomasEmrax, + ner, + daysFromNow(7), + 'Send thank-you letter and team photo to Bose', + boseSponsor.sponsorId + ); + + // Prospective sponsor tasks + await prisma.sponsor_Task.create({ + data: { + dueDate: daysFromNow(3), + notes: 'Follow up email with Tesla partnership proposal PDF', + prospectiveSponsorId: teslaProsSpons.prospectiveSponsorId, + notifyDate: daysFromNow(1), + assigneeUserId: thomasEmrax.userId + } + }); + + await prisma.sponsor_Task.create({ + data: { + dueDate: daysFromNow(10), + notes: 'Schedule demo call with SolidWorks academic team', + prospectiveSponsorId: solidworksProsSpons.prospectiveSponsorId, + assigneeUserId: thomasEmrax.userId + } + }); + + await prisma.sponsor_Task.create({ + data: { + dueDate: daysAgo(2), + notes: 'Send TI the team roster for university program enrollment', + prospectiveSponsorId: tiProsSpons.prospectiveSponsorId, + notifyDate: daysAgo(5), + assigneeUserId: thomasEmrax.userId, + done: true + } + }); + + await prisma.sponsor_Task.create({ + data: { + dueDate: daysFromNow(5), + notes: 'Prepare MathWorks sponsorship tier options document', + prospectiveSponsorId: mathworksProsSpons.prospectiveSponsorId, + notifyDate: daysFromNow(2) + } + }); + + await prisma.sponsor_Task.create({ + data: { + dueDate: daysFromNow(14), + notes: 'Draft MATLAB workshop proposal to show value of partnership', + prospectiveSponsorId: mathworksProsSpons.prospectiveSponsorId, + assigneeUserId: thomasEmrax.userId + } + }); + // Create shops for machinery const advancedShop = await prisma.shop.create({ data: { diff --git a/src/backend/src/routes/finance.routes.ts b/src/backend/src/routes/finance.routes.ts index 75d6e60217..8212642871 100644 --- a/src/backend/src/routes/finance.routes.ts +++ b/src/backend/src/routes/finance.routes.ts @@ -16,19 +16,28 @@ financeRouter.post( '/sponsor/create', nonEmptyString(body('name')), body('activeStatus').isBoolean(), - body('sponsorValue').isInt(), + body('valueTypes').isArray(), + nonEmptyString(body('valueTypes.*')), + body('sponsorValue').isInt().optional(), isDate(body('joinDate')), body('activeYears').isArray(), intMinZero(body('activeYears.*')), - nonEmptyString(body('sponsorTierId')), + nonEmptyString(body('sponsorTierId')).optional({ checkFalsy: true }), body('taxExempt').isBoolean(), - nonEmptyString(body('sponsorContact')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactEmail')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPhone')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPosition')).optional({ checkFalsy: true }), body('sponsorTasks').isArray(), isDate(body('sponsorTasks.*.dueDate')), - isDate(body('sponsorTasks.*.notifyDate')), - nonEmptyString(body('sponsorTasks.*.assigneeUserId')), + isDate(body('sponsorTasks.*.notifyDate')).optional({ checkFalsy: true }), + nonEmptyString(body('sponsorTasks.*.assigneeUserId')).optional({ checkFalsy: true }), nonEmptyString(body('sponsorTasks.*.notes')), - nonEmptyString(body('discountCode')).optional(), + body('sponsorTasks.*.done').optional().isBoolean(), + nonEmptyString(body('discountCode')).optional({ checkFalsy: true }), + nonEmptyString(body('sponsorNotes')).optional({ checkFalsy: true }), + nonEmptyString(body('stockDescription')).optional({ checkFalsy: true }), + nonEmptyString(body('discountDescription')).optional({ checkFalsy: true }), validateInputs, FinanceController.createSponsor ); @@ -59,6 +68,8 @@ financeRouter.post( financeRouter.post('/sponsorTask/:sponsorTaskId/delete', FinanceController.deleteSponsorTask); +financeRouter.post('/sponsorTask/:sponsorTaskId/toggle-done', FinanceController.toggleSponsorTaskDone); + financeRouter.post( '/sponsor/:sponsorId/sponsorTasks', isDate(body('dueDate')), @@ -133,19 +144,29 @@ financeRouter.post( '/sponsor/:sponsorId/edit', nonEmptyString(body('name')), body('activeStatus').isBoolean(), - body('sponsorValue').isInt(), + body('valueTypes').isArray(), + nonEmptyString(body('valueTypes.*')), + body('sponsorValue').isInt().optional(), isDate(body('joinDate')), body('activeYears').isArray(), intMinZero(body('activeYears.*')), - nonEmptyString(body('sponsorTierId')), + nonEmptyString(body('sponsorTierId')).optional({ checkFalsy: true }), body('taxExempt').isBoolean(), - nonEmptyString(body('sponsorContact')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactEmail')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPhone')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPosition')).optional({ checkFalsy: true }), body('sponsorTasks').isArray(), + nonEmptyString(body('sponsorTasks.*.sponsorTaskId')).optional({ checkFalsy: true }), isDate(body('sponsorTasks.*.dueDate')), - isDate(body('sponsorTasks.*.notifyDate')), - nonEmptyString(body('sponsorTasks.*.assigneeUserId')), + isDate(body('sponsorTasks.*.notifyDate')).optional({ checkFalsy: true }), + nonEmptyString(body('sponsorTasks.*.assigneeUserId')).optional({ checkFalsy: true }), nonEmptyString(body('sponsorTasks.*.notes')), - body('discountCode').optional(), + body('sponsorTasks.*.done').optional().isBoolean(), + body('discountCode').optional({ checkFalsy: true }), + nonEmptyString(body('sponsorNotes')).optional({ checkFalsy: true }), + nonEmptyString(body('stockDescription')).optional({ checkFalsy: true }), + nonEmptyString(body('discountDescription')).optional({ checkFalsy: true }), validateInputs, FinanceController.editSponsor ); diff --git a/src/backend/src/routes/prospective-sponsor.routes.ts b/src/backend/src/routes/prospective-sponsor.routes.ts new file mode 100644 index 0000000000..51828b11a8 --- /dev/null +++ b/src/backend/src/routes/prospective-sponsor.routes.ts @@ -0,0 +1,107 @@ +import express from 'express'; +import { body } from 'express-validator'; +import { + nonEmptyString, + validateInputs, + isDate, + isOptionalDate, + intMinZero +} from '../utils/validation.utils.js'; +import ProspectiveSponsorController from '../controllers/prospective-sponsor.controllers.js'; + +const prospectiveSponsorRouter = express.Router(); + +// Create prospective sponsor +prospectiveSponsorRouter.post( + '/create', + nonEmptyString(body('organizationName')), + isDate(body('lastContactDate')), + nonEmptyString(body('firstContactMethod')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactorUserId')), + intMinZero(body('highlightThresholdDays')).optional(), + nonEmptyString(body('contactEmail')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPhone')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPosition')).optional({ checkFalsy: true }), + nonEmptyString(body('notes')).optional({ checkFalsy: true }), + body('tasks').optional().isArray(), + isDate(body('tasks.*.dueDate')).optional(), + isDate(body('tasks.*.notifyDate')).optional({ checkFalsy: true }), + nonEmptyString(body('tasks.*.assigneeUserId')).optional({ checkFalsy: true }), + nonEmptyString(body('tasks.*.notes')).optional(), + body('tasks.*.done').optional().isBoolean(), + validateInputs, + ProspectiveSponsorController.createProspectiveSponsor +); + +// Get all prospective sponsors +prospectiveSponsorRouter.get('/', ProspectiveSponsorController.getAllProspectiveSponsors); + +// Edit prospective sponsor +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/edit', + nonEmptyString(body('organizationName')), + isDate(body('lastContactDate')), + nonEmptyString(body('status')), + nonEmptyString(body('firstContactMethod')), + nonEmptyString(body('contactName')), + nonEmptyString(body('contactorUserId')), + intMinZero(body('highlightThresholdDays')).optional(), + nonEmptyString(body('contactEmail')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPhone')).optional({ checkFalsy: true }), + nonEmptyString(body('contactPosition')).optional({ checkFalsy: true }), + nonEmptyString(body('notes')).optional({ checkFalsy: true }), + body('tasks').optional().isArray(), + nonEmptyString(body('tasks.*.sponsorTaskId')).optional({ checkFalsy: true }), + isDate(body('tasks.*.dueDate')).optional(), + isDate(body('tasks.*.notifyDate')).optional({ checkFalsy: true }), + nonEmptyString(body('tasks.*.assigneeUserId')).optional({ checkFalsy: true }), + nonEmptyString(body('tasks.*.notes')).optional(), + body('tasks.*.done').optional().isBoolean(), + validateInputs, + ProspectiveSponsorController.editProspectiveSponsor +); + +// Delete prospective sponsor +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/delete', + ProspectiveSponsorController.deleteProspectiveSponsor +); + +// Get tasks for prospective sponsor +prospectiveSponsorRouter.get( + '/:prospectiveSponsorId/tasks', + ProspectiveSponsorController.getProspectiveSponsorTasks +); + +// Create task for prospective sponsor +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/tasks', + isDate(body('dueDate')), + nonEmptyString(body('notes')), + isOptionalDate(body('notifyDate')), + nonEmptyString(body('assigneeUserId')).optional(), + validateInputs, + ProspectiveSponsorController.createProspectiveSponsorTask +); + +// Accept prospective sponsor (convert to full sponsor) +prospectiveSponsorRouter.post( + '/:prospectiveSponsorId/accept', + nonEmptyString(body('sponsorTierId')).optional({ checkFalsy: true }), + body('valueTypes').isArray(), + nonEmptyString(body('valueTypes.*')), + body('sponsorValue').isInt().optional(), + isDate(body('joinDate')), + body('activeYears').isArray(), + intMinZero(body('activeYears.*')), + body('taxExempt').isBoolean(), + nonEmptyString(body('discountCode')).optional({ checkFalsy: true }), + nonEmptyString(body('sponsorNotes')).optional({ checkFalsy: true }), + nonEmptyString(body('stockDescription')).optional({ checkFalsy: true }), + nonEmptyString(body('discountDescription')).optional({ checkFalsy: true }), + validateInputs, + ProspectiveSponsorController.acceptProspectiveSponsor +); + +export default prospectiveSponsorRouter; diff --git a/src/backend/src/services/finance.services.ts b/src/backend/src/services/finance.services.ts index 6bd7dcf915..b2e5ecd7f1 100644 --- a/src/backend/src/services/finance.services.ts +++ b/src/backend/src/services/finance.services.ts @@ -10,7 +10,7 @@ import { wbsPipe, User } from 'shared'; -import { Organization, Sponsor_Task, Reimbursement_Status_Type } from '@prisma/client'; +import { Organization, Sponsor_Task, Reimbursement_Status_Type, Sponsor_Value_Type } from '@prisma/client'; import { userHasPermission } from '../utils/users.utils.js'; import { getSponsorQueryArgs, @@ -34,6 +34,7 @@ import { getReimbursementRequestWhereInput } from '../utils/finance.utils.js'; import { notifySponsorTaskAssignee } from '../utils/slack.utils.js'; +import { isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; export default class FinanceServices { /** @@ -49,7 +50,10 @@ export default class FinanceServices { * @param taxExempt Boolean indicating if the sponsor is tax-exempt. * @param discountCode The discount code associated with the sponsor. * @param sponsorNotes Additional notes about the sponsor. - * @param sponsorContact The contact information for the sponsor. + * @param contactName The name of the sponsor contact. + * @param contactEmail The email of the sponsor contact. + * @param contactPhone The phone of the sponsor contact. + * @param contactPosition The position of the sponsor contact. * @param sponsorTasks An array of sponsor tasks associated with the sponsor. * @param organization The organization for which the sponsor is being created. * @@ -61,20 +65,30 @@ export default class FinanceServices { submitter: User, name: string, activeStatus: boolean, - sponsorValue: number, + valueTypes: Sponsor_Value_Type[], joinDate: Date, activeYears: number[], - sponsorTierId: string, + sponsorTierId: string | undefined, taxExempt: boolean, - sponsorContact: string, + contactName: string, sponsorTasks: CreateSponsorTask[], organization: Organization, + sponsorValue?: number, discountCode?: string, - sponsorNotes?: string + sponsorNotes?: string, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string, + stockDescription?: string, + discountDescription?: string ) { if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) throw new AccessDeniedException('Only heads can create a sponsor'); + if (!contactEmail && !contactPhone) { + throw new HttpException(400, 'At least one of contact email or contact phone is required'); + } + const existingSponsor = await prisma.sponsor.findFirst({ where: { name, @@ -86,18 +100,25 @@ export default class FinanceServices { throw new HttpException(400, `A sponsor with the name "${name}" already exists.`); } + const contact = await prisma.sponsor_Contact.create({ + data: { name: contactName, email: contactEmail, phone: contactPhone, position: contactPosition } + }); + const sponsor = await prisma.sponsor.create({ data: { name, activeStatus, + valueTypes, sponsorValue, + stockDescription, + discountDescription, joinDate, activeYears, sponsorTierId, taxExempt, discountCode, sponsorNotes, - vendorContact: sponsorContact, + contactId: contact.sponsorContactId, sponsorTasks: { create: sponsorTasks.map((task) => ({ dueDate: task.dueDate, @@ -224,17 +245,20 @@ export default class FinanceServices { dueDate: Date, notes: string, notifyDate?: Date, - assigneeUserId?: string + assigneeUserId?: string, + done?: boolean ): Promise { - if (!(await userHasPermission(submitter.userId, org.organizationId, isHead))) - throw new AccessDeniedException('Only heads can edit sponsor tasks.'); + if (!(await isUserFinanceTeamOrHead(submitter, org.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can edit sponsor tasks'); + } - const oldSponsorTask = await prisma.sponsor_Task.findUnique({ + const oldSponsorTask = await prisma.sponsor_Task.findFirst({ where: { sponsorTaskId, - sponsor: { - organizationId: org.organizationId - } + OR: [ + { sponsor: { organizationId: org.organizationId } }, + { prospectiveSponsor: { organizationId: org.organizationId } } + ] } }); @@ -266,21 +290,28 @@ export default class FinanceServices { notifyDate, assigneeUserId, dueDate, - notes + notes, + done: done ?? undefined } }); if (assignee && oldSponsorTask.assigneeUserId !== assigneeUserId) { - const sponsor = await prisma.sponsor.findUnique({ - where: { sponsorId: updatedSponsorTask.sponsorId } - }); - - if (!sponsor) { - throw new NotFoundException('Sponsor', updatedSponsorTask.sponsorId); + // Get sponsor or prospective sponsor name for notification + let sponsorName: string | undefined; + if (updatedSponsorTask.sponsorId) { + const sponsor = await prisma.sponsor.findUnique({ + where: { sponsorId: updatedSponsorTask.sponsorId } + }); + sponsorName = sponsor?.name; + } else if (updatedSponsorTask.prospectiveSponsorId) { + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId: updatedSponsorTask.prospectiveSponsorId } + }); + sponsorName = prospectiveSponsor?.organizationName; } - if (assignee) { - await notifySponsorTaskAssignee(assignee, updatedSponsorTask, sponsor.name); + if (sponsorName && assignee) { + await notifySponsorTaskAssignee(assignee, updatedSponsorTask, sponsorName); } } @@ -326,12 +357,17 @@ export default class FinanceServices { where: { sponsorTaskId, dateDeleted: null } }); - if (!(await userHasPermission(deleter.userId, organization.organizationId, isHead))) { - throw new AccessDeniedException('Only heads can delete sponsor tasks.'); - } - if (!sponsorTask) throw new NotFoundException('SponsorTask', sponsorTaskId); + const hasHeadPermission = await userHasPermission(deleter.userId, organization.organizationId, isHead); + const isAssignee = sponsorTask.assigneeUserId === deleter.userId; + + // Heads can delete any task. Assignees can delete their own tasks. + // Others cannot delete tasks (especially tasks assigned to someone else). + if (!hasHeadPermission && !isAssignee) { + throw new AccessDeniedException('Only heads or the task assignee can delete sponsor tasks'); + } + const deletedSponsorTask = await prisma.sponsor_Task.update({ where: { sponsorTaskId }, data: { dateDeleted: new Date() } @@ -361,10 +397,11 @@ export default class FinanceServices { notes: string, sponsorId: string, notifyDate?: Date, - assigneeUserId?: string + assigneeUserId?: string, + done?: boolean ): Promise { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { - throw new AccessDeniedException('Only heads can create a sponsor task'); + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can create a sponsor task'); } const sponsor = await prisma.sponsor.findUnique({ where: { sponsorId, organizationId: organization.organizationId } }); @@ -382,6 +419,7 @@ export default class FinanceServices { notifyDate, assignee: assigneeUserId ? { connect: { userId: assigneeUserId } } : undefined, notes, + done: done ?? false, sponsor: { connect: { sponsorId } } }, ...getSponsorTaskQueryArgs(organization.organizationId) @@ -1106,7 +1144,10 @@ export default class FinanceServices { * @param taxExempt Boolean indicating if the sponsor is tax-exempt. * @param discountCode The discount code associated with the sponsor. * @param sponsorNotes Additional notes about the sponsor. - * @param sponsorContact The contact information for the sponsor. + * @param contactName The name of the sponsor contact. + * @param contactEmail The email of the sponsor contact. + * @param contactPhone The phone of the sponsor contact. + * @param contactPosition The position of the sponsor contact. * @param sponsorTasks An array of sponsor tasks associated with the sponsor. * @param organization The organization for which the sponsor is being edited. * @returns the edited sponsor. @@ -1118,19 +1159,29 @@ export default class FinanceServices { sponsorId: string, name: string, activeStatus: boolean, - sponsorValue: number, + valueTypes: Sponsor_Value_Type[], joinDate: Date, activeYears: number[], - sponsorTierId: string, - sponsorContact: string, + sponsorTierId: string | undefined, + contactName: string, taxExempt: boolean, sponsorTasks: CreateSponsorTask[], + sponsorValue?: number, discountCode?: string, - sponsorNotes?: string + sponsorNotes?: string, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string, + stockDescription?: string, + discountDescription?: string ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) throw new AccessDeniedException('Only heads can edit sponsors.'); + if (!contactEmail && !contactPhone) { + throw new HttpException(400, 'At least one of contact email or contact phone is required'); + } + const oldSponsor = await prisma.sponsor.findUnique({ where: { sponsorId, @@ -1143,24 +1194,52 @@ export default class FinanceServices { if (!oldSponsor) throw new NotFoundException('Sponsor', sponsorId); + // Determine which tasks to update, create, or delete + const incomingTaskIds = new Set(sponsorTasks.filter((t) => t.sponsorTaskId).map((t) => t.sponsorTaskId!)); + const tasksToDelete = oldSponsor.sponsorTasks.filter((t) => !incomingTaskIds.has(t.sponsorTaskId)); + const tasksToUpdate = sponsorTasks.filter((t) => t.sponsorTaskId); + const tasksToCreate = sponsorTasks.filter((t) => !t.sponsorTaskId); + + // Delete removed tasks + if (tasksToDelete.length > 0) { + await prisma.sponsor_Task.deleteMany({ + where: { sponsorTaskId: { in: tasksToDelete.map((t) => t.sponsorTaskId) } } + }); + } + + // Update existing tasks await Promise.all( - oldSponsor.sponsorTasks.map((t) => - prisma.sponsor_Task.deleteMany({ - where: { - sponsorTaskId: t.sponsorTaskId + tasksToUpdate.map((t) => + prisma.sponsor_Task.update({ + where: { sponsorTaskId: t.sponsorTaskId! }, + data: { + dueDate: t.dueDate, + notifyDate: t.notifyDate, + assigneeUserId: t.assigneeUserId || null, + notes: t.notes, + done: t.done ?? false } }) ) ); - const tier = await prisma.sponsor_Tier.findUnique({ - where: { - sponsorTierId, - organizationId: organization.organizationId - } - }); + // Create new tasks + await Promise.all( + tasksToCreate.map((t) => + this.createSponsorTask(submitter, organization, t.dueDate, t.notes, sponsorId, t.notifyDate, t.assigneeUserId, t.done) + ) + ); - if (!tier) throw new NotFoundException('Sponsor Tier', sponsorTierId); + if (sponsorTierId) { + const tier = await prisma.sponsor_Tier.findUnique({ + where: { + sponsorTierId, + organizationId: organization.organizationId + } + }); + + if (!tier) throw new NotFoundException('Sponsor Tier', sponsorTierId); + } if (name !== oldSponsor.name) { const existingSponsor = await prisma.sponsor.findFirst({ @@ -1178,34 +1257,23 @@ export default class FinanceServices { } } + await prisma.sponsor_Contact.update({ + where: { sponsorContactId: oldSponsor.contactId }, + data: { name: contactName, email: contactEmail, phone: contactPhone, position: contactPosition } + }); + const updatedSponsor = await prisma.sponsor.update({ where: { sponsorId: oldSponsor.sponsorId }, data: { name, activeStatus, + valueTypes, sponsorValue, + stockDescription, + discountDescription, joinDate, activeYears, - tier: { - connect: { sponsorTierId } - }, - sponsorTasks: { - connect: await Promise.all( - sponsorTasks.map(async (t) => { - const createdTask = await this.createSponsorTask( - submitter, - organization, - t.dueDate, - t.notes, - sponsorId, - t.notifyDate, - t.assigneeUserId - ); - return { sponsorTaskId: createdTask.sponsorTaskId }; - }) - ) - }, - vendorContact: sponsorContact, + tier: sponsorTierId ? { connect: { sponsorTierId } } : { disconnect: true }, taxExempt, discountCode, sponsorNotes @@ -1216,6 +1284,49 @@ export default class FinanceServices { return sponsorTransformer(updatedSponsor); } + /** + * Toggles the done status of a sponsor task + * @param submitter The user toggling the task + * @param organization The organization the task belongs to + * @param sponsorTaskId The id of the sponsor task to toggle + * @returns The updated sponsor task + */ + static async toggleSponsorTaskDone( + submitter: User, + organization: Organization, + sponsorTaskId: string + ): Promise { + const sponsorTask = await prisma.sponsor_Task.findFirst({ + where: { + sponsorTaskId, + dateDeleted: null, + OR: [ + { sponsor: { organizationId: organization.organizationId } }, + { prospectiveSponsor: { organizationId: organization.organizationId } } + ] + }, + ...getSponsorTaskQueryArgs(organization.organizationId) + }); + + if (!sponsorTask) throw new NotFoundException('SponsorTask', sponsorTaskId); + + // Allow finance team, heads, or the task assignee to toggle done status + const isAssignee = sponsorTask.assigneeUserId === submitter.userId; + const isFinanceOrHead = await isUserFinanceTeamOrHead(submitter, organization.organizationId); + + if (!isAssignee && !isFinanceOrHead) { + throw new AccessDeniedException('Only finance team members, heads, or the task assignee can toggle task status'); + } + + const updatedSponsorTask = await prisma.sponsor_Task.update({ + where: { sponsorTaskId }, + data: { done: !sponsorTask.done }, + ...getSponsorTaskQueryArgs(organization.organizationId) + }); + + return sponsorTaskTransformer(updatedSponsorTask); + } + /** * Gets all sponsor tiers * @param organization organization sponsor tiers belong to diff --git a/src/backend/src/services/prospective-sponsor.services.ts b/src/backend/src/services/prospective-sponsor.services.ts new file mode 100644 index 0000000000..20685bef0c --- /dev/null +++ b/src/backend/src/services/prospective-sponsor.services.ts @@ -0,0 +1,459 @@ +import { + CreateSponsorTask, + FirstContactMethod, + isHead, + ProspectiveSponsor, + ProspectiveSponsorStatus, + SponsorTask, + User +} from 'shared'; +import { Organization, Prospective_Sponsor_Status, Sponsor_Value_Type } from '@prisma/client'; +import { userHasPermission } from '../utils/users.utils.js'; +import { getProspectiveSponsorQueryArgs } from '../prisma-query-args/prospective-sponsor.query-args.js'; +import { getSponsorTaskQueryArgs } from '../prisma-query-args/sponsor.query.args.js'; +import { + AccessDeniedException, + DeletedException, + HttpException, + InvalidOrganizationException, + NotFoundException +} from '../utils/errors.utils.js'; +import prisma from '../prisma/prisma.js'; +import { prospectiveSponsorTransformer } from '../transformers/prospective-sponsor.transformer.js'; +import { sponsorTaskTransformer } from '../transformers/sponsor-task.transformer.js'; +import { notifySponsorTaskAssignee } from '../utils/slack.utils.js'; +import { isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; + +export default class ProspectiveSponsorServices { + /** + * Creates a new prospective sponsor for the given organization. + */ + static async createProspectiveSponsor( + submitter: User, + organization: Organization, + organizationName: string, + lastContactDate: Date, + firstContactMethod: FirstContactMethod, + contactName: string, + contactorUserId: string, + highlightThresholdDays?: number, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string, + notes?: string, + tasks?: CreateSponsorTask[] + ): Promise { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can create prospective sponsors'); + } + + if (!contactEmail && !contactPhone) { + throw new HttpException(400, 'At least one of contact email or contact phone is required'); + } + + const existingProspectiveSponsor = await prisma.prospective_Sponsor.findFirst({ + where: { + organizationName: { equals: organizationName, mode: 'insensitive' }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + + if (existingProspectiveSponsor) { + throw new HttpException(400, `A prospective sponsor with the name "${organizationName}" already exists.`); + } + + const contactor = await prisma.user.findUnique({ + where: { userId: contactorUserId } + }); + + if (!contactor) { + throw new NotFoundException('User', contactorUserId); + } + + const contact = await prisma.sponsor_Contact.create({ + data: { name: contactName, email: contactEmail, phone: contactPhone, position: contactPosition } + }); + + const prospectiveSponsor = await prisma.prospective_Sponsor.create({ + data: { + organizationName, + lastContactDate, + highlightThresholdDays: highlightThresholdDays ?? 10, + firstContactMethod, + contactorUserId, + contactId: contact.sponsorContactId, + notes, + organizationId: organization.organizationId, + tasks: tasks?.length + ? { + create: tasks.map((task) => ({ + dueDate: task.dueDate, + notifyDate: task.notifyDate, + assigneeUserId: task.assigneeUserId, + notes: task.notes + })) + } + : undefined + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + if (tasks?.length) { + tasks.forEach(async (task) => { + if (!task.assigneeUserId) return; + const assignee = await prisma.user.findUnique({ + where: { userId: task.assigneeUserId }, + include: { userSettings: true } + }); + if (assignee) { + await notifySponsorTaskAssignee(assignee, task, organizationName); + } + }); + } + + return prospectiveSponsorTransformer(prospectiveSponsor); + } + + /** + * Returns all prospective sponsors for the organization. + */ + static async getAllProspectiveSponsors(organization: Organization): Promise { + const prospectiveSponsors = await prisma.prospective_Sponsor.findMany({ + where: { + organizationId: organization.organizationId, + dateDeleted: null + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsors.map(prospectiveSponsorTransformer); + } + + /** + * Edits a prospective sponsor. + */ + static async editProspectiveSponsor( + submitter: User, + organization: Organization, + prospectiveSponsorId: string, + organizationName: string, + lastContactDate: Date, + status: ProspectiveSponsorStatus, + firstContactMethod: FirstContactMethod, + contactName: string, + contactorUserId: string, + highlightThresholdDays?: number, + contactEmail?: string, + contactPhone?: string, + contactPosition?: string, + notes?: string, + tasks?: CreateSponsorTask[] + ): Promise { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can edit prospective sponsors'); + } + + if (!contactEmail && !contactPhone) { + throw new HttpException(400, 'At least one of contact email or contact phone is required'); + } + + const oldProspectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, organizationId: organization.organizationId }, + include: { tasks: true } + }); + + if (!oldProspectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (oldProspectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + + if (organizationName !== oldProspectiveSponsor.organizationName) { + const existingProspectiveSponsor = await prisma.prospective_Sponsor.findFirst({ + where: { + organizationName: { equals: organizationName, mode: 'insensitive' }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + + if (existingProspectiveSponsor) { + throw new HttpException(400, `A prospective sponsor with the name "${organizationName}" already exists.`); + } + } + + const contactor = await prisma.user.findUnique({ + where: { userId: contactorUserId } + }); + + if (!contactor) { + throw new NotFoundException('User', contactorUserId); + } + + // Upsert tasks if provided + if (tasks) { + const incomingTaskIds = new Set(tasks.filter((t) => t.sponsorTaskId).map((t) => t.sponsorTaskId!)); + const tasksToDelete = oldProspectiveSponsor.tasks.filter((t) => !incomingTaskIds.has(t.sponsorTaskId)); + const tasksToUpdate = tasks.filter((t) => t.sponsorTaskId); + const tasksToCreate = tasks.filter((t) => !t.sponsorTaskId); + + if (tasksToDelete.length > 0) { + await prisma.sponsor_Task.deleteMany({ + where: { sponsorTaskId: { in: tasksToDelete.map((t) => t.sponsorTaskId) } } + }); + } + + await Promise.all( + tasksToUpdate.map((t) => + prisma.sponsor_Task.update({ + where: { sponsorTaskId: t.sponsorTaskId! }, + data: { + dueDate: t.dueDate, + notifyDate: t.notifyDate, + assigneeUserId: t.assigneeUserId || null, + notes: t.notes, + done: t.done ?? false + } + }) + ) + ); + + await Promise.all( + tasksToCreate.map((t) => + this.createProspectiveSponsorTask( + submitter, + organization, + prospectiveSponsorId, + t.dueDate, + t.notes, + t.notifyDate, + t.assigneeUserId + ) + ) + ); + } + + await prisma.sponsor_Contact.update({ + where: { sponsorContactId: oldProspectiveSponsor.contactId }, + data: { name: contactName, email: contactEmail, phone: contactPhone, position: contactPosition } + }); + + const updatedProspectiveSponsor = await prisma.prospective_Sponsor.update({ + where: { prospectiveSponsorId }, + data: { + organizationName, + lastContactDate, + highlightThresholdDays: highlightThresholdDays ?? 10, + status, + firstContactMethod, + contactorUserId, + notes + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsorTransformer(updatedProspectiveSponsor); + } + + /** + * Soft deletes a prospective sponsor. + */ + static async deleteProspectiveSponsor( + prospectiveSponsorId: string, + deleter: User, + organization: Organization + ): Promise { + if (!(await userHasPermission(deleter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedException('Only heads can delete prospective sponsors'); + } + + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('ProspectiveSponsor'); + } + if (prospectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + + const deletedProspectiveSponsor = await prisma.prospective_Sponsor.update({ + where: { prospectiveSponsorId }, + data: { dateDeleted: new Date() }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsorTransformer(deletedProspectiveSponsor); + } + + /** + * Gets tasks for a prospective sponsor. + */ + static async getProspectiveSponsorTasks( + prospectiveSponsorId: string, + organizationId: string + ): Promise { + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, dateDeleted: null } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + + const tasks = await prisma.sponsor_Task.findMany({ + where: { + prospectiveSponsorId, + dateDeleted: null + }, + ...getSponsorTaskQueryArgs(organizationId) + }); + + return tasks.map(sponsorTaskTransformer); + } + + /** + * Creates a task for a prospective sponsor. + */ + static async createProspectiveSponsorTask( + submitter: User, + organization: Organization, + prospectiveSponsorId: string, + dueDate: Date, + notes: string, + notifyDate?: Date, + assigneeUserId?: string + ): Promise { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { + throw new AccessDeniedException('Only finance team members or heads can create prospective sponsor tasks'); + } + + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, organizationId: organization.organizationId } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + + if (assigneeUserId) { + const assignee = await prisma.user.findUnique({ where: { userId: assigneeUserId } }); + if (!assignee) throw new NotFoundException('User', assigneeUserId); + } + + const createdTask = await prisma.sponsor_Task.create({ + data: { + dueDate, + notifyDate, + assigneeUserId, + notes, + prospectiveSponsorId + }, + ...getSponsorTaskQueryArgs(organization.organizationId) + }); + + if (createdTask.assigneeUserId) { + const assignee = await prisma.user.findUnique({ + where: { userId: createdTask.assigneeUserId }, + include: { userSettings: true } + }); + if (assignee) { + await notifySponsorTaskAssignee(assignee, createdTask, prospectiveSponsor.organizationName); + } + } + + return sponsorTaskTransformer(createdTask); + } + + /** + * Accepts a prospective sponsor and creates a full sponsor record. + */ + static async acceptProspectiveSponsor( + submitter: User, + organization: Organization, + prospectiveSponsorId: string, + sponsorTierId: string | undefined, + valueTypes: Sponsor_Value_Type[], + joinDate: Date, + activeYears: number[], + taxExempt: boolean, + sponsorValue?: number, + discountCode?: string, + sponsorNotes?: string, + stockDescription?: string, + discountDescription?: string + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedException('Only heads can accept prospective sponsors'); + } + + const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId, organizationId: organization.organizationId }, + include: { contact: true } + }); + + if (!prospectiveSponsor) throw new NotFoundException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId); + if (prospectiveSponsor.status === Prospective_Sponsor_Status.ACCEPTED) { + throw new HttpException(400, 'This prospective sponsor has already been accepted'); + } + + if (sponsorTierId) { + const tier = await prisma.sponsor_Tier.findUnique({ + where: { sponsorTierId, organizationId: organization.organizationId } + }); + + if (!tier) throw new NotFoundException('SponsorTier', sponsorTierId); + } + + // Check if a sponsor with this name already exists + const existingSponsor = await prisma.sponsor.findFirst({ + where: { + name: { equals: prospectiveSponsor.organizationName, mode: 'insensitive' }, + organizationId: organization.organizationId, + dateDeleted: null + } + }); + + if (existingSponsor) { + throw new HttpException(400, `A sponsor with the name "${prospectiveSponsor.organizationName}" already exists.`); + } + + // Create a new contact for the sponsor, copied from the prospective sponsor's contact + const sponsorContact = await prisma.sponsor_Contact.create({ + data: { + name: prospectiveSponsor.contact.name, + email: prospectiveSponsor.contact.email, + phone: prospectiveSponsor.contact.phone, + position: prospectiveSponsor.contact.position + } + }); + + // Create the sponsor + await prisma.sponsor.create({ + data: { + name: prospectiveSponsor.organizationName, + activeStatus: true, + valueTypes, + sponsorValue, + stockDescription, + discountDescription, + joinDate, + activeYears, + sponsorTierId, + taxExempt, + discountCode, + sponsorNotes, + contactId: sponsorContact.sponsorContactId, + organizationId: organization.organizationId + } + }); + + // Soft-delete the prospective sponsor since they're now a full sponsor + const updatedProspectiveSponsor = await prisma.prospective_Sponsor.update({ + where: { prospectiveSponsorId }, + data: { + status: Prospective_Sponsor_Status.ACCEPTED, + dateDeleted: new Date() + }, + ...getProspectiveSponsorQueryArgs(organization.organizationId) + }); + + return prospectiveSponsorTransformer(updatedProspectiveSponsor); + } +} diff --git a/src/backend/src/services/reimbursement-requests.services.ts b/src/backend/src/services/reimbursement-requests.services.ts index f3f3a57621..4a5183a9e3 100644 --- a/src/backend/src/services/reimbursement-requests.services.ts +++ b/src/backend/src/services/reimbursement-requests.services.ts @@ -34,7 +34,7 @@ import { validateUserEditRRPermissions, validateRefund, validateUserIsPartOfFinanceTeamOrHead, - isUserOnFinanceTeam + isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js'; import { AccessDeniedAdminOnlyException, @@ -188,10 +188,7 @@ export default class ReimbursementRequestService { * @returns All the reimbursements in the database */ static async getAllReimbursements(user: User, organization: Organization): Promise { - const isUserAuthorized = - (await isUserOnFinanceTeam(user, organization.organizationId)) || - (await userHasPermission(user.userId, organization.organizationId, isHead)); - if (!isUserAuthorized) { + if (!(await isUserFinanceTeamOrHead(user, organization.organizationId))) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -869,10 +866,7 @@ export default class ReimbursementRequestService { * @returns the 'deleted' account code */ static async deleteAccountCode(accountCodeId: string, submitter: User, organization: Organization) { - const isUserAuthorized = - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); - if (!isUserAuthorized) { + if (!(await isUserFinanceTeamOrHead(submitter, organization.organizationId))) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -981,10 +975,7 @@ export default class ReimbursementRequestService { * @returns an array of the prisma version of the reimbursement requests transformed to the shared version */ static async getAllReimbursementRequests(user: User, organization: Organization): Promise { - const isUserAuthorized = - (await isUserOnFinanceTeam(user, organization.organizationId)) || - (await userHasPermission(user.userId, organization.organizationId, isHead)); - if (!isUserAuthorized) { + if (!(await isUserFinanceTeamOrHead(user, organization.organizationId))) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -1511,8 +1502,7 @@ export default class ReimbursementRequestService { const isUserAuthorized = existingVendor.addedByUserId === submitter.userId || - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); + (await isUserFinanceTeamOrHead(submitter, organization.organizationId)); if (!isUserAuthorized) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -1565,8 +1555,7 @@ export default class ReimbursementRequestService { const isUserAuthorized = existingVendor.addedByUserId === submitter.userId || - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); + (await isUserFinanceTeamOrHead(submitter, organization.organizationId)); if (!isUserAuthorized) { throw new AccessDeniedException(`You are not a member of the finance team!`); } @@ -1603,8 +1592,7 @@ export default class ReimbursementRequestService { const isUserAuthorized = existingVendor.addedByUserId === submitter.userId || - (await isUserOnFinanceTeam(submitter, organization.organizationId)) || - (await userHasPermission(submitter.userId, organization.organizationId, isHead)); + (await isUserFinanceTeamOrHead(submitter, organization.organizationId)); if (!isUserAuthorized) { throw new AccessDeniedException(`You are not a member of the finance team!`); } diff --git a/src/backend/src/transformers/finance.transformer.ts b/src/backend/src/transformers/finance.transformer.ts index e7aa8365f3..00eed4ca40 100644 --- a/src/backend/src/transformers/finance.transformer.ts +++ b/src/backend/src/transformers/finance.transformer.ts @@ -1,12 +1,29 @@ import { Prisma } from '@prisma/client'; -import { Sponsor, SponsorTask } from 'shared'; +import { Sponsor, SponsorTask, SponsorValueType } from 'shared'; import { SponsorQueryArgs, SponsorTaskQueryArgs } from '../prisma-query-args/sponsor.query.args.js'; import { userTransformer } from './user.transformer.js'; export const sponsorTransformer = (sponsor: Prisma.SponsorGetPayload): Sponsor => { return { ...sponsor, - sponsorContact: sponsor.vendorContact, + valueTypes: sponsor.valueTypes as SponsorValueType[], + contact: { + name: sponsor.contact.name, + email: sponsor.contact.email ?? undefined, + phone: sponsor.contact.phone ?? undefined, + position: sponsor.contact.position ?? undefined + }, + sponsorValue: sponsor.sponsorValue ?? undefined, + stockDescription: sponsor.stockDescription ?? undefined, + discountDescription: sponsor.discountDescription ?? undefined, + tier: sponsor.tier + ? { + sponsorTierId: sponsor.tier.sponsorTierId, + name: sponsor.tier.name, + colorHexCode: sponsor.tier.colorHexCode, + minSupportValue: sponsor.tier.minSupportValue + } + : undefined, sponsorNotes: sponsor.sponsorNotes ?? undefined, discountCode: sponsor.discountCode ?? undefined, sponsorTasks: sponsor.sponsorTasks.map(sponsorTaskTranformer) diff --git a/src/backend/src/transformers/prospective-sponsor.transformer.ts b/src/backend/src/transformers/prospective-sponsor.transformer.ts new file mode 100644 index 0000000000..b036575d00 --- /dev/null +++ b/src/backend/src/transformers/prospective-sponsor.transformer.ts @@ -0,0 +1,30 @@ +import { Prisma } from '@prisma/client'; +import { ProspectiveSponsor, ProspectiveSponsorStatus, FirstContactMethod } from 'shared'; +import { ProspectiveSponsorQueryArgs } from '../prisma-query-args/prospective-sponsor.query-args.js'; +import { userTransformer } from './user.transformer.js'; +import { sponsorTaskTransformer } from './sponsor-task.transformer.js'; + +export const prospectiveSponsorTransformer = ( + prospectiveSponsor: Prisma.Prospective_SponsorGetPayload +): ProspectiveSponsor => { + return { + prospectiveSponsorId: prospectiveSponsor.prospectiveSponsorId, + organizationName: prospectiveSponsor.organizationName, + dateCreated: prospectiveSponsor.dateCreated, + lastContactDate: prospectiveSponsor.lastContactDate, + highlightThresholdDays: prospectiveSponsor.highlightThresholdDays, + status: prospectiveSponsor.status as ProspectiveSponsorStatus, + firstContactMethod: prospectiveSponsor.firstContactMethod as FirstContactMethod, + contactor: userTransformer(prospectiveSponsor.contactor), + contact: { + name: prospectiveSponsor.contact.name, + email: prospectiveSponsor.contact.email ?? undefined, + phone: prospectiveSponsor.contact.phone ?? undefined, + position: prospectiveSponsor.contact.position ?? undefined + }, + notes: prospectiveSponsor.notes ?? undefined, + tasks: prospectiveSponsor.tasks.map(sponsorTaskTransformer) + }; +}; + +export default prospectiveSponsorTransformer; diff --git a/src/backend/src/transformers/sponsor-task.transformer.ts b/src/backend/src/transformers/sponsor-task.transformer.ts index 68a30dece7..36e8a2735b 100644 --- a/src/backend/src/transformers/sponsor-task.transformer.ts +++ b/src/backend/src/transformers/sponsor-task.transformer.ts @@ -9,7 +9,8 @@ export const sponsorTaskTransformer = (sponsorTask: Prisma.Sponsor_TaskGetPayloa dueDate: sponsorTask.dueDate, notifyDate: sponsorTask.notifyDate ?? undefined, assignee: sponsorTask.assignee ? userTransformer(sponsorTask.assignee) : undefined, - notes: sponsorTask.notes + notes: sponsorTask.notes, + done: sponsorTask.done }; }; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index 14cb219315..8ef8e17166 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -210,4 +210,6 @@ export type ExceptionObjectNames = | 'Calendar' | 'Event Type' | 'Event' - | 'Schedule Slot'; + | 'Schedule Slot' + | 'ProspectiveSponsor' + | 'SponsorTier'; diff --git a/src/backend/src/utils/reimbursement-requests.utils.ts b/src/backend/src/utils/reimbursement-requests.utils.ts index b29af44d65..4d23ed084b 100644 --- a/src/backend/src/utils/reimbursement-requests.utils.ts +++ b/src/backend/src/utils/reimbursement-requests.utils.ts @@ -311,12 +311,18 @@ export const createReimbursementProducts = async ( * finance team. */ export const validateUserIsPartOfFinanceTeamOrHead = async (user: User, organizationId: string) => { - const isUserAuthorized = - (await isUserOnFinanceTeam(user, organizationId)) || (await userHasPermission(user.userId, organizationId, isHead)); - - if (!isUserAuthorized) { - throw new AccessDeniedException(`You are not a member of the finance team!`); + // Check isHead first since it doesn't require finance team to exist + if (await userHasPermission(user.userId, organizationId, isHead)) { + return; + } + try { + if (await isUserOnFinanceTeam(user, organizationId)) { + return; + } + } catch { + // Finance team may not exist yet } + throw new AccessDeniedException(`You are not a member of the finance team!`); }; const getFinanceTeam = async (organizationId: string) => { @@ -349,6 +355,26 @@ export const isUserOnFinanceTeam = async (user: User, organizationId: string): P return isUserOnTeam(await getFinanceTeam(organizationId), user); }; +/** + * Checks if a user is on the finance team or is a head. + * Checks isHead first since it doesn't require the finance team to exist. + * + * @param user the user to check + * @param organizationId the organization id + * @returns whether the user is on the finance team or is a head + */ +export const isUserFinanceTeamOrHead = async (user: User, organizationId: string): Promise => { + if (await userHasPermission(user.userId, organizationId, isHead)) { + return true; + } + try { + return await isUserOnFinanceTeam(user, organizationId); + } catch { + // Finance team may not exist yet + return false; + } +}; + /** * Determines if a user is lead or head of the finance team. * diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index cda5e85f94..ee33c7d2ee 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -161,6 +161,7 @@ export const resetUsers = async () => { await prisma.announcement.deleteMany(); await prisma.popUp.deleteMany(); await prisma.sponsor_Task.deleteMany(); + await prisma.prospective_Sponsor.deleteMany(); await prisma.sponsor.deleteMany(); await prisma.sponsor_Tier.deleteMany(); await prisma.reimbursement_Product_Other_Reason.deleteMany(); diff --git a/src/backend/tests/unit/finance.test.ts b/src/backend/tests/unit/finance.test.ts index 1f4e22f66a..ac5fe2b02d 100644 --- a/src/backend/tests/unit/finance.test.ts +++ b/src/backend/tests/unit/finance.test.ts @@ -5,6 +5,8 @@ import { batmanAppAdmin, wonderwomanGuest, supermanAdmin, theVisitorGuest } from import { createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; import prisma from '../../src/prisma/prisma.js'; +const TEST_EMAIL = 'test@test.com'; + describe('Finance Tests', () => { let orgId: string; let organization: Organization; @@ -35,7 +37,7 @@ describe('Finance Tests', () => { await createTestUser(wonderwomanGuest, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -43,7 +45,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ) ).rejects.toThrow(new AccessDeniedException('Only heads can create a sponsor')); }); @@ -53,7 +58,7 @@ describe('Finance Tests', () => { await createTestUser(batmanAppAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -61,7 +66,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); expect(result.name).toEqual('Google'); @@ -69,10 +77,10 @@ describe('Finance Tests', () => { expect(result.sponsorValue).toBe(5000); expect(result.joinDate).toEqual(new Date(12, 1, 24)); expect(result.activeYears).toEqual([2024, 2025]); - expect(result.tier.sponsorTierId).toEqual(sponsorTierId); + expect(result.tier!.sponsorTierId).toEqual(sponsorTierId); expect(result.taxExempt).toBe(true); expect(result.discountCode).toEqual('googlecode'); - expect(result.sponsorContact).toEqual('Bill Gates'); + expect(result.contact.name).toEqual('Bill Gates'); expect(result.sponsorTasks).toEqual([]); }); }); @@ -83,7 +91,7 @@ describe('Finance Tests', () => { await createTestUser(batmanAppAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -91,13 +99,16 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); const spon2 = await FinanceServices.createSponsor( await createTestUser(supermanAdmin, orgId), 'Apple', true, - 2000, + ['MONETARY'], new Date(11, 23, 24), [2024, 2025], sponsorTierId, @@ -105,7 +116,10 @@ describe('Finance Tests', () => { 'Tim Cook', [], organization, - 'applecode' + 2000, + 'applecode', + undefined, + TEST_EMAIL ); const result = await FinanceServices.getAllSponsors(organization); expect(result).toStrictEqual([spon1, spon2]); @@ -117,7 +131,7 @@ describe('Finance Tests', () => { await createTestUser(supermanAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -125,7 +139,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); const deletedSponsor = await FinanceServices.deleteSponsor( @@ -145,7 +162,7 @@ describe('Finance Tests', () => { await createTestUser(supermanAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -153,7 +170,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); await expect(async () => @@ -171,7 +191,7 @@ describe('Finance Tests', () => { user, 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -179,7 +199,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); await FinanceServices.deleteSponsor(sponsor.sponsorId, user, organization); @@ -196,7 +219,7 @@ describe('Finance Tests', () => { await createTestUser(supermanAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -204,7 +227,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); const oldSponsorTask = await prisma.sponsor_Task.create({ @@ -237,7 +263,7 @@ describe('Finance Tests', () => { await createTestUser(supermanAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -245,7 +271,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); await expect( @@ -258,7 +287,7 @@ describe('Finance Tests', () => { 'newNotes', new Date(12, 20, 24) ) - ).rejects.toThrow(new AccessDeniedException('Only heads can edit sponsor tasks.')); + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can edit sponsor tasks')); }); it('Edit fails if sponsor task does not exist', async () => { await expect( @@ -278,7 +307,7 @@ describe('Finance Tests', () => { await createTestUser(supermanAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -286,7 +315,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); const oldSponsorTask = await prisma.sponsor_Task.create({ @@ -346,7 +378,7 @@ describe('Finance Tests', () => { await createTestUser(batmanAppAdmin, orgId), 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -367,7 +399,10 @@ describe('Finance Tests', () => { } ], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); const sponsorTasks = await FinanceServices.getSponsorTasks(sponsor.sponsorId, organization.organizationId); @@ -389,7 +424,7 @@ describe('Finance Tests', () => { const user = await createTestUser(wonderwomanGuest, orgId); await expect( FinanceServices.createSponsorTask(user, organization, new Date(1, 1, 25), 'notes', 'sponsorId') - ).rejects.toThrow(new AccessDeniedException('Only heads can create a sponsor task')); + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can create a sponsor task')); }); it('Fails when assigned user is not found', async () => { @@ -398,7 +433,7 @@ describe('Finance Tests', () => { user, 'Telsa', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024], sponsorTierId, @@ -406,7 +441,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'telsaCode' + 5000, + 'telsaCode', + undefined, + TEST_EMAIL ); await expect( @@ -436,7 +474,7 @@ describe('Finance Tests', () => { user, 'Telsa', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024], sponsorTierId, @@ -444,7 +482,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'telsaCode' + 5000, + 'telsaCode', + undefined, + TEST_EMAIL ); const result = await FinanceServices.createSponsorTask( @@ -499,7 +540,7 @@ describe('Finance Tests', () => { user, 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -507,7 +548,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); const updatedSponsor = await FinanceServices.editSponsor( @@ -516,14 +560,17 @@ describe('Finance Tests', () => { oldSponsor.sponsorId, 'newName', false, - 4000, + ['MONETARY'], new Date(5, 11, 25), [2024, 2025], sponsorTierId, 'New Vendor Contact', false, [], - 'New Discount code' + 4000, + 'New Discount code', + undefined, + TEST_EMAIL ); expect(updatedSponsor.name).toBe('newName'); @@ -531,8 +578,8 @@ describe('Finance Tests', () => { expect(updatedSponsor.sponsorValue).toBe(4000); expect(updatedSponsor.joinDate).toEqual(new Date(5, 11, 25)); expect(updatedSponsor.activeYears).toEqual([2024, 2025]); - expect(updatedSponsor.tier.sponsorTierId).toBe(sponsorTierId); - expect(updatedSponsor.sponsorContact).toBe('New Vendor Contact'); + expect(updatedSponsor.tier!.sponsorTierId).toBe(sponsorTierId); + expect(updatedSponsor.contact.name).toBe('New Vendor Contact'); expect(updatedSponsor.taxExempt).toBe(false); expect(updatedSponsor.discountCode).toBe('New Discount code'); }); @@ -544,7 +591,7 @@ describe('Finance Tests', () => { user, 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -552,7 +599,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); await expect( async () => @@ -562,13 +612,17 @@ describe('Finance Tests', () => { oldSponsor.sponsorId, 'newName', false, - 4000, + ['MONETARY'], new Date(5, 11, 25), [2024, 2025], sponsorTierId, 'New Vendor Contact', false, - [] + [], + 4000, + undefined, + undefined, + TEST_EMAIL ) ).rejects.toThrow(new AccessDeniedException('Only heads can edit sponsors.')); }); @@ -581,13 +635,17 @@ describe('Finance Tests', () => { 'badId', 'newName', false, - 4000, + ['MONETARY'], new Date(5, 11, 25), [2024, 2025], sponsorTierId, 'New Vendor Contact', false, - [] + [], + 4000, + undefined, + undefined, + TEST_EMAIL ) ).rejects.toThrow(new NotFoundException('Sponsor', 'badId')); }); @@ -597,7 +655,7 @@ describe('Finance Tests', () => { user, 'Google', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024, 2025], sponsorTierId, @@ -605,7 +663,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'googlecode' + 5000, + 'googlecode', + undefined, + TEST_EMAIL ); await expect( async () => @@ -615,13 +676,17 @@ describe('Finance Tests', () => { oldSponsor.sponsorId, 'newName', false, - 4000, + ['MONETARY'], new Date(5, 11, 25), [2024, 2025], 'badId', 'New Vendor Contact', false, - [] + [], + 4000, + undefined, + undefined, + TEST_EMAIL ) ).rejects.toThrow(new NotFoundException('Sponsor Tier', 'badId')); }); @@ -634,7 +699,7 @@ describe('Finance Tests', () => { user, 'Telsa', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024], sponsorTierId, @@ -642,7 +707,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'telsaCode' + 5000, + 'telsaCode', + undefined, + TEST_EMAIL ); const sponsorTask = await FinanceServices.createSponsorTask( @@ -670,7 +738,7 @@ describe('Finance Tests', () => { user, 'Telsa', true, - 5000, + ['MONETARY'], new Date(12, 1, 24), [2024], sponsorTierId, @@ -678,7 +746,10 @@ describe('Finance Tests', () => { 'Bill Gates', [], organization, - 'telsaCode' + 5000, + 'telsaCode', + undefined, + TEST_EMAIL ); const sponsorTask = await FinanceServices.createSponsorTask( @@ -697,7 +768,7 @@ describe('Finance Tests', () => { await createTestUser(theVisitorGuest, orgId), organization ) - ).rejects.toThrow(new AccessDeniedException('Only heads can delete sponsor tasks.')); + ).rejects.toThrow(new AccessDeniedException('Only heads or the task assignee can delete sponsor tasks')); }); it('Delete fails if given sponsor task cannot be found', async () => { await expect(async () => @@ -705,4 +776,164 @@ describe('Finance Tests', () => { ).rejects.toThrow(new NotFoundException('SponsorTask', '123')); }); }); + + describe('Toggle Sponsor Task Done', () => { + it('Succeeds and toggles done from false to true', async () => { + const user = await createTestUser(supermanAdmin, orgId); + const sponsor = await FinanceServices.createSponsor( + user, + 'Tesla', + true, + ['MONETARY'], + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization, + 5000, + undefined, + undefined, + TEST_EMAIL + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + user, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + expect(sponsorTask.done).toBe(false); + + const toggledTask = await FinanceServices.toggleSponsorTaskDone( + user, + organization, + sponsorTask.sponsorTaskId + ); + + expect(toggledTask.done).toBe(true); + }); + + it('Succeeds and toggles done from true to false', async () => { + const user = await createTestUser(supermanAdmin, orgId); + const sponsor = await FinanceServices.createSponsor( + user, + 'Tesla', + true, + ['MONETARY'], + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization, + 5000, + undefined, + undefined, + TEST_EMAIL + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + user, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + // Toggle to true first + await FinanceServices.toggleSponsorTaskDone(user, organization, sponsorTask.sponsorTaskId); + + // Toggle back to false + const toggledTask = await FinanceServices.toggleSponsorTaskDone( + user, + organization, + sponsorTask.sponsorTaskId + ); + + expect(toggledTask.done).toBe(false); + }); + + it('Fails if user is not a head', async () => { + const head = await createTestUser(supermanAdmin, orgId); + const guest = await createTestUser(theVisitorGuest, orgId); + + const sponsor = await FinanceServices.createSponsor( + head, + 'Tesla', + true, + ['MONETARY'], + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization, + 5000, + undefined, + undefined, + TEST_EMAIL + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + head, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + await expect( + FinanceServices.toggleSponsorTaskDone(guest, organization, sponsorTask.sponsorTaskId) + ).rejects.toThrow(new AccessDeniedException('Only finance team members, heads, or the task assignee can toggle task status')); + }); + + it('Fails if sponsor task does not exist', async () => { + const user = await createTestUser(supermanAdmin, orgId); + + await expect( + FinanceServices.toggleSponsorTaskDone(user, organization, 'nonexistent-id') + ).rejects.toThrow(new NotFoundException('SponsorTask', 'nonexistent-id')); + }); + + it('Fails if sponsor task is deleted', async () => { + const user = await createTestUser(supermanAdmin, orgId); + const sponsor = await FinanceServices.createSponsor( + user, + 'Tesla', + true, + ['MONETARY'], + new Date(12, 1, 24), + [2024], + sponsorTierId, + true, + 'Elon Musk', + [], + organization, + 5000, + undefined, + undefined, + TEST_EMAIL + ); + + const sponsorTask = await FinanceServices.createSponsorTask( + user, + organization, + new Date(1, 2, 3), + 'Test task', + sponsor.sponsorId + ); + + await FinanceServices.deleteSponsorTask(sponsorTask.sponsorTaskId, user, organization); + + await expect( + FinanceServices.toggleSponsorTaskDone(user, organization, sponsorTask.sponsorTaskId) + ).rejects.toThrow(new NotFoundException('SponsorTask', sponsorTask.sponsorTaskId)); + }); + }); }); diff --git a/src/backend/tests/unit/prospective-sponsor.test.ts b/src/backend/tests/unit/prospective-sponsor.test.ts new file mode 100644 index 0000000000..e1d29a2204 --- /dev/null +++ b/src/backend/tests/unit/prospective-sponsor.test.ts @@ -0,0 +1,1074 @@ +import { Organization } from '@prisma/client'; +import ProspectiveSponsorServices from '../../src/services/prospective-sponsor.services.js'; +import FinanceServices from '../../src/services/finance.services.js'; +import { + AccessDeniedException, + DeletedException, + HttpException, + NotFoundException +} from '../../src/utils/errors.utils.js'; +import { batmanAppAdmin, wonderwomanGuest, supermanAdmin, theVisitorGuest } from '../test-data/users.test-data.js'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; +import prisma from '../../src/prisma/prisma.js'; +import { FirstContactMethod, ProspectiveSponsorStatus } from 'shared'; + +describe('Prospective Sponsor Tests', () => { + let orgId: string; + let organization: Organization; + let sponsorTierId: string; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + const sponsorTier = await prisma.sponsor_Tier.create({ + data: { + name: 'Gold Tier', + colorHexCode: '#FFD700', + organizationId: orgId + } + }); + ({ sponsorTierId } = sponsorTier); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Create Prospective Sponsor', () => { + it('Fails if user is not on finance team or a head', async () => { + const guest = await createTestUser(wonderwomanGuest, orgId); + const contactor = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsor( + guest, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + contactor.userId, + undefined, + 'contact@test.com' + ) + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can create prospective sponsors')); + }); + + it('Fails if neither email nor phone is provided', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ) + ).rejects.toThrow(new HttpException(400, 'At least one of contact email or contact phone is required')); + }); + + it('Succeeds with only email', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const result = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Email Only Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'john@email.com' + ); + + expect(result.contact.email).toBe('john@email.com'); + expect(result.contact.phone).toBeUndefined(); + }); + + it('Succeeds with only phone', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const result = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Phone Only Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + undefined, + '555-1234' + ); + + expect(result.contact.email).toBeUndefined(); + expect(result.contact.phone).toBe('555-1234'); + }); + + it('Fails if prospective sponsor with same name already exists', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Doe', + head.userId, + undefined, + 'jane@test.com' + ) + ).rejects.toThrow(new HttpException(400, 'A prospective sponsor with the name "Acme Corp" already exists.')); + }); + + it('Fails if contactor user does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + 'nonexistent-user-id', + undefined, + 'contact@test.com' + ) + ).rejects.toThrow(new NotFoundException('User', 'nonexistent-user-id')); + }); + + it('Succeeds and creates a prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const lastContactDate = new Date(2024, 5, 15); + + const result = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + lastContactDate, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + 14, + 'john@acme.com', + '555-1234', + 'CEO' + ); + + expect(result.organizationName).toBe('Acme Corp'); + expect(result.lastContactDate).toEqual(lastContactDate); + expect(result.firstContactMethod).toBe(FirstContactMethod.INBOUND_EMAIL); + expect(result.contact.name).toBe('John Doe'); + expect(result.contact.email).toBe('john@acme.com'); + expect(result.contact.phone).toBe('555-1234'); + expect(result.contact.position).toBe('CEO'); + expect(result.contactor.userId).toBe(head.userId); + expect(result.highlightThresholdDays).toBe(14); + expect(result.status).toBe(ProspectiveSponsorStatus.IN_PROGRESS); + expect(result.tasks).toEqual([]); + }); + + it('Uses default highlight threshold of 10 days', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const result = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + expect(result.highlightThresholdDays).toBe(10); + }); + }); + + describe('Get All Prospective Sponsors', () => { + it('Returns all prospective sponsors for organization', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps1 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + const ps2 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Beta Inc', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + head.userId, + undefined, + 'jane@test.com' + ); + + const results = await ProspectiveSponsorServices.getAllProspectiveSponsors(organization); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.prospectiveSponsorId)).toContain(ps1.prospectiveSponsorId); + expect(results.map((r) => r.prospectiveSponsorId)).toContain(ps2.prospectiveSponsorId); + }); + + it('Does not return deleted prospective sponsors', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps1 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Beta Inc', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + head.userId, + undefined, + 'jane@test.com' + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps1.prospectiveSponsorId, head, organization); + + const results = await ProspectiveSponsorServices.getAllProspectiveSponsors(organization); + + expect(results).toHaveLength(1); + expect(results[0].organizationName).toBe('Beta Inc'); + }); + }); + + describe('Edit Prospective Sponsor', () => { + it('Fails if user is not on finance team or a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + guest, + organization, + ps.prospectiveSponsorId, + 'Acme Corp Updated', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ) + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can edit prospective sponsors')); + }); + + it('Fails if neither email nor phone is provided', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'Acme Corp', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId + ) + ).rejects.toThrow(new HttpException(400, 'At least one of contact email or contact phone is required')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + 'nonexistent-id', + 'Acme Corp', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'Acme Corp Updated', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if new name already exists', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps1 = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Beta Inc', + new Date(), + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + head.userId, + undefined, + 'jane@test.com' + ); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps1.prospectiveSponsorId, + 'Beta Inc', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ) + ).rejects.toThrow(new HttpException(400, 'A prospective sponsor with the name "Beta Inc" already exists.')); + }); + + it('Fails if contactor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'Acme Corp', + new Date(), + ProspectiveSponsorStatus.IN_PROGRESS, + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + 'nonexistent-user-id', + undefined, + 'contact@test.com' + ) + ).rejects.toThrow(new NotFoundException('User', 'nonexistent-user-id')); + }); + + it('Succeeds and updates prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const newContactor = await createTestUser(supermanAdmin, orgId); + const newLastContactDate = new Date(2024, 8, 20); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + const result = await ProspectiveSponsorServices.editProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'Acme Corporation', + newLastContactDate, + ProspectiveSponsorStatus.NO_RESPONSE, + FirstContactMethod.OUTBOUND_EMAIL, + 'Jane Smith', + newContactor.userId, + 20, + 'jane@acme.com', + '555-5678', + 'CFO' + ); + + expect(result.organizationName).toBe('Acme Corporation'); + expect(result.lastContactDate).toEqual(newLastContactDate); + expect(result.status).toBe(ProspectiveSponsorStatus.NO_RESPONSE); + expect(result.firstContactMethod).toBe(FirstContactMethod.OUTBOUND_EMAIL); + expect(result.contact.name).toBe('Jane Smith'); + expect(result.contact.email).toBe('jane@acme.com'); + expect(result.contact.phone).toBe('555-5678'); + expect(result.contact.position).toBe('CFO'); + expect(result.contactor.userId).toBe(newContactor.userId); + expect(result.highlightThresholdDays).toBe(20); + }); + }); + + describe('Delete Prospective Sponsor', () => { + it('Fails if user is not a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, guest, organization) + ).rejects.toThrow(new AccessDeniedException('Only heads can delete prospective sponsors')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.deleteProspectiveSponsor('nonexistent-id', head, organization) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is already deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Succeeds and soft deletes prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + const result = await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + expect(result.prospectiveSponsorId).toBe(ps.prospectiveSponsorId); + + const deletedPs = await prisma.prospective_Sponsor.findUnique({ + where: { prospectiveSponsorId: ps.prospectiveSponsorId } + }); + + expect(deletedPs?.dateDeleted).not.toBeNull(); + }); + }); + + describe('Get Prospective Sponsor Tasks', () => { + it('Fails if prospective sponsor does not exist', async () => { + await expect( + ProspectiveSponsorServices.getProspectiveSponsorTasks('nonexistent-id', orgId) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Returns tasks for prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + const task1 = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(2024, 6, 15), + 'Follow up on initial contact' + ); + + const task2 = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(2024, 7, 1), + 'Send proposal' + ); + + const results = await ProspectiveSponsorServices.getProspectiveSponsorTasks(ps.prospectiveSponsorId, orgId); + + expect(results).toHaveLength(2); + expect(results.map((r) => r.sponsorTaskId)).toContain(task1.sponsorTaskId); + expect(results.map((r) => r.sponsorTaskId)).toContain(task2.sponsorTaskId); + }); + }); + + describe('Create Prospective Sponsor Task', () => { + it('Fails if user is not on finance team or a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + guest, + organization, + ps.prospectiveSponsorId, + new Date(), + 'Follow up' + ) + ).rejects.toThrow(new AccessDeniedException('Only finance team members or heads can create prospective sponsor tasks')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + 'nonexistent-id', + new Date(), + 'Follow up' + ) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(), + 'Follow up' + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if assignee does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + new Date(), + 'Follow up', + undefined, + 'nonexistent-user-id' + ) + ).rejects.toThrow(new NotFoundException('User', 'nonexistent-user-id')); + }); + + it('Succeeds and creates task without assignee', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const dueDate = new Date(2024, 6, 15); + const notifyDate = new Date(2024, 6, 10); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + const result = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + dueDate, + 'Follow up on initial contact', + notifyDate + ); + + expect(result.dueDate).toEqual(dueDate); + expect(result.notifyDate).toEqual(notifyDate); + expect(result.notes).toBe('Follow up on initial contact'); + expect(result.assignee).toBeUndefined(); + expect(result.done).toBe(false); + }); + + it('Succeeds and creates task with assignee', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const assignee = await createTestUser(supermanAdmin, orgId); + const dueDate = new Date(2024, 6, 15); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + const result = await ProspectiveSponsorServices.createProspectiveSponsorTask( + head, + organization, + ps.prospectiveSponsorId, + dueDate, + 'Follow up on initial contact', + undefined, + assignee.userId + ); + + expect(result.dueDate).toEqual(dueDate); + expect(result.notes).toBe('Follow up on initial contact'); + expect(result.assignee?.userId).toBe(assignee.userId); + }); + }); + + describe('Accept Prospective Sponsor', () => { + it('Fails if user is not a head', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const guest = await createTestUser(wonderwomanGuest, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + guest, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + ['MONETARY'], + new Date(), + [2024], + false, + 5000 + ) + ).rejects.toThrow(new AccessDeniedException('Only heads can accept prospective sponsors')); + }); + + it('Fails if prospective sponsor does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + 'nonexistent-id', + sponsorTierId, + ['MONETARY'], + new Date(), + [2024], + false, + 5000 + ) + ).rejects.toThrow(new NotFoundException('ProspectiveSponsor', 'nonexistent-id')); + }); + + it('Fails if prospective sponsor is deleted', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, head, organization); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + ['MONETARY'], + new Date(), + [2024], + false, + 5000 + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if prospective sponsor is already accepted (soft-deleted)', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + ['MONETARY'], + new Date(), + [2024], + false, + 5000 + ); + + // After accepting, the prospective sponsor is soft-deleted, so trying to accept again throws DeletedException + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + ['MONETARY'], + new Date(), + [2024, 2025], + true, + 10000 + ) + ).rejects.toThrow(new DeletedException('ProspectiveSponsor', ps.prospectiveSponsorId)); + }); + + it('Fails if sponsor tier does not exist', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + 'nonexistent-tier-id', + ['MONETARY'], + new Date(), + [2024], + false, + 5000 + ) + ).rejects.toThrow(new NotFoundException('SponsorTier', 'nonexistent-tier-id')); + }); + + it('Fails if sponsor with same name already exists', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + + // Create an existing sponsor with the same name + await FinanceServices.createSponsor( + head, + 'Acme Corp', + true, + ['MONETARY'], + new Date(), + [2024], + sponsorTierId, + false, + 'Existing Contact', + [], + organization, + 5000, + undefined, + undefined, + 'test@test.com' + ); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + undefined, + 'contact@test.com' + ); + + await expect( + ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + ['MONETARY'], + new Date(), + [2024], + false, + 5000 + ) + ).rejects.toThrow(new HttpException(400, 'A sponsor with the name "Acme Corp" already exists.')); + }); + + it('Succeeds and creates sponsor from prospective sponsor', async () => { + const head = await createTestUser(batmanAppAdmin, orgId); + const joinDate = new Date(2024, 6, 1); + + const ps = await ProspectiveSponsorServices.createProspectiveSponsor( + head, + organization, + 'Acme Corp', + new Date(), + FirstContactMethod.INBOUND_EMAIL, + 'John Doe', + head.userId, + 14, + 'john@acme.com', + '555-1234', + 'CEO' + ); + + const result = await ProspectiveSponsorServices.acceptProspectiveSponsor( + head, + organization, + ps.prospectiveSponsorId, + sponsorTierId, + ['MONETARY'], + joinDate, + [2024, 2025], + true, + 10000, + 'ACME10', + 'Great partner!' + ); + + // Check that prospective sponsor status is updated + expect(result.status).toBe(ProspectiveSponsorStatus.ACCEPTED); + + // Verify the sponsor was created + const sponsors = await FinanceServices.getAllSponsors(organization); + const createdSponsor = sponsors.find((s) => s.name === 'Acme Corp'); + + expect(createdSponsor).toBeDefined(); + expect(createdSponsor!.name).toBe('Acme Corp'); + expect(createdSponsor!.activeStatus).toBe(true); + expect(createdSponsor!.sponsorValue).toBe(10000); + expect(createdSponsor!.joinDate).toEqual(joinDate); + expect(createdSponsor!.activeYears).toEqual([2024, 2025]); + expect(createdSponsor!.tier!.sponsorTierId).toBe(sponsorTierId); + expect(createdSponsor!.taxExempt).toBe(true); + expect(createdSponsor!.discountCode).toBe('ACME10'); + expect(createdSponsor!.sponsorNotes).toBe('Great partner!'); + expect(createdSponsor!.contact.name).toBe('John Doe'); + expect(createdSponsor!.contact.email).toBe('john@acme.com'); + expect(createdSponsor!.contact.phone).toBe('555-1234'); + expect(createdSponsor!.contact.position).toBe('CEO'); + + // Verify the prospective sponsor was soft-deleted + const allProspectiveSponsors = await ProspectiveSponsorServices.getAllProspectiveSponsors(organization); + const deletedPs = allProspectiveSponsors.find((p) => p.prospectiveSponsorId === ps.prospectiveSponsorId); + expect(deletedPs).toBeUndefined(); // Should not appear in the list since it's soft-deleted + }); + }); +}); diff --git a/src/frontend/src/apis/finance.api.ts b/src/frontend/src/apis/finance.api.ts index 1da8b5de25..1b91a3693a 100644 --- a/src/frontend/src/apis/finance.api.ts +++ b/src/frontend/src/apis/finance.api.ts @@ -769,3 +769,125 @@ export const deleteSponsorTier = (sponsorTierId: string) => { export const editSponsorTier = (sponsorTierId: string, formData: SponsorTierPayload) => { return axios.post(apiUrls.editSponsorTier(sponsorTierId), formData); }; + +/** + * Toggles the done status of a sponsor task + * + * @param sponsorTaskId the id of the sponsor task to toggle + * @returns the updated sponsor task + */ +export const toggleSponsorTaskDone = (sponsorTaskId: string) => { + return axios.post(apiUrls.toggleSponsorTaskDone(sponsorTaskId), { + transformResponse: (data: string) => JSON.parse(data) + }); +}; + +/**************** Prospective Sponsors API ****************/ + +import { ProspectiveSponsor, FirstContactMethod, ProspectiveSponsorStatus, CreateSponsorTask } from 'shared'; +import { prospectiveSponsorTransformer } from './transformers/prospective-sponsor.transformer'; + +export interface CreateProspectiveSponsorPayload { + organizationName: string; + lastContactDate: Date; + firstContactMethod: FirstContactMethod; + contactName: string; + contactorUserId: string; + highlightThresholdDays?: number; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; + notes?: string; + tasks?: CreateSponsorTask[]; +} + +export interface EditProspectiveSponsorPayload { + organizationName: string; + lastContactDate: Date; + status: ProspectiveSponsorStatus; + firstContactMethod: FirstContactMethod; + contactName: string; + contactorUserId: string; + highlightThresholdDays?: number; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; + notes?: string; + tasks?: CreateSponsorTask[]; +} + +export interface AcceptProspectiveSponsorPayload { + sponsorTierId?: string; + valueTypes: string[]; + sponsorValue?: number; + joinDate: Date; + activeYears: number[]; + taxExempt: boolean; + discountCode?: string; + sponsorNotes?: string; + stockDescription?: string; + discountDescription?: string; +} + +/** + * Get all prospective sponsors + */ +export const getAllProspectiveSponsors = () => { + return axios.get(apiUrls.getAllProspectiveSponsors(), { + transformResponse: (data: string) => JSON.parse(data).map(prospectiveSponsorTransformer) + }); +}; + +/** + * Create a new prospective sponsor + */ +export const createProspectiveSponsor = (payload: CreateProspectiveSponsorPayload) => { + return axios.post(apiUrls.createProspectiveSponsor(), payload, { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; + +/** + * Edit a prospective sponsor + */ +export const editProspectiveSponsor = (prospectiveSponsorId: string, payload: EditProspectiveSponsorPayload) => { + return axios.post(apiUrls.editProspectiveSponsor(prospectiveSponsorId), payload, { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; + +/** + * Delete a prospective sponsor + */ +export const deleteProspectiveSponsor = (prospectiveSponsorId: string) => { + return axios.post(apiUrls.deleteProspectiveSponsor(prospectiveSponsorId), { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; + +/** + * Get tasks for a prospective sponsor + */ +export const getProspectiveSponsorTasks = (prospectiveSponsorId: string) => { + return axios.get(apiUrls.getProspectiveSponsorTasks(prospectiveSponsorId), { + transformResponse: (data: string) => JSON.parse(data) + }); +}; + +/** + * Create a task for a prospective sponsor + */ +export const createProspectiveSponsorTask = (prospectiveSponsorId: string, payload: SponsorTaskPayload) => { + return axios.post(apiUrls.createProspectiveSponsorTask(prospectiveSponsorId), payload, { + transformResponse: (data: string) => JSON.parse(data) + }); +}; + +/** + * Accept a prospective sponsor (convert to full sponsor) + */ +export const acceptProspectiveSponsor = (prospectiveSponsorId: string, payload: AcceptProspectiveSponsorPayload) => { + return axios.post(apiUrls.acceptProspectiveSponsor(prospectiveSponsorId), payload, { + transformResponse: (data: string) => prospectiveSponsorTransformer(JSON.parse(data)) + }); +}; diff --git a/src/frontend/src/apis/transformers/prospective-sponsor.transformer.ts b/src/frontend/src/apis/transformers/prospective-sponsor.transformer.ts new file mode 100644 index 0000000000..a416cd452e --- /dev/null +++ b/src/frontend/src/apis/transformers/prospective-sponsor.transformer.ts @@ -0,0 +1,25 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { ProspectiveSponsor } from 'shared'; + +/** + * Transforms a prospective sponsor to ensure deep field transformation of date objects. + * + * @param prospectiveSponsor Incoming prospective sponsor object supplied by the HTTP response. + * @returns Properly transformed prospective sponsor object. + */ +export const prospectiveSponsorTransformer = (prospectiveSponsor: ProspectiveSponsor): ProspectiveSponsor => { + return { + ...prospectiveSponsor, + dateCreated: new Date(prospectiveSponsor.dateCreated), + lastContactDate: new Date(prospectiveSponsor.lastContactDate), + tasks: (prospectiveSponsor.tasks ?? []).map((task) => ({ + ...task, + dueDate: new Date(task.dueDate), + notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined + })) + }; +}; diff --git a/src/frontend/src/components/NERDataGrid.tsx b/src/frontend/src/components/NERDataGrid.tsx index b8aca1b45e..8ffc4040e0 100644 --- a/src/frontend/src/components/NERDataGrid.tsx +++ b/src/frontend/src/components/NERDataGrid.tsx @@ -25,6 +25,10 @@ interface NERDataGridProps { rowHeight?: number; paperSx?: SxProps; canEditRow?: (row: MapRowResult) => boolean; + // optional function to add custom CSS class names to rows + getRowClassName?: (row: MapRowResult) => string; + // optional custom sx styles for the DataGrid + dataGridSx?: SxProps; } function NERDataGrid({ @@ -42,7 +46,9 @@ function NERDataGrid({ headerHeight = 56, rowHeight = 52, paperSx, - canEditRow + canEditRow, + getRowClassName: customGetRowClassName, + dataGridSx }: NERDataGridProps) { const [searchTerm, setSearchTerm] = useState(''); const [pageSize, setPageSize] = useState(pageSizeDefault); @@ -125,7 +131,9 @@ function NERDataGrid({ getRowClassName={(params) => { const row = params.row as MapRowResult; const editable = canEditRow ? canEditRow(row) : true; - return editable ? 'editable-row' : 'non-editable-row'; + const editableClass = editable ? 'editable-row' : 'non-editable-row'; + const customClass = customGetRowClassName ? customGetRowClassName(row) : ''; + return `${editableClass} ${customClass}`.trim(); }} sx={{ height: '100%', @@ -150,7 +158,8 @@ function NERDataGrid({ }, '& .MuiDataGrid-columnSeparator': { display: 'none' - } + }, + ...dataGridSx }} /> diff --git a/src/frontend/src/hooks/finance.hooks.ts b/src/frontend/src/hooks/finance.hooks.ts index 9b8097c69b..e812b04fc8 100644 --- a/src/frontend/src/hooks/finance.hooks.ts +++ b/src/frontend/src/hooks/finance.hooks.ts @@ -67,7 +67,18 @@ import { editIndexCode, getCurrentUserAssignedReimbursementRequests, assignMemberToRR, - setTaxExemptStatus + setTaxExemptStatus, + toggleSponsorTaskDone, + getAllProspectiveSponsors, + createProspectiveSponsor, + editProspectiveSponsor, + deleteProspectiveSponsor, + getProspectiveSponsorTasks, + createProspectiveSponsorTask, + acceptProspectiveSponsor, + CreateProspectiveSponsorPayload, + EditProspectiveSponsorPayload, + AcceptProspectiveSponsorPayload } from '../apis/finance.api'; import { IndexCode, @@ -87,7 +98,8 @@ import { ReimbursementRequestComment, ReimbursementRequestData, SpendingBarData, - CreateSponsorTask + CreateSponsorTask, + ProspectiveSponsor } from 'shared'; import { fullNamePipe } from '../utils/pipes'; @@ -158,15 +170,21 @@ export interface MarkDeliveredRequestPayload { export interface SponsorPayload { name: string; activeStatus: boolean; - sponsorValue: number; + valueTypes: string[]; + sponsorValue?: number; joinDate: Date; activeYears: number[]; - sponsorTierId: string; + sponsorTierId?: string; taxExempt: boolean; - sponsorContact: string; + contactName: string; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; sponsorTasks: CreateSponsorTask[]; discountCode?: string; sponsorNotes?: string; + stockDescription?: string; + discountDescription?: string; } interface EditSponsorPayload extends SponsorPayload { @@ -184,6 +202,7 @@ export interface SponsorTaskPayload { notes: string; notifyDate?: Date; assigneeUserId?: string; + done?: boolean; } export interface EditSponsorTaskPayload { @@ -1345,3 +1364,156 @@ export const useEditSponsorTier = (sponsorTierId: string) => { } ); }; + +/** + * Custom React Hook to toggle a sponsor task done status + * + * @returns the updated sponsor task + */ +export const useToggleSponsorTaskDone = () => { + const queryClient = useQueryClient(); + return useMutation( + ['sponsor-task', 'toggle-done'], + async (sponsorTaskId: string) => { + const { data } = await toggleSponsorTaskDone(sponsorTaskId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['sponsor-task']); + queryClient.invalidateQueries(['prospective-sponsor']); + } + } + ); +}; + +/**************** Prospective Sponsors Hooks ****************/ + +/** + * Custom React Hook to get all prospective sponsors + */ +export const useAllProspectiveSponsors = () => { + return useQuery(['prospective-sponsors'], async () => { + const { data } = await getAllProspectiveSponsors(); + return data; + }); +}; + +/** + * Custom React Hook to create a prospective sponsor + */ +export const useCreateProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'create'], + async (payload: CreateProspectiveSponsorPayload) => { + const { data } = await createProspectiveSponsor(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +interface EditProspectiveSponsorMutationPayload extends EditProspectiveSponsorPayload { + prospectiveSponsorId: string; +} + +/** + * Custom React Hook to edit a prospective sponsor + */ +export const useEditProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'edit'], + async ({ prospectiveSponsorId, ...payload }) => { + const { data } = await editProspectiveSponsor(prospectiveSponsorId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +/** + * Custom React Hook to delete a prospective sponsor + */ +export const useDeleteProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'delete'], + async (prospectiveSponsorId: string) => { + const { data } = await deleteProspectiveSponsor(prospectiveSponsorId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +/** + * Custom React Hook to get tasks for a prospective sponsor + */ +export const useProspectiveSponsorTasks = (prospectiveSponsorId: string) => { + return useQuery( + ['prospective-sponsor-tasks', prospectiveSponsorId], + async () => { + const { data } = await getProspectiveSponsorTasks(prospectiveSponsorId); + return data; + }, + { enabled: !!prospectiveSponsorId } + ); +}; + +/** + * Custom React Hook to create a task for a prospective sponsor + */ +export const useCreateProspectiveSponsorTask = (prospectiveSponsorId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor-task', 'create'], + async (payload: SponsorTaskPayload) => { + const { data } = await createProspectiveSponsorTask(prospectiveSponsorId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsor-tasks', prospectiveSponsorId]); + queryClient.invalidateQueries(['prospective-sponsors']); + } + } + ); +}; + +interface AcceptProspectiveSponsorMutationPayload extends AcceptProspectiveSponsorPayload { + prospectiveSponsorId: string; +} + +/** + * Custom React Hook to accept a prospective sponsor (convert to full sponsor) + */ +export const useAcceptProspectiveSponsor = () => { + const queryClient = useQueryClient(); + return useMutation( + ['prospective-sponsor', 'accept'], + async ({ prospectiveSponsorId, ...payload }) => { + const { data } = await acceptProspectiveSponsor(prospectiveSponsorId, payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['prospective-sponsors']); + queryClient.invalidateQueries(['sponsor']); + } + } + ); +}; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/AcceptProspectiveSponsorModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/AcceptProspectiveSponsorModal.tsx new file mode 100644 index 0000000000..9e165920d7 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/AcceptProspectiveSponsorModal.tsx @@ -0,0 +1,395 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useEffect, useRef, useState } from 'react'; +import { useForm, Controller, useWatch } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { + Box, + FormControl, + FormHelperText, + Typography, + Select, + MenuItem, + Checkbox, + Autocomplete, + TextField, + Chip +} from '@mui/material'; +import { DatePicker } from '@mui/x-date-pickers'; +import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; +import { ProspectiveSponsor, SponsorValueType } from 'shared'; +import NERModal from '../../../components/NERModal'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { useAcceptProspectiveSponsor, useGetAllSponsorTiers } from '../../../hooks/finance.hooks'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { useToast } from '../../../hooks/toasts.hooks'; + +interface AcceptProspectiveSponsorModalProps { + handleClose: () => void; + prospectiveSponsor: ProspectiveSponsor; + showModal: boolean; +} + +interface AcceptFormInputs { + sponsorTierId?: string; + valueTypes: string[]; + sponsorValue?: number; + joinDate: Date; + activeYears: number[]; + taxExempt: boolean; + discountCode?: string; + sponsorNotes?: string; + stockDescription?: string; + discountDescription?: string; +} + +const VALUE_TYPE_OPTIONS = [ + { value: SponsorValueType.MONETARY, label: 'Monetary' }, + { value: SponsorValueType.STOCK, label: 'Stock/Parts/Services' }, + { value: SponsorValueType.DISCOUNT, label: 'Discount' } +]; + +const getYears = (startYear = 1950) => { + const currentYear = new Date().getFullYear(); + const years = []; + for (let y = currentYear; y >= startYear; y--) { + years.push(y); + } + return years; +}; + +const acceptSchema = yup.object().shape({ + sponsorTierId: yup.string().optional(), + valueTypes: yup + .array() + .of(yup.string().required()) + .min(1, 'At least one value type is required') + .required('Value types are required'), + sponsorValue: yup + .number() + .typeError('Must be a number') + .when('valueTypes', { + is: (types: string[]) => types?.includes(SponsorValueType.MONETARY), + then: (schema) => schema.required('Sponsor value is required for monetary sponsors'), + otherwise: (schema) => schema.optional().nullable() + }), + joinDate: yup.date().required('Join date is required'), + activeYears: yup.array().of(yup.number().required()).min(1, 'At least one active year is required').required(), + taxExempt: yup.boolean().required(), + discountCode: yup.string().optional(), + sponsorNotes: yup.string().optional(), + stockDescription: yup.string().optional(), + discountDescription: yup.string().optional() +}); + +const AcceptProspectiveSponsorModal = ({ + handleClose, + prospectiveSponsor, + showModal +}: AcceptProspectiveSponsorModalProps) => { + const { isLoading, isError, error, mutateAsync } = useAcceptProspectiveSponsor(); + const { + isLoading: tiersLoading, + isError: tiersIsError, + error: tiersError, + data: sponsorTiers + } = useGetAllSponsorTiers(); + const toast = useToast(); + + const [datePickerOpen, setDatePickerOpen] = useState(false); + const yearsOptions = getYears(); + + const { + control, + handleSubmit, + setValue, + formState: { errors } + } = useForm({ + resolver: yupResolver(acceptSchema), + defaultValues: { + sponsorTierId: '', + valueTypes: ['MONETARY'], + sponsorValue: 0, + joinDate: new Date(), + activeYears: [new Date().getFullYear()], + taxExempt: false, + discountCode: '', + sponsorNotes: '', + stockDescription: '', + discountDescription: '' + } + }); + + const watchedValueTypes: string[] = useWatch({ control, name: 'valueTypes' }) ?? []; + const isMonetary = watchedValueTypes.includes(SponsorValueType.MONETARY); + const isStock = watchedValueTypes.includes(SponsorValueType.STOCK); + const isDiscount = watchedValueTypes.includes(SponsorValueType.DISCOUNT); + + const watchedSponsorValue: number | undefined = useWatch({ control, name: 'sponsorValue' }); + const tierManuallySet = useRef(false); + + useEffect(() => { + if (tierManuallySet.current || !sponsorTiers || sponsorTiers.length === 0) return; + const value = watchedSponsorValue ?? 0; + const sorted = [...sponsorTiers].sort((a, b) => b.minSupportValue - a.minSupportValue); + const bestTier = sorted.find((t) => value >= t.minSupportValue); + if (bestTier) { + setValue('sponsorTierId', bestTier.sponsorTierId); + } + }, [watchedSponsorValue, sponsorTiers, setValue]); + + if (isError) return ; + if (tiersIsError) return ; + if (isLoading || tiersLoading || !sponsorTiers) return ; + + const onSubmit = async (formData: AcceptFormInputs) => { + try { + await mutateAsync({ + prospectiveSponsorId: prospectiveSponsor.prospectiveSponsorId, + sponsorTierId: formData.sponsorTierId, + valueTypes: formData.valueTypes, + sponsorValue: formData.sponsorValue, + joinDate: formData.joinDate, + activeYears: formData.activeYears, + taxExempt: formData.taxExempt, + discountCode: formData.discountCode || undefined, + sponsorNotes: formData.sponsorNotes || undefined, + stockDescription: formData.stockDescription || undefined, + discountDescription: formData.discountDescription || undefined + }); + toast.success(`${prospectiveSponsor.organizationName} has been added as a sponsor! View them in the Sponsors tab.`); + handleClose(); + } catch (err) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }; + + return ( + + + + + Sponsor Tier: + + ( + + )} + /> + {errors.sponsorTierId?.message} + + + + + Value Types:* + + ( + option.label} + value={VALUE_TYPE_OPTIONS.filter((opt) => value?.includes(opt.value))} + onChange={(_, data) => onChange(data.map((d) => d.value))} + renderInput={(params) => ( + + )} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } + isOptionEqualToValue={(option, val) => option.value === val.value} + disableCloseOnSelect + /> + )} + /> + {errors.valueTypes?.message} + + + + + Sponsor Value:{isMonetary ? '*' : ''} + + } + errorMessage={errors.sponsorValue} + /> + + + {isStock && ( + + + Stock/Parts/Services Description: + + + + )} + + {isDiscount && ( + + + Discount Description: + + + + )} + + + + Join Date:* + + ( + setDatePickerOpen(false)} + onOpen={() => setDatePickerOpen(true)} + onChange={(newValue) => onChange(newValue ?? new Date())} + slotProps={{ + textField: { + size: 'small', + error: !!errors.joinDate, + helperText: errors.joinDate?.message, + onClick: () => setDatePickerOpen(true) + } + }} + /> + )} + /> + + + + + Active Years:* + + ( + option.toString()} + onChange={(_, data) => field.onChange(data)} + size="small" + renderInput={(params) => ( + + )} + isOptionEqualToValue={(option, value) => option === value} + disableCloseOnSelect + /> + )} + /> + {errors.activeYears?.message} + + + + + ( + onChange(e.target.checked)} + sx={{ p: 0 }} + /> + )} + /> + + Tax Exempt + + + + + + + Discount Code: + + + + + + + Notes: + + + + + + ); +}; + +export default AcceptProspectiveSponsorModal; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/CreateProspectiveSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateProspectiveSponsorPage.tsx new file mode 100644 index 0000000000..36c56f2e26 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateProspectiveSponsorPage.tsx @@ -0,0 +1,110 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box } from '@mui/system'; +import { useState } from 'react'; +import { useToast } from '../../../hooks/toasts.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useCreateProspectiveSponsor } from '../../../hooks/finance.hooks'; +import SidePage from './SidePagePopup'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import { + ProspectiveSponsorForm, + ProspectiveSponsorFormInputs, + prospectiveSponsorSchema +} from './ProspectiveSponsorForm'; +import { FirstContactMethod } from 'shared'; + +interface CreateProspectiveSponsorPageProps { + showPage: boolean; + handleClose: () => void; +} + +const CreateProspectiveSponsorPage = ({ showPage, handleClose }: CreateProspectiveSponsorPageProps) => { + const toast = useToast(); + const { isLoading, mutateAsync } = useCreateProspectiveSponsor(); + + const { + handleSubmit, + control, + formState: { errors } + } = useForm({ + resolver: yupResolver(prospectiveSponsorSchema), + defaultValues: { + organizationName: '', + lastContactDate: new Date(), + firstContactMethod: '' as FirstContactMethod, + contactName: '', + contactorUserId: '', + highlightThresholdDays: 10, + contactEmail: '', + contactPhone: '', + contactPosition: '', + notes: '', + tasks: [] + } + }); + + const [submitError, setSubmitError] = useState(null); + + if (isLoading) return ; + + const onFormSubmit = async (formData: ProspectiveSponsorFormInputs) => { + try { + setSubmitError(null); + await mutateAsync({ + organizationName: formData.organizationName, + lastContactDate: formData.lastContactDate, + firstContactMethod: formData.firstContactMethod, + contactName: formData.contactName, + contactorUserId: formData.contactorUserId, + highlightThresholdDays: formData.highlightThresholdDays, + contactEmail: formData.contactEmail || undefined, + contactPhone: formData.contactPhone || undefined, + contactPosition: formData.contactPosition || undefined, + notes: formData.notes || undefined, + tasks: formData.tasks + }); + toast.success('Prospective sponsor created successfully!'); + handleClose(); + } catch (err: unknown) { + if (err instanceof Error) { + toast.error(err.message); + setSubmitError(err.message); + } + } + }; + + return ( + + + {submitError && ( + + {submitError} + + )} + + + CLOSE + + + Submit + + + + } + /> + ); +}; + +export default CreateProspectiveSponsorPage; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx index 8c958e8ae8..821bce37a3 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/CreateSponsorPage.tsx @@ -8,6 +8,7 @@ import SidePage from './SidePagePopup'; import NERFailButton from '../../../components/NERFailButton'; import NERSuccessButton from '../../../components/NERSuccessButton'; import { useState } from 'react'; +import { useToast } from '../../../hooks/toasts.hooks'; interface CreateSponsorPageProps { showPage: boolean; @@ -15,25 +16,33 @@ interface CreateSponsorPageProps { } const CreateSponsorPage = ({ showPage, handleClose }: CreateSponsorPageProps) => { + const toast = useToast(); const { isLoading, mutateAsync } = useCreateSponsor(); const { handleSubmit, control, + setValue, formState: { errors } } = useForm({ resolver: yupResolver(sponsorSchema), defaultValues: { name: '', activeStatus: undefined, + valueTypes: ['MONETARY'], sponsorValue: 0, - joinDate: undefined, + joinDate: new Date(), activeYears: [], sponsorTierId: '', - sponsorContact: '', + contactName: '', + contactEmail: '', + contactPhone: '', + contactPosition: '', taxExempt: false, discountCode: '', sponsorNotes: '', + stockDescription: '', + discountDescription: '', sponsorTasks: [] } }); @@ -45,9 +54,11 @@ const CreateSponsorPage = ({ showPage, handleClose }: CreateSponsorPageProps) => try { setSubmitError(null); await mutateAsync({ ...formData }); + toast.success('Sponsor created successfully!'); handleClose(); } catch (err: unknown) { if (err instanceof Error) { + toast.error(err.message); setSubmitError(err.message); } } @@ -60,7 +71,7 @@ const CreateSponsorPage = ({ showPage, handleClose }: CreateSponsorPageProps) => title="Add Sponsor" component={ - + {submitError && ( {submitError} diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteProspectiveSponsorModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteProspectiveSponsorModal.tsx new file mode 100644 index 0000000000..a48604e037 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteProspectiveSponsorModal.tsx @@ -0,0 +1,57 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useDeleteProspectiveSponsor } from '../../../hooks/finance.hooks'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { ProspectiveSponsor } from 'shared'; +import NERModal from '../../../components/NERModal'; +import { Typography } from '@mui/material'; +import { useToast } from '../../../hooks/toasts.hooks'; + +interface DeleteProspectiveSponsorModalProps { + handleClose: () => void; + prospectiveSponsor: ProspectiveSponsor; + showModal: boolean; +} + +const DeleteProspectiveSponsorModal = ({ + handleClose, + prospectiveSponsor, + showModal +}: DeleteProspectiveSponsorModalProps) => { + const toast = useToast(); + const { isLoading, isError, error, mutateAsync } = useDeleteProspectiveSponsor(); + + if (isError) return ; + if (isLoading) return ; + + return ( + { + try { + await mutateAsync(prospectiveSponsor.prospectiveSponsorId); + toast.success(`Prospective sponsor "${prospectiveSponsor.organizationName}" deleted successfully!`); + handleClose(); + } catch (err: unknown) { + if (err instanceof Error) { + toast.error(err.message); + } + } + }} + > + + Are you sure you want to delete the prospective sponsor {prospectiveSponsor.organizationName}? + + This action cannot be undone! + + ); +}; + +export default DeleteProspectiveSponsorModal; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsor.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsor.tsx index 780cd42d35..ae5143999d 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsor.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsor.tsx @@ -4,6 +4,7 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import { Sponsor } from 'shared'; import NERModal from '../../../components/NERModal'; import { Typography } from '@mui/material'; +import { useToast } from '../../../hooks/toasts.hooks'; interface DeleteSponsorProps { handleClose: () => void; @@ -12,6 +13,7 @@ interface DeleteSponsorProps { } const DeleteSponsorModal = ({ handleClose, sponsor, showModal }: DeleteSponsorProps) => { + const toast = useToast(); const { isLoading, isError, error, mutateAsync } = useDeleteSponsor(sponsor.sponsorId); if (isError) return ; @@ -23,9 +25,16 @@ const DeleteSponsorModal = ({ handleClose, sponsor, showModal }: DeleteSponsorPr title="Warning!" onHide={handleClose} submitText="Delete" - onSubmit={() => { - mutateAsync(); - handleClose(); + onSubmit={async () => { + try { + await mutateAsync(); + toast.success(`Sponsor "${sponsor.name}" deleted successfully!`); + handleClose(); + } catch (err: unknown) { + if (err instanceof Error) { + toast.error(err.message); + } + } }} > diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsorTaskModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsorTaskModal.tsx index a00217a96d..c05c14e88c 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsorTaskModal.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/DeleteSponsorTaskModal.tsx @@ -4,6 +4,7 @@ import LoadingIndicator from '../../../components/LoadingIndicator'; import { SponsorTask } from 'shared'; import NERModal from '../../../components/NERModal'; import { Typography } from '@mui/material'; +import { useToast } from '../../../hooks/toasts.hooks'; interface DeleteSponsorTaskModalProps { handleClose: () => void; @@ -11,6 +12,7 @@ interface DeleteSponsorTaskModalProps { } const DeleteSponsorModal = ({ handleClose, sponsorTask }: DeleteSponsorTaskModalProps) => { + const toast = useToast(); const { isLoading, isError, error, mutateAsync } = useDeleteSponsorTask(); if (isError) return ; @@ -22,9 +24,16 @@ const DeleteSponsorModal = ({ handleClose, sponsorTask }: DeleteSponsorTaskModal title="Warning!" onHide={handleClose} submitText="Delete" - onSubmit={() => { - mutateAsync({ sponsorTaskId: sponsorTask.sponsorTaskId }); - handleClose(); + onSubmit={async () => { + try { + await mutateAsync({ sponsorTaskId: sponsorTask.sponsorTaskId }); + toast.success('Task deleted successfully!'); + handleClose(); + } catch (err: unknown) { + if (err instanceof Error) { + toast.error(err.message); + } + } }} > Are you sure you want to delete this sponsor task? diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/EditProspectiveSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/EditProspectiveSponsorPage.tsx new file mode 100644 index 0000000000..e13642eb73 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/EditProspectiveSponsorPage.tsx @@ -0,0 +1,134 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { Box } from '@mui/system'; +import { useState } from 'react'; +import { useToast } from '../../../hooks/toasts.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { useEditProspectiveSponsor } from '../../../hooks/finance.hooks'; +import SidePage from './SidePagePopup'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; +import { + ProspectiveSponsorForm, + ProspectiveSponsorFormInputs, + prospectiveSponsorSchema +} from './ProspectiveSponsorForm'; +import { ProspectiveSponsor } from 'shared'; + +interface EditProspectiveSponsorPageProps { + showPage: boolean; + handleClose: () => void; + prospectiveSponsor: ProspectiveSponsor; +} + +const EditProspectiveSponsorPage = ({ + showPage, + handleClose, + prospectiveSponsor +}: EditProspectiveSponsorPageProps) => { + const toast = useToast(); + const { isLoading, mutateAsync } = useEditProspectiveSponsor(); + + const defaultTasks = ( + prospectiveSponsor.tasks?.map((task) => ({ + sponsorTaskId: task.sponsorTaskId, + dueDate: new Date(task.dueDate), + notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined, + assigneeUserId: task.assignee?.userId, + notes: task.notes, + done: task.done + })) ?? [] + ).sort((a, b) => Number(a.done) - Number(b.done)); + + const { + handleSubmit, + control, + formState: { errors } + } = useForm({ + resolver: yupResolver(prospectiveSponsorSchema), + defaultValues: { + organizationName: prospectiveSponsor.organizationName, + lastContactDate: prospectiveSponsor.lastContactDate, + firstContactMethod: prospectiveSponsor.firstContactMethod, + contactName: prospectiveSponsor.contact.name, + contactorUserId: prospectiveSponsor.contactor.userId, + highlightThresholdDays: prospectiveSponsor.highlightThresholdDays, + contactEmail: prospectiveSponsor.contact.email ?? '', + contactPhone: prospectiveSponsor.contact.phone ?? '', + contactPosition: prospectiveSponsor.contact.position ?? '', + notes: prospectiveSponsor.notes ?? '', + status: prospectiveSponsor.status, + tasks: defaultTasks + } + }); + + const [submitError, setSubmitError] = useState(null); + + if (isLoading) return ; + + const onFormSubmit = async (formData: ProspectiveSponsorFormInputs) => { + try { + setSubmitError(null); + await mutateAsync({ + prospectiveSponsorId: prospectiveSponsor.prospectiveSponsorId, + organizationName: formData.organizationName, + lastContactDate: formData.lastContactDate, + status: formData.status!, + firstContactMethod: formData.firstContactMethod, + contactName: formData.contactName, + contactorUserId: formData.contactorUserId, + highlightThresholdDays: formData.highlightThresholdDays, + contactEmail: formData.contactEmail || undefined, + contactPhone: formData.contactPhone || undefined, + contactPosition: formData.contactPosition || undefined, + notes: formData.notes || undefined, + tasks: formData.tasks + }); + toast.success('Prospective sponsor updated successfully!'); + handleClose(); + } catch (err: unknown) { + if (err instanceof Error) { + toast.error(err.message); + setSubmitError(err.message); + } + } + }; + + return ( + + + {submitError && ( + + {submitError} + + )} + + + CLOSE + + + SUBMIT + + + + } + /> + ); +}; + +export default EditProspectiveSponsorPage; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx index a9f2a63eeb..a5e3a26dd0 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/EditSponsorPage.tsx @@ -10,6 +10,7 @@ import NERSuccessButton from '../../../components/NERSuccessButton'; import { useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { useState } from 'react'; +import { useToast } from '../../../hooks/toasts.hooks'; interface EditSponsorPageProps { showPage: boolean; @@ -18,34 +19,44 @@ interface EditSponsorPageProps { } const EditSponsorPage = ({ showPage, handleClose, sponsor }: EditSponsorPageProps) => { + const toast = useToast(); const { isLoading, mutateAsync } = useEditSponsor(); - const defaultSponsorTasks: CreateSponsorTask[] = + const defaultSponsorTasks: CreateSponsorTask[] = ( sponsor.sponsorTasks?.map((task) => ({ sponsorTaskId: task.sponsorTaskId, dueDate: new Date(task.dueDate), notifyDate: task.notifyDate ? new Date(task.notifyDate) : undefined, assigneeUserId: task.assignee?.userId ?? undefined, - notes: task.notes - })) ?? []; + notes: task.notes, + done: task.done + })) ?? [] + ).sort((a, b) => Number(a.done) - Number(b.done)); const { handleSubmit, control, + setValue, formState: { errors } } = useForm({ resolver: yupResolver(sponsorSchema), defaultValues: { name: sponsor.name, activeStatus: sponsor.activeStatus, + valueTypes: sponsor.valueTypes ?? ['MONETARY'], sponsorValue: sponsor.sponsorValue, joinDate: sponsor.joinDate, activeYears: sponsor.activeYears, - sponsorTierId: sponsor.tier.sponsorTierId, - sponsorContact: sponsor.sponsorContact, + sponsorTierId: sponsor.tier?.sponsorTierId ?? '', + contactName: sponsor.contact.name, + contactEmail: sponsor.contact.email ?? undefined, + contactPhone: sponsor.contact.phone ?? undefined, + contactPosition: sponsor.contact.position ?? undefined, taxExempt: sponsor.taxExempt, discountCode: sponsor.discountCode ?? undefined, sponsorNotes: sponsor.sponsorNotes ?? undefined, + stockDescription: sponsor.stockDescription ?? undefined, + discountDescription: sponsor.discountDescription ?? undefined, sponsorTasks: defaultSponsorTasks } }); @@ -56,9 +67,11 @@ const EditSponsorPage = ({ showPage, handleClose, sponsor }: EditSponsorPageProp try { setSubmitError(null); await mutateAsync({ sponsorId: sponsor.sponsorId, ...formData }); + toast.success('Sponsor updated successfully!'); handleClose(); } catch (err: unknown) { if (err instanceof Error) { + toast.error(err.message); setSubmitError(err.message); } } @@ -71,7 +84,7 @@ const EditSponsorPage = ({ showPage, handleClose, sponsor }: EditSponsorPageProp title="Edit Sponsor" component={ - + {submitError && ( {submitError} diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorForm.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorForm.tsx new file mode 100644 index 0000000000..8ee4e802ac --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorForm.tsx @@ -0,0 +1,398 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import * as yup from 'yup'; +import { Control, Controller, FieldErrors, FieldValues, useFieldArray } from 'react-hook-form'; +import { + FormControl, + Grid, + FormHelperText, + Button, + MenuItem, + Select, + Typography, + Box, + Tooltip +} from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import { useTheme } from '@mui/system'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { DatePicker } from '@mui/x-date-pickers'; +import { useAllMembers } from '../../../hooks/users.hooks'; +import React, { useState } from 'react'; +import { AddCircle } from '@mui/icons-material'; +import NERAutocomplete from '../../../components/NERAutocomplete'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import SponsorTaskCard from './SponsorTaskCard'; +import { FirstContactMethod, ProspectiveSponsor, ProspectiveSponsorStatus } from 'shared'; + +export interface ProspectiveSponsorFormInputs { + organizationName: string; + lastContactDate: Date; + firstContactMethod: FirstContactMethod; + contactName: string; + contactorUserId: string; + highlightThresholdDays?: number; + contactEmail?: string; + contactPhone?: string; + contactPosition?: string; + notes?: string; + status?: ProspectiveSponsorStatus; + tasks: { + sponsorTaskId?: string; + dueDate: Date; + notifyDate?: Date; + assigneeUserId?: string; + notes: string; + done?: boolean; + }[]; +} + +interface ProspectiveSponsorFormProps { + control: Control; + errors: FieldErrors; + defaultValues?: ProspectiveSponsor; + isEditMode?: boolean; +} + +const firstContactMethodDisplayNames: Record = { + [FirstContactMethod.INBOUND_FORM]: 'Inbound Form', + [FirstContactMethod.INBOUND_EMAIL]: 'Inbound Email', + [FirstContactMethod.OUTBOUND_EMAIL]: 'Outbound Email', + [FirstContactMethod.OTHER]: 'Other' +}; + +const statusDisplayNames: Record = { + [ProspectiveSponsorStatus.IN_PROGRESS]: 'In Progress', + [ProspectiveSponsorStatus.DECLINED]: 'Declined', + [ProspectiveSponsorStatus.NOT_IN_CONTACT]: 'Not In Contact', + [ProspectiveSponsorStatus.NO_RESPONSE]: 'No Response', + [ProspectiveSponsorStatus.ACCEPTED]: 'Accepted' +}; + +export const prospectiveSponsorSchema = yup.object().shape({ + organizationName: yup.string().required('Organization name is required'), + lastContactDate: yup.date().required('Last contact date is required'), + firstContactMethod: yup + .string() + .oneOf(Object.values(FirstContactMethod), 'Please select a contact method') + .required('Please select how first contact was made'), + contactName: yup.string().required('Contact name is required'), + contactorUserId: yup.string().required('Contactor is required'), + highlightThresholdDays: yup.number().positive('Must be positive').optional(), + contactEmail: yup + .string() + .matches(/^$|^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/, 'Please enter a valid email address') + .optional() + .test('email-or-phone', 'At least one of email or phone is required', function (value) { + return !!value || !!this.parent.contactPhone; + }), + contactPhone: yup + .string() + .matches(/^$|^[+]?[\d\s().-]{7,20}$/, 'Please enter a valid phone number') + .optional() + .test('phone-or-email', 'At least one of email or phone is required', function (value) { + return !!value || !!this.parent.contactEmail; + }), + contactPosition: yup.string().optional(), + notes: yup.string().trim().optional(), + status: yup.string().oneOf(Object.values(ProspectiveSponsorStatus)).optional(), + tasks: yup + .array() + .of( + yup.object().shape({ + sponsorTaskId: yup.string().optional(), + dueDate: yup.date().required('Due date is required'), + notifyDate: yup.date().optional(), + assigneeUserId: yup.string().optional(), + notes: yup.string().required('Notes are required'), + done: yup.boolean().optional() + }) + ) + .required() +}); + +export const ProspectiveSponsorForm: React.FC = ({ + control, + errors, + defaultValues, + isEditMode = false +}: ProspectiveSponsorFormProps) => { + const theme = useTheme(); + + const [datePickerOpenLastContact, setDatePickerOpenLastContact] = useState(false); + + const { isLoading: membersLoading, isError: membersIsError, error: membersError, data: members } = useAllMembers(); + + const { fields, append, remove } = useFieldArray({ + control, + name: 'tasks' + }); + + if (membersIsError) return ; + if (membersLoading || !members) return ; + + return ( + + + + + Organization Name:* + + + {errors.organizationName?.message} + + + + + + + Contactor:* + + ( + m.userId === value) ? { label: members.find((m) => m.userId === value)!.firstName + ' ' + members.find((m) => m.userId === value)!.lastName, id: value } : null} + onChange={(_event, newValue) => onChange(newValue ? newValue.id : '')} + options={members.map((m) => ({ label: m.firstName + ' ' + m.lastName, id: m.userId }))} + size="small" + placeholder="Select Contactor" + /> + )} + /> + {errors.contactorUserId?.message} + + + + + + + Last Contact Date:* + + ( + setDatePickerOpenLastContact(false)} + onOpen={() => setDatePickerOpenLastContact(true)} + onChange={(newValue) => onChange(newValue ?? new Date())} + slotProps={{ + textField: { + error: !!errors.lastContactDate, + helperText: errors.lastContactDate?.message, + onClick: () => setDatePickerOpenLastContact(true) + } + }} + /> + )} + /> + + + + + + + + First Contact Method:* + + + + + + ( + + )} + /> + {errors.firstContactMethod?.message} + + + + {isEditMode && ( + + + + Status:* + + ( + + )} + /> + {errors.status?.message} + + + )} + + + + + + Highlight Threshold (Days): + + + + + + + {errors.highlightThresholdDays?.message} + + + + + + Contact Information + + + + + + + Contact Name:* + + + {errors.contactName?.message} + + + + + + + Contact Email: + + + {errors.contactEmail?.message} + + + + + + + Contact Phone: + + + {errors.contactPhone?.message} + + + + + + + Contact Position: + + + {errors.contactPosition?.message} + + + + + + + Notes: + + + {errors.notes?.message} + + + + + + + Tasks: + + {fields.map((item, index) => ( + + } + errors={errors as unknown as FieldErrors} + fieldPrefix={`tasks.${index}`} + members={members} + onRemove={() => remove(index)} + showDoneCheckbox + isExistingTask={!!defaultValues?.tasks?.[index]?.sponsorTaskId} + defaultAssigneeName={ + defaultValues?.tasks?.[index]?.assignee + ? `${defaultValues.tasks[index].assignee!.firstName} ${defaultValues.tasks[index].assignee!.lastName}` + : undefined + } + /> + + ))} + + + + + ); +}; + +export default prospectiveSponsorSchema; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx new file mode 100644 index 0000000000..350583ace2 --- /dev/null +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx @@ -0,0 +1,26 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import React from 'react'; +import { ProspectiveSponsor } from 'shared'; +import { useCreateProspectiveSponsorTask, useProspectiveSponsorTasks } from '../../../hooks/finance.hooks'; +import SponsorTasksModal from './SponsorTasksModal'; + +interface ProspectiveSponsorTasksModalProps { + onClose: () => void; + prospectiveSponsor: ProspectiveSponsor; +} + +const ProspectiveSponsorTasksModal: React.FC = ({ + onClose, + prospectiveSponsor +}) => { + const { data: tasks } = useProspectiveSponsorTasks(prospectiveSponsor.prospectiveSponsorId); + const { mutate: createTask } = useCreateProspectiveSponsorTask(prospectiveSponsor.prospectiveSponsorId); + + return ; +}; + +export default ProspectiveSponsorTasksModal; diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/SidePagePopup.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/SidePagePopup.tsx index 0b8e40e3f0..4238a245ca 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/SidePagePopup.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/SidePagePopup.tsx @@ -27,7 +27,7 @@ const SidePage: React.FC = ({ showPage, handleClose, title, compo {title} - {component} + {component} ); diff --git a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx index f0c749854d..e1f34ec468 100644 --- a/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx +++ b/src/frontend/src/pages/FinancePage/FinanceComponents/SponsorForm.tsx @@ -2,33 +2,34 @@ import * as yup from 'yup'; import { SponsorPayload, useGetAllSponsorTiers } from '../../../hooks/finance.hooks'; import ErrorPage from '../../ErrorPage'; import LoadingIndicator from '../../../components/LoadingIndicator'; -import { Control, Controller, FieldErrors, useFieldArray } from 'react-hook-form'; +import { Control, Controller, FieldErrors, FieldValues, UseFormSetValue, useFieldArray, useWatch } from 'react-hook-form'; import { FormControl, Grid, FormHelperText, - IconButton, + Button, MenuItem, Select, Typography, Checkbox, Autocomplete, - TextField + TextField, + Chip } from '@mui/material'; import ReactHookTextField from '../../../components/ReactHookTextField'; import { DatePicker } from '@mui/x-date-pickers'; import { useAllMembers } from '../../../hooks/users.hooks'; -import React, { useState } from 'react'; -import { Box, useTheme } from '@mui/system'; -import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; -import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import React, { useEffect, useRef, useState } from 'react'; +import { Box } from '@mui/system'; +import { AddCircle } from '@mui/icons-material'; import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; -import NERAutocomplete from '../../../components/NERAutocomplete'; -import { Sponsor } from 'shared'; +import SponsorTaskCard from './SponsorTaskCard'; +import { Sponsor, SponsorValueType } from 'shared'; interface SponsorFormProps { control: Control; errors: FieldErrors; + setValue: UseFormSetValue; defaultValues?: Sponsor; } @@ -41,40 +42,76 @@ const getYears = (startYear = 1950) => { return years; }; -const sponsorSchema = yup.object().shape({ - name: yup.string().required('Name is required'), - activeStatus: yup.boolean().required('Sponsor status is required'), - sponsorValue: yup.number().typeError('Sponsor value must be a number').required('Sponsor value is required'), - joinDate: yup.date().required('Join date is required'), - activeYears: yup - .array() - .of(yup.number().typeError('Active year must be a number').required('Active year is required')) - .required('Active years are required'), - sponsorTierId: yup.string().required('Sponsor tier is required'), - sponsorContact: yup.string().required('Sponsor contact is required'), - taxExempt: yup.boolean().required('Tax exempt is required'), - discountCode: yup.string().trim().optional(), - sponsorNotes: yup.string().trim().optional(), - sponsorTasks: yup - .array() - .of( - yup.object().shape({ - dueDate: yup.date().required('Due date is required'), - notifyDate: yup.date(), - assigneeUserId: yup.string(), - notes: yup.string().required('Notes are required') - }) - ) - .required('Sponsor Tasks are Required') -}); +const VALUE_TYPE_OPTIONS = [ + { value: SponsorValueType.MONETARY, label: 'Monetary' }, + { value: SponsorValueType.STOCK, label: 'Stock/Parts/Services' }, + { value: SponsorValueType.DISCOUNT, label: 'Discount' } +]; -export const SponsorForm: React.FC = ({ control, errors, defaultValues }: SponsorFormProps) => { - const theme = useTheme(); +const sponsorSchema = yup.object().shape( + { + name: yup.string().required('Name is required'), + activeStatus: yup.boolean().required('Sponsor status is required'), + valueTypes: yup + .array() + .of(yup.string().required()) + .min(1, 'At least one value type is required') + .required('Value types are required'), + sponsorValue: yup + .number() + .typeError('Sponsor value must be a number') + .when('valueTypes', { + is: (types: string[]) => types?.includes(SponsorValueType.MONETARY), + then: (schema) => schema.required('Sponsor value is required for monetary sponsors'), + otherwise: (schema) => schema.optional().nullable() + }), + stockDescription: yup.string().trim().optional(), + discountDescription: yup.string().trim().optional(), + joinDate: yup.date().required('Join date is required'), + activeYears: yup + .array() + .of(yup.number().typeError('Active year must be a number').required('Active year is required')) + .required('Active years are required'), + sponsorTierId: yup.string().optional(), + contactName: yup.string().required('Contact name is required'), + contactEmail: yup + .string() + .email('Invalid email') + .when('contactPhone', { + is: (phone: string | undefined) => !phone, + then: (schema) => schema.required('Email or phone is required'), + otherwise: (schema) => schema.optional() + }), + contactPhone: yup.string().when('contactEmail', { + is: (email: string | undefined) => !email, + then: (schema) => schema.required('Email or phone is required'), + otherwise: (schema) => schema.optional() + }), + contactPosition: yup.string().optional(), + taxExempt: yup.boolean().required('Tax exempt is required'), + discountCode: yup.string().trim().optional(), + sponsorNotes: yup.string().trim().optional(), + sponsorTasks: yup + .array() + .of( + yup.object().shape({ + sponsorTaskId: yup.string().optional(), + dueDate: yup.date().required('Due date is required'), + notifyDate: yup.date().optional(), + assigneeUserId: yup.string().optional(), + notes: yup.string().required('Notes are required'), + done: yup.boolean().optional() + }) + ) + .required('Sponsor Tasks are Required') + }, + [['contactEmail', 'contactPhone']] +); + +export const SponsorForm: React.FC = ({ control, errors, setValue, defaultValues }: SponsorFormProps) => { const yearsOptions = getYears(); - const [datePickerOpenNotify, setDatePickerOpenNotify] = useState(false); const [datePickerOpenJoin, setDatePickerOpenJoin] = useState(false); - const [datePickerOpenDue, setDatePickerOpenDue] = useState(false); const { isLoading: membersLoading, isError: membersIsError, error: membersError, data: members } = useAllMembers(); @@ -85,6 +122,24 @@ export const SponsorForm: React.FC = ({ control, errors, defau data: allSponsorTiers } = useGetAllSponsorTiers(); + const watchedValueTypes: string[] = useWatch({ control, name: 'valueTypes' }) ?? []; + const isMonetary = watchedValueTypes.includes(SponsorValueType.MONETARY); + const isStock = watchedValueTypes.includes(SponsorValueType.STOCK); + const isDiscount = watchedValueTypes.includes(SponsorValueType.DISCOUNT); + + const watchedSponsorValue: number | undefined = useWatch({ control, name: 'sponsorValue' }); + const tierManuallySet = useRef(!!defaultValues); + + useEffect(() => { + if (tierManuallySet.current || !allSponsorTiers || allSponsorTiers.length === 0) return; + const value = watchedSponsorValue ?? 0; + const sorted = [...allSponsorTiers].sort((a, b) => b.minSupportValue - a.minSupportValue); + const bestTier = sorted.find((t) => value >= t.minSupportValue); + if (bestTier) { + setValue('sponsorTierId', bestTier.sponsorTierId); + } + }, [watchedSponsorValue, allSponsorTiers, setValue]); + const { fields, append, remove } = useFieldArray({ control, name: 'sponsorTasks' @@ -135,10 +190,40 @@ export const SponsorForm: React.FC = ({ control, errors, defau + - Sponsor Value:* + Value Types:* + + ( + option.label} + value={VALUE_TYPE_OPTIONS.filter((opt) => value?.includes(opt.value))} + onChange={(_, data) => onChange(data.map((d) => d.value))} + renderInput={(params) => ( + + )} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ) + } + isOptionEqualToValue={(option, val) => option.value === val.value} + disableCloseOnSelect + /> + )} + /> + {errors.valueTypes?.message} + + + + + + Sponsor Value:{isMonetary ? '*' : ''} = ({ control, errors, defau /> + {isStock && ( + + + + Stock/Parts/Services Description: + + + + + )} + {isDiscount && ( + + + + Discount Description: + + + + + )} @@ -211,7 +328,7 @@ export const SponsorForm: React.FC = ({ control, errors, defau - Sponsor Tier:* + Sponsor Tier: = ({ control, errors, defau