diff --git a/src/services/auth/auth-router.ts b/src/services/auth/auth-router.ts index c789962..9bb9b1c 100644 --- a/src/services/auth/auth-router.ts +++ b/src/services/auth/auth-router.ts @@ -171,6 +171,22 @@ authRouter.get("/me", createUser, (req: Request, res: Response) => { return res.status(StatusCode.SuccessOK).json((req as any).user); }); +/** + * POST /auth/logout + * + * Logs the user out by clearing the authentication cookie. + * + * @description This endpoint clears the sb-access-token cookie, + * effectively ending the user's session. + * + * @returns {Object} JSON response: + * - Success (200): { message: "Logged out successfully" } + */ +authRouter.post("/logout", (_req: Request, res: Response) => { + res.clearCookie("sb-access-token", { path: "/" }); + return res.status(StatusCode.SuccessOK).json({ message: "Logged out successfully" }); +}); + /** * THIS ENDPOINT IS USED FOR POSTMAN TESTING * diff --git a/src/services/sponsors/sponsor-router.ts b/src/services/sponsors/sponsor-router.ts index f77dd33..43eed96 100644 --- a/src/services/sponsors/sponsor-router.ts +++ b/src/services/sponsors/sponsor-router.ts @@ -1,12 +1,36 @@ import { Router, Request, Response, NextFunction } from "express"; -import { isValidSponsorInsertFormat, isValidSponsorUpdateFormat, SponsorInsert, SponsorUpdate } from "./sponsor-formats"; +import { isValidSponsorInsertFormat, isValidSponsorUpdateFormat, SponsorInsert, SponsorSelect, SponsorUpdate } from "./sponsor-formats"; import { RouterError } from "../../middleware/error-handler"; import StatusCode from "status-code-enum"; -import { Roles, Tables } from "../../lib/db/strings"; +import { EmailStatus, Tables } from "../../lib/db/strings"; import { createUser, requireMemberRole } from "../../middleware/auth"; const sponsorRouter: Router = Router(); +/** + * Helper function to filter out inactive tasks from the sponsors array + * @param sponsors Array of sponsors with contact_tasks + * @returns Array of sponsors with only active tasks + */ +const filterForActive = (sponsors: (SponsorSelect & { contact_tasks?: any[] })[]) => { + return sponsors.map((sponsor) => { + const { contact_tasks, ...rest } = sponsor; + const active_task = + (contact_tasks || []).find( + (task: any) => + task.status !== null && + task.status !== EmailStatus.REJECTED && + task.status !== EmailStatus.GHOSTED && + task.status !== EmailStatus.INVALID_CONTACT && + task.status !== EmailStatus.DEFERRED, + ) || null; + return { + ...rest, + active_task, + }; + }); +}; + /** * POST /sponsors/create * @@ -20,7 +44,7 @@ const sponsorRouter: Router = Router(); * - sponsor_name: string - Name of the sponsor contact * - company_name: string - Name of the sponsor's company * - notes: string (optional) - Additional notes about the sponsor - * - status: "PENDING_EMAIL" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED" (optional, defaults to "PENDING_EMAIL") + * - status: "NOT_CONTACTED" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED" | "INVALID_CONTACT" | "DEFERRED" (optional, defaults to "NOT_CONTACTED") * * * @returns {Object} JSON response containing: @@ -73,7 +97,7 @@ sponsorRouter.post("/create", createUser, requireMemberRole, async (req: Request * sponsor_name: string, * company_name: string, * notes: string, - * status: "PENDING_EMAIL" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED", + * status: "NOT_CONTACTED" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED" | "INVALID_CONTACT" | "DEFERRED", * created_at: string, * updated_at: string * } @@ -92,6 +116,9 @@ sponsorRouter.get("/", createUser, requireMemberRole, async (req: Request, res: contact_tasks ( id, status, + notes, + due_date, + owner_id, profiles ( id, name @@ -103,7 +130,7 @@ sponsorRouter.get("/", createUser, requireMemberRole, async (req: Request, res: return next(new RouterError(StatusCode.ServerErrorInternal, "Error fetching sponsors", null, error)); } - return res.status(StatusCode.SuccessOK).json(data); + return res.status(StatusCode.SuccessOK).json(filterForActive(data)); }); /** @@ -124,7 +151,7 @@ sponsorRouter.get("/", createUser, requireMemberRole, async (req: Request, res: * sponsor_name: string, * company_name: string, * notes: string, - * status: "PENDING_EMAIL" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED", + * status: "NOT_CONTACTED" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED" | "INVALID_CONTACT" | "DEFERRED", * created_at: string, * updated_at: string * } @@ -147,7 +174,20 @@ sponsorRouter.get("/:email", createUser, requireMemberRole, async (req: Request, return next(new RouterError(StatusCode.ClientErrorBadRequest, "Email is required")); } - const { data, error } = await supabase.from(Tables.SPONSORS).select("*").eq("sponsor_email", email); + const { data, error } = await supabase.from(Tables.SPONSORS).select(` + *, + contact_tasks ( + id, + status, + notes, + due_date, + owner_id, + profiles ( + id, + name + ) + ) + `).eq("sponsor_email", email); if (error) { return next(new RouterError(StatusCode.ServerErrorInternal, "Error fetching sponsor", null, error)); @@ -157,7 +197,7 @@ sponsorRouter.get("/:email", createUser, requireMemberRole, async (req: Request, return next(new RouterError(StatusCode.ClientErrorNotFound, "Sponsor not found")); } - return res.status(StatusCode.SuccessOK).json(data); + return res.status(StatusCode.SuccessOK).json(filterForActive(data)); }); /** @@ -179,7 +219,7 @@ sponsorRouter.get("/:email", createUser, requireMemberRole, async (req: Request, * sponsor_name: string, * company_name: string, * notes: string, - * status: "PENDING_EMAIL" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED", + * status: "NOT_CONTACTED" | "CONTACTED" | "REJECTED" | "NEED_PAYMENT" | "CONFIRMED" | "INVALID_CONTACT" | "DEFERRED", * created_at: string, * updated_at: string * } @@ -203,13 +243,26 @@ sponsorRouter.get( return next(new RouterError(StatusCode.ClientErrorBadRequest, "Company name is required")); } const supabase = (req as any).supabase; - const { data, error } = await supabase.from(Tables.SPONSORS).select("*").ilike("company_name", `%${companyName}%`); + const { data, error } = await supabase.from(Tables.SPONSORS).select(` + *, + contact_tasks ( + id, + status, + notes, + due_date, + owner_id, + profiles ( + id, + name + ) + ) + `).ilike("company_name", `%${companyName}%`); if (error) { return next(new RouterError(StatusCode.ServerErrorInternal, "Error fetching sponsor", null, error)); } - return res.status(StatusCode.SuccessOK).json(data); + return res.status(StatusCode.SuccessOK).json(filterForActive(data)); }, ); @@ -233,7 +286,20 @@ sponsorRouter.patch("/:email", createUser, requireMemberRole, async (req: Reques .from(Tables.SPONSORS) .update(updatePayload) .eq("sponsor_email", email) - .select() + .select(` + *, + contact_tasks ( + id, + status, + notes, + due_date, + owner_id, + profiles ( + id, + name + ) + ) + `) .single(); if (error) { @@ -244,10 +310,83 @@ sponsorRouter.patch("/:email", createUser, requireMemberRole, async (req: Reques return next(new RouterError(StatusCode.ServerErrorInternal, "Error updating sponsor", null, error)); } - return res.status(StatusCode.SuccessOK).json(data); + return res.status(StatusCode.SuccessOK).json(filterForActive([data])[0]); } catch (error) { return next(new RouterError(StatusCode.ServerErrorInternal, "Error updating sponsor", null, error)); } }); -export default sponsorRouter; +/** + * DELETE /sponsors/:email + * + * Deletes a sponsor by email address. + * + * @description Deletes a sponsor from the database. If the sponsor has an active task + * (not REJECTED, GHOSTED, INVALID_CONTACT, or DEFERRED), the delete is blocked + * and a 409 Conflict is returned. + * + * @param {string} email - The email address of the sponsor to delete + * + * @returns {Object} JSON response: + * - Success (200): { message: "Sponsor deleted successfully" } + * - Error (400): Missing email + * - Error (404): Sponsor not found + * - Error (409): Sponsor has an active task + * - Error (500): Database error + */ +sponsorRouter.delete("/:email", createUser, requireMemberRole, async (req: Request, res: Response, next: NextFunction) => { + const { email } = req.params; + const supabase = (req as any).supabase; + + if (!email || typeof email !== "string") { + return next(new RouterError(StatusCode.ClientErrorBadRequest, "Email is required")); + } + + // First, check if the sponsor exists and has any active tasks + const { data: sponsor, error: fetchErr } = await supabase + .from(Tables.SPONSORS) + .select(` + sponsor_email, + contact_tasks ( + id, + status + ) + `) + .eq("sponsor_email", email) + .maybeSingle(); + + if (fetchErr) { + return next(new RouterError(StatusCode.ServerErrorInternal, "Error fetching sponsor", null, fetchErr)); + } + + if (!sponsor) { + return next(new RouterError(StatusCode.ClientErrorNotFound, "Sponsor not found")); + } + + // Check for active tasks + const hasActiveTask = (sponsor.contact_tasks || []).some( + (task: any) => + task.status !== EmailStatus.REJECTED && + task.status !== EmailStatus.GHOSTED && + task.status !== EmailStatus.INVALID_CONTACT && + task.status !== EmailStatus.DEFERRED, + ); + + if (hasActiveTask) { + return next(new RouterError(StatusCode.ClientErrorConflict, "Cannot delete sponsor with an active task. Close or reassign the task first.")); + } + + // Delete only the sponsor row + const { error: deleteErr } = await supabase + .from(Tables.SPONSORS) + .delete() + .eq("sponsor_email", email); + + if (deleteErr) { + return next(new RouterError(StatusCode.ServerErrorInternal, "Error deleting sponsor", null, deleteErr)); + } + + return res.status(StatusCode.SuccessOK).json({ message: "Sponsor deleted successfully" }); +}); + +export default sponsorRouter; \ No newline at end of file diff --git a/src/services/tasks/task-router.ts b/src/services/tasks/task-router.ts index 554d720..0db218e 100644 --- a/src/services/tasks/task-router.ts +++ b/src/services/tasks/task-router.ts @@ -152,12 +152,43 @@ taskRouter.post("/create", createUser, requireMemberRole, async (req: Request, r return next(new RouterError(StatusCode.ClientErrorBadRequest, "Invalid task format")); } + const { data: existingTasks, error: checkError } = await supabase + .from(Tables.CONTACT_TASKS) + .select("id, status") + .eq("sponsor_email", task.sponsor_email); + + if (checkError) { + return next(new RouterError(StatusCode.ServerErrorInternal, "Error checking existing tasks", null, checkError)); + } + + const hasActiveTask = existingTasks?.some( + (t: any) => + t.status !== EmailStatus.REJECTED && + t.status !== EmailStatus.GHOSTED && + t.status !== EmailStatus.INVALID_CONTACT && + t.status !== EmailStatus.DEFERRED + ); + if (hasActiveTask) { + return next(new RouterError(StatusCode.ClientErrorBadRequest, "Sponsor already has an active task")); + } + const { data: insertedTask, error: dbErr } = await supabase.from(Tables.CONTACT_TASKS).insert(task).select().single(); if (dbErr) { return next(new RouterError(StatusCode.ServerErrorInternal, "Error creating task", null, dbErr)); } + // Reset sponsor status to NOT_CONTACTED so it reflects the fresh active task + // THIS MEANS THAT CREATING A NEW TASK OVERWRITES ANY PREVIOUS STATUS!!! + const { error: sponsorResetError } = await supabase + .from(Tables.SPONSORS) + .update({ status: SponsorStatus.NOT_CONTACTED, updated_at: new Date().toISOString() }) + .eq("sponsor_email", task.sponsor_email); + + if (sponsorResetError) { + console.error(`Task ${insertedTask.id} created, but failed to reset sponsor ${task.sponsor_email} status:`, sponsorResetError); + } + return res.status(StatusCode.SuccessOK).json({ message: "Task created successfully", task_id: insertedTask.id }); }); @@ -321,4 +352,4 @@ taskRouter.patch("/:id", createUser, requireMemberRole, async (req: Request, res return next(new RouterError(StatusCode.ServerErrorInternal, "Error updating task", null, error)); } }); -export default taskRouter; +export default taskRouter; \ No newline at end of file