Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/services/auth/auth-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
167 changes: 153 additions & 14 deletions src/services/sponsors/sponsor-router.ts
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -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:
Expand Down Expand Up @@ -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
* }
Expand All @@ -92,6 +116,9 @@ sponsorRouter.get("/", createUser, requireMemberRole, async (req: Request, res:
contact_tasks (
id,
status,
notes,
due_date,
owner_id,
profiles (
id,
name
Expand All @@ -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));
});

/**
Expand All @@ -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
* }
Expand All @@ -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));
Expand All @@ -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));
});

/**
Expand All @@ -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
* }
Expand All @@ -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));
},
);

Expand All @@ -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) {
Expand All @@ -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;
33 changes: 32 additions & 1 deletion src/services/tasks/task-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand Down Expand Up @@ -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;