+
+
+ payment qr
+
+
+ upload the QR code shown on accepted applicants' payment
+ instructions
+
+
+
+
+ {isPaymentQrLoading ? (
+
+ ) : paymentQrData?.url ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ no qr uploaded
+
+ )}
+
+
+
+
+
+
+
+
+ payment acknowledgement receipt
+
+
+ upload the PDF template applicants must fill out and submit as a
+ Google Drive link
+
+
+
+
+
+
+ community link
+
+
+ configure the group link shown to accepted applicants
+
+
+
+
+
+
+
+
+ executive associate availability
+
+
+ choose which EB roles applicants can apply to as Executive
+ Associate
+
+
+ {savingAvailability && (
+
+ saving...
+
+ )}
+
+
+ {isAvailabilityLoading ? (
+
+
+
+ ) : (
+
+ {ebRoles.map((role) => {
+ const enabled = availability[role.id] !== false;
+
+ return (
+
+
+
+ {role.title}
+
+
+ {enabled
+ ? "visible to applicants"
+ : "hidden from applicants"}
+
+
+
+ handleToggleAvailability(role.id, e.target.checked)
+ }
+ className="h-5 w-5 rounded border-[#005FD9]/20 text-[#044FAF] focus:ring-[#044FAF]/30"
+ />
+
+ );
+ })}
+
+ )}
+
+
{/* Existing Cycles */}
{allCycles.length > 0 && (
@@ -1228,6 +1724,7 @@ function SettingsTab() {
setForm({ ...form, applicationStart: e.target.value })
@@ -1243,6 +1740,7 @@ function SettingsTab() {
setForm({ ...form, interviewStart: e.target.value })
@@ -1252,11 +1750,12 @@ function SettingsTab() {
- Interview End *
+ Interview Last Day *
setForm({ ...form, interviewEnd: e.target.value })
diff --git a/src/app/api/admin/applications/[position]/route.ts b/src/app/api/admin/applications/[position]/route.ts
index 924e6d1..a8ec1c8 100644
--- a/src/app/api/admin/applications/[position]/route.ts
+++ b/src/app/api/admin/applications/[position]/route.ts
@@ -3,6 +3,18 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";
import { getPositionTitle, getRoleId } from "@/lib/eb-mapping";
+import { committeeRoles } from "@/data/committeeRoles";
+
+const normalizeCommitteeId = (value: string) => {
+ const normalizedValue = value.toLowerCase().replace(/&/g, "and");
+ const committee = committeeRoles.find(
+ ({ id, title }) =>
+ id.toLowerCase() === normalizedValue ||
+ title.toLowerCase().replace(/&/g, "and") === normalizedValue,
+ );
+
+ return committee?.id ?? value;
+};
// GET all applications with filtering
export async function GET(
@@ -19,6 +31,7 @@ export async function GET(
// Check if user has admin access
const userRole = session.user.role;
const hasAdminAccess = userRole === "admin" || userRole === "super_admin";
+ const isSuperAdmin = userRole === "super_admin";
if (!hasAdminAccess) {
return NextResponse.json(
@@ -76,8 +89,29 @@ export async function GET(
.filter(Boolean)
.map((value) => value.toLowerCase());
+ const ebProfile = await prisma.eBProfile.findFirst({
+ where: {
+ OR: [
+ { position: { equals: position, mode: "insensitive" } },
+ { position: { equals: positionTitle, mode: "insensitive" } },
+ { position: { equals: roleId, mode: "insensitive" } },
+ ],
+ },
+ select: { committees: true },
+ });
+ const accessibleCommittees = new Set(
+ ebProfile?.committees.map(normalizeCommitteeId) ?? [],
+ );
+
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
// First, let's see all applications for this position
const allCommApplications = await prisma.committeeApplication.findMany({
+ where: { recruitmentCycleId: activeCycleId },
orderBy: { createdAt: "desc" },
include: {
user: {
@@ -95,81 +129,101 @@ export async function GET(
console.log(
`Found ${allCommApplications.length} total committee applications for position: ${position}`,
);
- allCommApplications.forEach((app: typeof allCommApplications[number]) => {
+ allCommApplications.forEach((app: (typeof allCommApplications)[number]) => {
console.log(
`Committee App ${app.id}: hasAccepted=${app.hasAccepted}, status=${app.status}, user=${app.user?.name}`,
);
});
- const commApplications = allCommApplications.filter((app: typeof allCommApplications[number]) => {
- // Include applications that are NOT truly processed
- const isAccepted = app.hasAccepted && app.status === "passed";
- const isRejected = app.status === "failed";
- const isRedirected = app.status === "redirected";
+ const commApplications = allCommApplications.filter(
+ (app: (typeof allCommApplications)[number]) => {
+ // Include applications that are NOT truly processed
+ const isAccepted = app.hasAccepted && app.status === "passed";
+ const isRejected = app.status === "failed";
+ const isRedirected = app.status === "redirected";
- const shouldInclude = !isAccepted && !isRejected && !isRedirected;
+ const hasCommitteeAccess =
+ isSuperAdmin ||
+ accessibleCommittees.has(
+ normalizeCommitteeId(app.firstOptionCommittee),
+ ) ||
+ accessibleCommittees.has(
+ normalizeCommitteeId(app.secondOptionCommittee),
+ );
- if (!shouldInclude) {
- console.log(
- `Excluding committee app ${app.id}: hasAccepted=${app.hasAccepted}, status=${app.status}`,
- );
- }
+ const shouldInclude =
+ hasCommitteeAccess && !isAccepted && !isRejected && !isRedirected;
- return shouldInclude;
- });
+ if (!shouldInclude) {
+ console.log(
+ `Excluding committee app ${app.id}: hasAccepted=${app.hasAccepted}, status=${app.status}`,
+ );
+ }
+
+ return shouldInclude;
+ },
+ );
console.log(
`Filtered to ${commApplications.length} committee applications for All Applications tab`,
);
// Get all EA applications and compute whether each one is assigned to the current admin position
- const allEAApplications = await prisma.eAApplication.findMany({
- orderBy: { createdAt: "desc" },
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- studentNumber: true,
- section: true,
+ const allExecutiveAssociateApplications =
+ await prisma.executiveAssociateApplication.findMany({
+ where: { recruitmentCycleId: activeCycleId },
+ orderBy: { createdAt: "desc" },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ studentNumber: true,
+ section: true,
+ },
},
},
- },
- });
+ });
console.log(
- `Found ${allEAApplications.length} total EA applications for position: ${position}`,
+ `Found ${allExecutiveAssociateApplications.length} total EA applications for position: ${position}`,
+ );
+ allExecutiveAssociateApplications.forEach(
+ (app: (typeof allExecutiveAssociateApplications)[number]) => {
+ console.log(
+ `EA App ${app.id}: hasAccepted=${app.hasAccepted}, status=${app.status}, user=${app.user?.name}`,
+ );
+ },
);
- allEAApplications.forEach((app: typeof allEAApplications[number]) => {
- console.log(
- `EA App ${app.id}: hasAccepted=${app.hasAccepted}, status=${app.status}, user=${app.user?.name}`,
- );
- });
- const eAApplications = allEAApplications.filter((app: typeof allEAApplications[number]) => {
- // Include applications that are NOT truly processed
- const isAccepted = app.hasAccepted && app.status === "passed";
- const isRejected = app.status === "failed";
- const isRedirected = app.status === "redirected";
+ const executiveAssociateApplications =
+ allExecutiveAssociateApplications.filter(
+ (app: (typeof allExecutiveAssociateApplications)[number]) => {
+ // Include applications that are NOT truly processed
+ const isAccepted = app.hasAccepted && app.status === "passed";
+ const isRejected = app.status === "failed";
+ const isRedirected = app.status === "redirected";
- const shouldInclude = !isAccepted && !isRejected && !isRedirected;
+ const shouldInclude = !isAccepted && !isRejected && !isRedirected;
- if (!shouldInclude) {
- console.log(
- `Excluding EA app ${app.id}: hasAccepted=${app.hasAccepted}, status=${app.status}`,
- );
- }
+ if (!shouldInclude) {
+ console.log(
+ `Excluding EA app ${app.id}: hasAccepted=${app.hasAccepted}, status=${app.status}`,
+ );
+ }
- return shouldInclude;
- });
+ return shouldInclude;
+ },
+ );
console.log(
- `Filtered to ${eAApplications.length} EA applications for All Applications tab`,
+ `Filtered to ${executiveAssociateApplications.length} EA applications for All Applications tab`,
);
// get member applications
const memberApplications = await prisma.memberApplication.findMany({
+ where: { recruitmentCycleId: activeCycleId },
orderBy: { createdAt: "desc" },
include: {
user: {
@@ -186,52 +240,60 @@ export async function GET(
// Add CV and Portfolio download links for Committee applications
applications.committee = await Promise.all(
- commApplications.map(async (application: typeof commApplications[number]) => {
- const cvDownloadUrl = application.supabaseFilePath
- ? `/api/admin/cv-download?applicationId=${application.id}&type=committee`
- : null;
-
- const portfolioDownloadUrl = application.portfolioLink
- ? `/api/admin/portfolio-download?applicationId=${application.id}`
- : null;
-
- return {
- ...application,
- type: "committee",
- isAssigned: Boolean(
- application.interviewBy &&
+ commApplications.map(
+ async (application: (typeof commApplications)[number]) => {
+ const cvDownloadUrl = application.supabaseFilePath
+ ? `/api/admin/cv-download?applicationId=${application.id}&type=committee`
+ : null;
+
+ const portfolioDownloadUrl = application.portfolioLink
+ ? `/api/admin/portfolio-download?applicationId=${application.id}`
+ : null;
+
+ return {
+ ...application,
+ type: "committee",
+ isAssigned: Boolean(
+ application.interviewBy &&
assignmentValues.includes(application.interviewBy.toLowerCase()),
- ),
- cvDownloadUrl,
- portfolioDownloadUrl,
- };
- }),
+ ),
+ cvDownloadUrl,
+ portfolioDownloadUrl,
+ };
+ },
+ ),
);
// Add CV download links for EA applications
applications.ea = await Promise.all(
- eAApplications.map(async (application: typeof eAApplications[number]) => {
- const cvDownloadUrl = application.supabaseFilePath
- ? `/api/admin/cv-download?applicationId=${application.id}&type=ea`
- : null;
-
- return {
- ...application,
- type: "ea",
- isAssigned: Boolean(
- application.interviewBy &&
+ executiveAssociateApplications.map(
+ async (
+ application: (typeof executiveAssociateApplications)[number],
+ ) => {
+ const cvDownloadUrl = application.supabaseFilePath
+ ? `/api/admin/cv-download?applicationId=${application.id}&type=executive-associate`
+ : null;
+
+ return {
+ ...application,
+ type: "executive-associate",
+ isAssigned: Boolean(
+ application.interviewBy &&
assignmentValues.includes(application.interviewBy.toLowerCase()),
- ),
- cvDownloadUrl,
- };
- }),
+ ),
+ cvDownloadUrl,
+ };
+ },
+ ),
);
- applications.member = memberApplications.map((application: typeof memberApplications[number]) => ({
- ...application,
- type: "member",
- isAssigned: true,
- }));
+ applications.member = memberApplications.map(
+ (application: (typeof memberApplications)[number]) => ({
+ ...application,
+ type: "member",
+ isAssigned: true,
+ }),
+ );
return NextResponse.json({
success: true,
diff --git a/src/app/api/admin/applications/counts/route.ts b/src/app/api/admin/applications/counts/route.ts
new file mode 100644
index 0000000..cf54b6f
--- /dev/null
+++ b/src/app/api/admin/applications/counts/route.ts
@@ -0,0 +1,129 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { Prisma } from "@prisma/client";
+import { committeeRoles } from "@/data/committeeRoles";
+
+const normalizeCommitteeId = (value: string): string => {
+ const normalizedValue = value.toLowerCase().replace(/&/g, "and");
+ const committee = committeeRoles.find(
+ ({ id, title }) =>
+ id.toLowerCase() === normalizedValue ||
+ title.toLowerCase().replace(/&/g, "and") === normalizedValue,
+ );
+
+ return committee?.id ?? value;
+};
+
+export async function GET(_request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ // Check if user has admin access
+ const userRole = session.user.role;
+ const hasAdminAccess = userRole === "admin" || userRole === "super_admin";
+
+ if (!hasAdminAccess) {
+ return NextResponse.json(
+ { error: "Forbidden - Admin access required" },
+ { status: 403 },
+ );
+ }
+
+ const isSuperAdmin = userRole === "super_admin";
+
+ // Get active cycle
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
+ // Get EB profile of the logged in user to find their accessible committees
+ let accessibleCommittees: Set | null = null;
+ if (!isSuperAdmin && session.user.dbId) {
+ const ebProfile = await prisma.eBProfile.findFirst({
+ where: { userId: session.user.dbId },
+ select: { committees: true },
+ });
+ if (ebProfile) {
+ accessibleCommittees = new Set(
+ ebProfile.committees.map(normalizeCommitteeId),
+ );
+ }
+ }
+
+ // 1. Pending Members (hasAccepted: false)
+ const memberCount = await prisma.memberApplication.count({
+ where: {
+ recruitmentCycleId: activeCycleId,
+ hasAccepted: false,
+ },
+ });
+
+ // 2. Pending EAs (hasAccepted: false, status: null/pending/evaluating)
+ const eaCount = await prisma.executiveAssociateApplication.count({
+ where: {
+ recruitmentCycleId: activeCycleId,
+ hasAccepted: false,
+ OR: [{ status: null }, { status: "pending" }, { status: "evaluating" }],
+ },
+ });
+
+ // 3. Pending Committees (hasAccepted: false, status: null/pending/evaluating, and filtered by EB access)
+ const committeeConditions: Prisma.CommitteeApplicationWhereInput = {
+ recruitmentCycleId: activeCycleId,
+ hasAccepted: false,
+ };
+
+ if (accessibleCommittees) {
+ const accessibleList = Array.from(accessibleCommittees);
+ committeeConditions.AND = [
+ {
+ OR: [
+ { status: null },
+ { status: "pending" },
+ { status: "evaluating" },
+ ],
+ },
+ {
+ OR: [
+ { firstOptionCommittee: { in: accessibleList } },
+ { redirection: { in: accessibleList } },
+ ],
+ },
+ ];
+ } else {
+ committeeConditions.OR = [
+ { status: null },
+ { status: "pending" },
+ { status: "evaluating" },
+ ];
+ }
+
+ const committeeCount = await prisma.committeeApplication.count({
+ where: committeeConditions,
+ });
+
+ return NextResponse.json({
+ success: true,
+ counts: {
+ member: memberCount,
+ ea: eaCount,
+ committee: committeeCount,
+ total: memberCount + eaCount + committeeCount,
+ },
+ });
+ } catch (error) {
+ console.error("Error getting application counts:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/admin/applications/route.ts b/src/app/api/admin/applications/route.ts
index 89d7d7e..8d49d12 100644
--- a/src/app/api/admin/applications/route.ts
+++ b/src/app/api/admin/applications/route.ts
@@ -2,7 +2,21 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
+import { Prisma } from "@prisma/client";
import { sendEmail, emailTemplates } from "@/lib/email";
+import { committeeRoles } from "@/data/committeeRoles";
+import { ensureCycleMemberId } from "@/lib/member-id";
+
+const normalizeCommitteeId = (value: string) => {
+ const normalizedValue = value.toLowerCase().replace(/&/g, "and");
+ const committee = committeeRoles.find(
+ ({ id, title }) =>
+ id.toLowerCase() === normalizedValue ||
+ title.toLowerCase().replace(/&/g, "and") === normalizedValue,
+ );
+
+ return committee?.id ?? value;
+};
import { applicationActionSchema } from "@/lib/schemas";
// Type definitions for raw query results
@@ -43,10 +57,32 @@ export async function GET(request: NextRequest) {
const type = searchParams.get("type");
const status = searchParams.get("status");
const committee = searchParams.get("committee");
+ const isSuperAdmin = userRole === "super_admin";
+
+ // Get EB profile of the logged in user to find their accessible committees
+ let accessibleCommittees: Set | null = null;
+ if (!isSuperAdmin && session.user.dbId) {
+ const ebProfile = await prisma.eBProfile.findFirst({
+ where: { userId: session.user.dbId },
+ select: { committees: true },
+ });
+ if (ebProfile) {
+ accessibleCommittees = new Set(
+ ebProfile.committees.map(normalizeCommitteeId),
+ );
+ }
+ }
+
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "10");
const skip = (page - 1) * limit;
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
if (type === "member") {
let memberApplications;
let totalCount;
@@ -54,11 +90,11 @@ export async function GET(request: NextRequest) {
if (status === "accepted") {
// Get accepted applications
totalCount = await prisma.memberApplication.count({
- where: { hasAccepted: true },
+ where: { hasAccepted: true, recruitmentCycleId: activeCycleId },
});
memberApplications = await prisma.memberApplication.findMany({
- where: { hasAccepted: true },
+ where: { hasAccepted: true, recruitmentCycleId: activeCycleId },
orderBy: { createdAt: "desc" },
skip: skip,
take: limit,
@@ -70,6 +106,11 @@ export async function GET(request: NextRequest) {
email: true,
studentNumber: true,
section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
@@ -81,6 +122,7 @@ export async function GET(request: NextRequest) {
SELECT * FROM "MemberApplication"
WHERE "hasAccepted" = false
AND "createdAt" = "updatedAt"
+ AND "recruitmentCycleId" = ${activeCycleId}
ORDER BY "createdAt" DESC
LIMIT ${limit} OFFSET ${skip}
`,
@@ -88,13 +130,16 @@ export async function GET(request: NextRequest) {
SELECT COUNT(*) as count FROM "MemberApplication"
WHERE "hasAccepted" = false
AND "createdAt" = "updatedAt"
+ AND "recruitmentCycleId" = ${activeCycleId}
`,
]);
totalCount = Number(countResult[0].count);
// Batch fetch users to avoid N+1 queries
- const studentNumbers = applications.map((app: MemberApplicationRaw) => app.studentNumber);
+ const studentNumbers = applications.map(
+ (app: MemberApplicationRaw) => app.studentNumber,
+ );
const users = await prisma.user.findMany({
where: { studentNumber: { in: studentNumbers } },
select: {
@@ -103,9 +148,16 @@ export async function GET(request: NextRequest) {
email: true,
studentNumber: true,
section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
});
- const userMap = new Map(users.map((u: typeof users[number]) => [u.studentNumber, u]));
+ const userMap = new Map(
+ users.map((u: (typeof users)[number]) => [u.studentNumber, u]),
+ );
memberApplications = applications.map((app: MemberApplicationRaw) => ({
...app,
user: userMap.get(app.studentNumber) ?? null,
@@ -117,6 +169,7 @@ export async function GET(request: NextRequest) {
SELECT * FROM "MemberApplication"
WHERE "hasAccepted" = false
AND "createdAt" != "updatedAt"
+ AND "recruitmentCycleId" = ${activeCycleId}
ORDER BY "createdAt" DESC
LIMIT ${limit} OFFSET ${skip}
`,
@@ -124,13 +177,16 @@ export async function GET(request: NextRequest) {
SELECT COUNT(*) as count FROM "MemberApplication"
WHERE "hasAccepted" = false
AND "createdAt" != "updatedAt"
+ AND "recruitmentCycleId" = ${activeCycleId}
`,
]);
totalCount = Number(countResult[0].count);
// Batch fetch users to avoid N+1 queries
- const studentNumbers = applications.map((app: MemberApplicationRaw) => app.studentNumber);
+ const studentNumbers = applications.map(
+ (app: MemberApplicationRaw) => app.studentNumber,
+ );
const users = await prisma.user.findMany({
where: { studentNumber: { in: studentNumbers } },
select: {
@@ -139,18 +195,28 @@ export async function GET(request: NextRequest) {
email: true,
studentNumber: true,
section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
});
- const userMap = new Map(users.map((u: typeof users[number]) => [u.studentNumber, u]));
+ const userMap = new Map(
+ users.map((u: (typeof users)[number]) => [u.studentNumber, u]),
+ );
memberApplications = applications.map((app: MemberApplicationRaw) => ({
...app,
user: userMap.get(app.studentNumber) ?? null,
}));
} else {
// Get all applications
- totalCount = await prisma.memberApplication.count();
+ totalCount = await prisma.memberApplication.count({
+ where: { recruitmentCycleId: activeCycleId },
+ });
memberApplications = await prisma.memberApplication.findMany({
+ where: { recruitmentCycleId: activeCycleId },
orderBy: { createdAt: "desc" },
skip: skip,
take: limit,
@@ -162,6 +228,11 @@ export async function GET(request: NextRequest) {
email: true,
studentNumber: true,
section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
@@ -182,8 +253,10 @@ export async function GET(request: NextRequest) {
});
}
- if (type === "ea") {
- const whereClause: Record = {};
+ if (type === "executive-associate") {
+ const whereClause: Record = {
+ recruitmentCycleId: activeCycleId,
+ };
// Filter by status if provided
if (status === "accepted") {
@@ -214,39 +287,48 @@ export async function GET(request: NextRequest) {
}
// Get total count for pagination
- const totalCount = await prisma.eAApplication.count({
+ const totalCount = await prisma.executiveAssociateApplication.count({
where: whereClause,
});
- const eaApplications = await prisma.eAApplication.findMany({
- where: whereClause,
- orderBy: { createdAt: "desc" },
- skip: skip,
- take: limit,
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- studentNumber: true,
- section: true,
+ const executiveAssociateApplications =
+ await prisma.executiveAssociateApplication.findMany({
+ where: whereClause,
+ orderBy: { createdAt: "desc" },
+ skip: skip,
+ take: limit,
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ studentNumber: true,
+ section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
+ },
},
},
- },
- });
+ });
// Add CV download links for EA applications (sync operation — no need for Promise.all)
- const eaApplicationsWithCvLinks = eaApplications.map((app: typeof eaApplications[number]) => ({
- ...app,
- cvDownloadUrl: app.supabaseFilePath
- ? `/api/admin/cv-download?applicationId=${app.id}&type=ea`
- : null,
- }));
+ const executiveAssociateApplicationsWithCvLinks =
+ executiveAssociateApplications.map(
+ (app: (typeof executiveAssociateApplications)[number]) => ({
+ ...app,
+ cvDownloadUrl: app.supabaseFilePath
+ ? `/api/admin/cv-download?applicationId=${app.id}&type=executive-associate`
+ : null,
+ }),
+ );
return NextResponse.json({
success: true,
- applications: eaApplicationsWithCvLinks,
+ applications: executiveAssociateApplicationsWithCvLinks,
pagination: {
currentPage: page,
totalPages: Math.ceil(totalCount / limit),
@@ -259,15 +341,46 @@ export async function GET(request: NextRequest) {
}
if (type === "committee") {
- const whereClause: Record = {};
+ const whereClause: Prisma.CommitteeApplicationWhereInput = {
+ recruitmentCycleId: activeCycleId,
+ };
+
+ // To avoid OR collisions, compile conditions into an AND array if needed
+ const andConditions: Prisma.CommitteeApplicationWhereInput[] = [];
+
+ // Enforce accessible committees
+ if (accessibleCommittees) {
+ const accessibleList = Array.from(accessibleCommittees);
+
+ if (committee && committee !== "all") {
+ // If they selected a committee, check if they have access to it
+ if (!accessibleCommittees.has(normalizeCommitteeId(committee))) {
+ return NextResponse.json({
+ success: true,
+ applications: [],
+ pagination: {
+ currentPage: page,
+ totalPages: 0,
+ totalCount: 0,
+ limit: limit,
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+ });
+ }
+ } else {
+ // If they selected "all" committees, restrict to their accessible ones
+ andConditions.push({
+ OR: [
+ { firstOptionCommittee: { in: accessibleList } },
+ { redirection: { in: accessibleList } },
+ ],
+ });
+ }
+ }
// Filter by committee if provided
if (committee && committee !== "all") {
- // For committee-specific filtering, we need to handle redirected applications
- // A redirected application should only appear in the committee they were redirected TO
- // We need to handle both committee ID and committee title since redirections store the full title
-
- // Get the committee title for the given committee ID
const { committeeRolesSubmitted } =
await import("@/data/committeeRoles");
const committeeData = committeeRolesSubmitted.find(
@@ -275,47 +388,50 @@ export async function GET(request: NextRequest) {
);
const committeeTitle = committeeData?.title;
- whereClause.OR = [
- // Direct applications to this committee (not redirected)
- {
- firstOptionCommittee: committee,
- redirection: null, // Not redirected
- },
- // Applications redirected TO this committee (by ID or title)
- ...(committeeTitle
- ? [
- { redirection: committee }, // By committee ID
- { redirection: committeeTitle }, // By committee title
- ]
- : [
- { redirection: committee }, // Fallback to just committee ID
- ]),
- ];
+ andConditions.push({
+ OR: [
+ {
+ firstOptionCommittee: committee,
+ redirection: null,
+ },
+ ...(committeeTitle
+ ? [{ redirection: committee }, { redirection: committeeTitle }]
+ : [{ redirection: committee }]),
+ ],
+ });
}
// Filter by status if provided
if (status === "accepted") {
whereClause.hasAccepted = true;
- whereClause.status = { not: null }; // Exclude applications with NULL status
+ whereClause.status = { not: null };
} else if (status === "pending") {
- whereClause.OR = [
- { hasAccepted: false, status: null },
- { hasAccepted: false, status: "pending" },
- { hasAccepted: true, status: null }, // Include accepted applications that were reset to NULL
- ];
+ andConditions.push({
+ OR: [
+ { hasAccepted: false, status: null },
+ { hasAccepted: false, status: "pending" },
+ { hasAccepted: true, status: null },
+ ],
+ });
} else if (status === "evaluating") {
whereClause.status = "evaluating";
} else if (status === "rejected") {
whereClause.status = "failed";
} else if (status === "redirected") {
- whereClause.redirection = { not: null }; // Show only applications with redirection
+ whereClause.redirection = { not: null };
} else if (status === "no-schedule") {
- whereClause.OR = [
- { interviewSlotDay: null },
- { interviewSlotTimeStart: null },
- { interviewSlotDay: "" },
- { interviewSlotTimeStart: "" },
- ];
+ andConditions.push({
+ OR: [
+ { interviewSlotDay: null },
+ { interviewSlotTimeStart: null },
+ { interviewSlotDay: "" },
+ { interviewSlotTimeStart: "" },
+ ],
+ });
+ }
+
+ if (andConditions.length > 0) {
+ whereClause.AND = andConditions;
}
// Get total count for pagination
@@ -336,6 +452,11 @@ export async function GET(request: NextRequest) {
email: true,
studentNumber: true,
section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
@@ -343,7 +464,7 @@ export async function GET(request: NextRequest) {
// Add CV and Portfolio download links for Committee applications (sync — no need for Promise.all)
const committeeApplicationsWithCvLinks = committeeApplications.map(
- (app: typeof committeeApplications[number]) => ({
+ (app: (typeof committeeApplications)[number]) => ({
...app,
cvDownloadUrl: app.supabaseFilePath
? `/api/admin/cv-download?applicationId=${app.id}&type=committee`
@@ -411,7 +532,7 @@ export async function DELETE(_request: NextRequest) {
include: {
user: {
include: {
- eaApplication: true,
+ executiveAssociateApplications: true,
},
},
},
@@ -421,8 +542,9 @@ export async function DELETE(_request: NextRequest) {
for (const committeeApp of orphanedCommitteeApps) {
// Check if the corresponding EA application exists and is failed
if (
- committeeApp.user.eaApplication &&
- committeeApp.user.eaApplication.status === "failed"
+ committeeApp.user.executiveAssociateApplications?.[0] &&
+ committeeApp.user.executiveAssociateApplications?.[0].status ===
+ "failed"
) {
await prisma.committeeApplication.delete({
where: { id: committeeApp.id },
@@ -471,7 +593,10 @@ export async function PUT(request: NextRequest) {
const body = await request.json();
const parsed = applicationActionSchema.safeParse(body);
if (!parsed.success) {
- return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
+ return NextResponse.json(
+ { error: parsed.error.issues[0].message },
+ { status: 400 },
+ );
}
const { applicationId, type, action, redirection } = parsed.data;
@@ -487,20 +612,44 @@ export async function PUT(request: NextRequest) {
if (type === "member") {
if (action === "accept") {
- updatedApplication = await prisma.memberApplication.update({
- where: { id: applicationId },
- data: { hasAccepted: true },
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- studentNumber: true,
- section: true,
+ updatedApplication = await prisma.$transaction(async (tx) => {
+ const acceptedApplication = await tx.memberApplication.update({
+ where: { id: applicationId },
+ data: { hasAccepted: true },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ studentNumber: true,
+ section: true,
+ },
},
},
- },
+ });
+
+ const memberId = await ensureCycleMemberId(
+ tx,
+ acceptedApplication.user.id,
+ acceptedApplication.recruitmentCycleId,
+ );
+
+ await tx.memberApplication.deleteMany({
+ where: {
+ studentNumber: acceptedApplication.studentNumber,
+ recruitmentCycleId: acceptedApplication.recruitmentCycleId,
+ id: { not: acceptedApplication.id },
+ },
+ });
+
+ return {
+ ...acceptedApplication,
+ user: {
+ ...acceptedApplication.user,
+ memberships: [{ memberId }],
+ },
+ };
});
// Send acceptance email
@@ -566,20 +715,40 @@ export async function PUT(request: NextRequest) {
updateData.redirection = redirection;
}
- updatedApplication = await prisma.committeeApplication.update({
- where: { id: applicationId },
- data: updateData,
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- studentNumber: true,
- section: true,
+ updatedApplication = await prisma.$transaction(async (tx) => {
+ const application = await tx.committeeApplication.update({
+ where: { id: applicationId },
+ data: updateData,
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ studentNumber: true,
+ section: true,
+ },
},
},
- },
+ });
+
+ if (action !== "accept") {
+ return application;
+ }
+
+ const memberId = await ensureCycleMemberId(
+ tx,
+ application.user.id,
+ application.recruitmentCycleId,
+ );
+
+ return {
+ ...application,
+ user: {
+ ...application.user,
+ memberships: [{ memberId }],
+ },
+ };
});
// Send appropriate email based on action
@@ -684,12 +853,13 @@ export async function PUT(request: NextRequest) {
console.error("Failed to send email:", emailError);
}
}
- } else if (type === "ea") {
+ } else if (type === "executive-associate") {
// First get the current application data to check if it was redirected
- const currentApplication = await prisma.eAApplication.findUnique({
- where: { id: applicationId },
- select: { status: true, redirection: true, studentNumber: true },
- });
+ const currentApplication =
+ await prisma.executiveAssociateApplication.findUnique({
+ where: { id: applicationId },
+ select: { status: true, redirection: true, studentNumber: true },
+ });
const updateData: {
hasAccepted?: boolean;
@@ -762,20 +932,40 @@ export async function PUT(request: NextRequest) {
updateData.redirection = redirection;
}
- updatedApplication = await prisma.eAApplication.update({
- where: { id: applicationId },
- data: updateData,
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- studentNumber: true,
- section: true,
+ updatedApplication = await prisma.$transaction(async (tx) => {
+ const application = await tx.executiveAssociateApplication.update({
+ where: { id: applicationId },
+ data: updateData,
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ studentNumber: true,
+ section: true,
+ },
},
},
- },
+ });
+
+ if (action !== "accept") {
+ return application;
+ }
+
+ const memberId = await ensureCycleMemberId(
+ tx,
+ application.user.id,
+ application.recruitmentCycleId,
+ );
+
+ return {
+ ...application,
+ user: {
+ ...application.user,
+ memberships: [{ memberId }],
+ },
+ };
});
// Send appropriate email based on action
@@ -823,7 +1013,7 @@ export async function PUT(request: NextRequest) {
emailTemplates.executiveAssistantRedirectedToMember(
updatedApplication.user.name,
updatedApplication.user.id,
- updatedApplication.firstOptionEb || "Executive Assistant",
+ updatedApplication.firstOptionEb || "Executive Associate",
);
await sendEmail(
updatedApplication.user.email,
@@ -839,7 +1029,7 @@ export async function PUT(request: NextRequest) {
emailTemplates.executiveAssistantRedirectedToCommittee(
updatedApplication.user.name,
updatedApplication.user.id,
- updatedApplication.firstOptionEb || "Executive Assistant",
+ updatedApplication.firstOptionEb || "Executive Associate",
committeeId,
);
await sendEmail(
@@ -855,7 +1045,7 @@ export async function PUT(request: NextRequest) {
const emailTemplate = emailTemplates.executiveAssistantRedirected(
updatedApplication.user.name,
updatedApplication.user.id,
- updatedApplication.firstOptionEb || "Executive Assistant",
+ updatedApplication.firstOptionEb || "Executive Associate",
redirection,
);
await sendEmail(
diff --git a/src/app/api/admin/applications/search/route.ts b/src/app/api/admin/applications/search/route.ts
index d2d459e..3de8060 100644
--- a/src/app/api/admin/applications/search/route.ts
+++ b/src/app/api/admin/applications/search/route.ts
@@ -3,6 +3,18 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";
import { getPositionTitle, getRoleId } from "@/lib/eb-mapping";
+import { committeeRoles } from "@/data/committeeRoles";
+
+const normalizeCommitteeId = (value: string) => {
+ const normalizedValue = value.toLowerCase().replace(/&/g, "and");
+ const committee = committeeRoles.find(
+ ({ id, title }) =>
+ id.toLowerCase() === normalizedValue ||
+ title.toLowerCase().replace(/&/g, "and") === normalizedValue,
+ );
+
+ return committee?.id ?? value;
+};
// GET search applications across all pages
export async function GET(request: NextRequest) {
@@ -16,6 +28,7 @@ export async function GET(request: NextRequest) {
// Check if user has admin access
const userRole = session.user.role;
const hasAdminAccess = userRole === "admin" || userRole === "super_admin";
+ const isSuperAdmin = userRole === "super_admin";
if (!hasAdminAccess) {
return NextResponse.json(
@@ -110,9 +123,30 @@ export async function GET(request: NextRequest) {
.filter(Boolean)
.map((value) => value.toLowerCase());
+ const ebProfile = await prisma.eBProfile.findFirst({
+ where: {
+ OR: [
+ { position: { equals: position, mode: "insensitive" } },
+ { position: { equals: positionTitle, mode: "insensitive" } },
+ { position: { equals: roleId, mode: "insensitive" } },
+ ],
+ },
+ select: { committees: true },
+ });
+ const accessibleCommittees = new Set(
+ ebProfile?.committees.map(normalizeCommitteeId) ?? [],
+ );
+
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
// Search committee applications
const allCommApplications = await prisma.committeeApplication.findMany({
where: {
+ recruitmentCycleId: activeCycleId,
OR: [
{
user: {
@@ -149,76 +183,104 @@ export async function GET(request: NextRequest) {
email: true,
studentNumber: true,
section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
});
-
// Filter committee applications (exclude truly processed ones)
- const commApplications = allCommApplications.filter((app: typeof allCommApplications[number]) => {
- const isAccepted = app.hasAccepted && app.status === "passed";
- const isRejected = app.status === "failed";
- const isRedirected = app.status === "redirected";
+ const commApplications = allCommApplications.filter(
+ (app: (typeof allCommApplications)[number]) => {
+ const isAccepted = app.hasAccepted && app.status === "passed";
+ const isRejected = app.status === "failed";
+ const isRedirected = app.status === "redirected";
- return !isAccepted && !isRejected && !isRedirected;
- });
+ const hasCommitteeAccess =
+ isSuperAdmin ||
+ accessibleCommittees.has(
+ normalizeCommitteeId(app.firstOptionCommittee),
+ ) ||
+ accessibleCommittees.has(
+ normalizeCommitteeId(app.secondOptionCommittee),
+ );
+
+ return (
+ hasCommitteeAccess && !isAccepted && !isRejected && !isRedirected
+ );
+ },
+ );
// Search EA applications
- const allEAApplications = await prisma.eAApplication.findMany({
- where: {
- OR: [
- {
- user: {
- name: {
- contains: query,
- mode: "insensitive",
+ const allExecutiveAssociateApplications =
+ await prisma.executiveAssociateApplication.findMany({
+ where: {
+ recruitmentCycleId: activeCycleId,
+ OR: [
+ {
+ user: {
+ name: {
+ contains: query,
+ mode: "insensitive",
+ },
},
},
- },
- {
- user: {
- studentNumber: {
- contains: query,
- mode: "insensitive",
+ {
+ user: {
+ studentNumber: {
+ contains: query,
+ mode: "insensitive",
+ },
},
},
- },
- {
- user: {
- email: {
- contains: query,
- mode: "insensitive",
+ {
+ user: {
+ email: {
+ contains: query,
+ mode: "insensitive",
+ },
+ },
+ },
+ ],
+ },
+ orderBy: { createdAt: "desc" },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ studentNumber: true,
+ section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
},
},
- },
- ],
- },
- orderBy: { createdAt: "desc" },
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- studentNumber: true,
- section: true,
},
},
- },
- });
+ });
// Filter EA applications (exclude truly processed ones)
- const eAApplications = allEAApplications.filter((app: typeof allEAApplications[number]) => {
- const isAccepted = app.hasAccepted && app.status === "passed";
- const isRejected = app.status === "failed";
- const isRedirected = app.status === "redirected";
+ const executiveAssociateApplications =
+ allExecutiveAssociateApplications.filter(
+ (app: (typeof allExecutiveAssociateApplications)[number]) => {
+ const isAccepted = app.hasAccepted && app.status === "passed";
+ const isRejected = app.status === "failed";
+ const isRedirected = app.status === "redirected";
- return !isAccepted && !isRejected && !isRedirected;
- });
+ return !isAccepted && !isRejected && !isRedirected;
+ },
+ );
// Search member applications
const memberApplications = await prisma.memberApplication.findMany({
where: {
+ recruitmentCycleId: activeCycleId,
OR: [
{
user: {
@@ -255,6 +317,11 @@ export async function GET(request: NextRequest) {
email: true,
studentNumber: true,
section: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
@@ -262,52 +329,60 @@ export async function GET(request: NextRequest) {
// Add CV and Portfolio download links for Committee applications
applications.committee = await Promise.all(
- commApplications.map(async (application: typeof commApplications[number]) => {
- const cvDownloadUrl = application.supabaseFilePath
- ? `/api/admin/cv-download?applicationId=${application.id}&type=committee`
- : null;
+ commApplications.map(
+ async (application: (typeof commApplications)[number]) => {
+ const cvDownloadUrl = application.supabaseFilePath
+ ? `/api/admin/cv-download?applicationId=${application.id}&type=committee`
+ : null;
- const portfolioDownloadUrl = application.portfolioLink
- ? `/api/admin/portfolio-download?applicationId=${application.id}`
- : null;
+ const portfolioDownloadUrl = application.portfolioLink
+ ? `/api/admin/portfolio-download?applicationId=${application.id}`
+ : null;
- return {
- ...application,
- type: "committee",
- isAssigned: Boolean(
- application.interviewBy &&
+ return {
+ ...application,
+ type: "committee",
+ isAssigned: Boolean(
+ application.interviewBy &&
assignmentValues.includes(application.interviewBy.toLowerCase()),
- ),
- cvDownloadUrl,
- portfolioDownloadUrl,
- };
- }),
+ ),
+ cvDownloadUrl,
+ portfolioDownloadUrl,
+ };
+ },
+ ),
);
// Add CV download links for EA applications
applications.ea = await Promise.all(
- eAApplications.map(async (application: typeof eAApplications[number]) => {
- const cvDownloadUrl = application.supabaseFilePath
- ? `/api/admin/cv-download?applicationId=${application.id}&type=ea`
- : null;
+ executiveAssociateApplications.map(
+ async (
+ application: (typeof executiveAssociateApplications)[number],
+ ) => {
+ const cvDownloadUrl = application.supabaseFilePath
+ ? `/api/admin/cv-download?applicationId=${application.id}&type=executive-associate`
+ : null;
- return {
- ...application,
- type: "ea",
- isAssigned: Boolean(
- application.interviewBy &&
+ return {
+ ...application,
+ type: "executive-associate",
+ isAssigned: Boolean(
+ application.interviewBy &&
assignmentValues.includes(application.interviewBy.toLowerCase()),
- ),
- cvDownloadUrl,
- };
- }),
+ ),
+ cvDownloadUrl,
+ };
+ },
+ ),
);
- applications.member = memberApplications.map((application: typeof memberApplications[number]) => ({
- ...application,
- type: "member",
- isAssigned: true,
- }));
+ applications.member = memberApplications.map(
+ (application: (typeof memberApplications)[number]) => ({
+ ...application,
+ type: "member",
+ isAssigned: true,
+ }),
+ );
return NextResponse.json({
success: true,
diff --git a/src/app/api/admin/available-executive-associate-roles/route.ts b/src/app/api/admin/available-executive-associate-roles/route.ts
new file mode 100644
index 0000000..6a816ea
--- /dev/null
+++ b/src/app/api/admin/available-executive-associate-roles/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { roles } from "@/data/ebRoles";
+import { prisma } from "@/lib/prisma";
+
+const CONFIG_KEY = "available_executive_associate_roles";
+
+function isSuperAdmin(role?: string) {
+ return role === "super_admin" || role === "super-admin";
+}
+
+function defaultAvailability() {
+ return Object.fromEntries(roles.map((role) => [role.id, true]));
+}
+
+export async function GET() {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!isSuperAdmin(session.user.role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const config = await prisma.systemConfig.findUnique({
+ where: { key: CONFIG_KEY },
+ });
+
+ const availability = {
+ ...defaultAvailability(),
+ ...(config ? JSON.parse(config.value) : {}),
+ };
+
+ return NextResponse.json({ availability });
+ } catch (error) {
+ console.error("Get available executive associate roles error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!isSuperAdmin(session.user.role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const { availability } = await request.json();
+
+ if (!availability || typeof availability !== "object") {
+ return NextResponse.json(
+ { error: "Availability map is required" },
+ { status: 400 },
+ );
+ }
+
+ const sanitizedAvailability = Object.fromEntries(
+ roles.map((role) => [role.id, Boolean(availability[role.id])]),
+ );
+
+ await prisma.systemConfig.upsert({
+ where: { key: CONFIG_KEY },
+ update: { value: JSON.stringify(sanitizedAvailability) },
+ create: {
+ key: CONFIG_KEY,
+ value: JSON.stringify(sanitizedAvailability),
+ description: "Executive Associate EB roles available for applicants",
+ },
+ });
+
+ return NextResponse.json({ availability: sanitizedAvailability });
+ } catch (error) {
+ console.error("Update available executive associate roles error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/admin/check-conflicts/route.ts b/src/app/api/admin/check-conflicts/route.ts
index 0c7a9a5..3aeec89 100644
--- a/src/app/api/admin/check-conflicts/route.ts
+++ b/src/app/api/admin/check-conflicts/route.ts
@@ -15,9 +15,16 @@ export async function GET() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
// Check for conflicts in EA Applications
- const eaConflicts = await prisma.eAApplication.findMany({
+ const eaConflicts = await prisma.executiveAssociateApplication.findMany({
where: {
+ recruitmentCycleId: activeCycleId,
AND: [
{ interviewSlotDay: { not: null } },
{ interviewSlotTimeStart: { not: null } },
@@ -48,6 +55,7 @@ export async function GET() {
// Check for conflicts in Committee Applications
const committeeConflicts = await prisma.committeeApplication.findMany({
where: {
+ recruitmentCycleId: activeCycleId,
AND: [
{ interviewSlotDay: { not: null } },
{ interviewSlotTimeStart: { not: null } },
diff --git a/src/app/api/admin/community-link/route.ts b/src/app/api/admin/community-link/route.ts
new file mode 100644
index 0000000..4da6f91
--- /dev/null
+++ b/src/app/api/admin/community-link/route.ts
@@ -0,0 +1,110 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+const URL_KEY = "community_group_url";
+const LABEL_KEY = "community_group_label";
+const ENABLED_KEY = "community_group_enabled";
+
+function isSuperAdmin(role?: string) {
+ return role === "super_admin" || role === "super-admin";
+}
+
+async function getCommunityLink() {
+ const configs = await prisma.systemConfig.findMany({
+ where: { key: { in: [URL_KEY, LABEL_KEY, ENABLED_KEY] } },
+ });
+
+ const configMap = new Map(configs.map((config) => [config.key, config.value]));
+
+ return {
+ enabled: configMap.get(ENABLED_KEY) !== "false",
+ url: configMap.get(URL_KEY)?.trim() || "",
+ label: configMap.get(LABEL_KEY)?.trim() || "Join Community Group",
+ };
+}
+
+export async function GET() {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!isSuperAdmin(session.user.role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ return NextResponse.json(await getCommunityLink());
+ } catch (error) {
+ console.error("Get admin community link error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!isSuperAdmin(session.user.role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const { enabled, url, label } = await request.json();
+
+ if (!url || typeof url !== "string") {
+ return NextResponse.json({ error: "Community URL is required" }, { status: 400 });
+ }
+
+ if (!label || typeof label !== "string") {
+ return NextResponse.json({ error: "Button label is required" }, { status: 400 });
+ }
+
+ await prisma.$transaction([
+ prisma.systemConfig.upsert({
+ where: { key: URL_KEY },
+ update: { value: url.trim() },
+ create: {
+ key: URL_KEY,
+ value: url.trim(),
+ description: "Community group invite URL shown to accepted applicants",
+ },
+ }),
+ prisma.systemConfig.upsert({
+ where: { key: LABEL_KEY },
+ update: { value: label.trim() },
+ create: {
+ key: LABEL_KEY,
+ value: label.trim(),
+ description: "Community group button label shown to accepted applicants",
+ },
+ }),
+ prisma.systemConfig.upsert({
+ where: { key: ENABLED_KEY },
+ update: { value: enabled === false ? "false" : "true" },
+ create: {
+ key: ENABLED_KEY,
+ value: enabled === false ? "false" : "true",
+ description: "Whether to show the community group card to accepted applicants",
+ },
+ }),
+ ]);
+
+ return NextResponse.json(await getCommunityLink());
+ } catch (error) {
+ console.error("Update community link error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/admin/cv-download/route.ts b/src/app/api/admin/cv-download/route.ts
index 56d551e..d70edf2 100644
--- a/src/app/api/admin/cv-download/route.ts
+++ b/src/app/api/admin/cv-download/route.ts
@@ -26,7 +26,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const applicationId = searchParams.get("applicationId");
- const type = searchParams.get("type"); // 'ea' or 'committee'
+ const type = searchParams.get("type"); // 'executive-associate' or 'committee'
if (!applicationId || !type) {
return NextResponse.json(
@@ -38,8 +38,8 @@ export async function GET(request: NextRequest) {
let application;
let supabaseFilePath: string | null = null;
- if (type === "ea") {
- application = await prisma.eAApplication.findUnique({
+ if (type === "executive-associate") {
+ application = await prisma.executiveAssociateApplication.findUnique({
where: { id: applicationId },
include: {
user: {
@@ -70,7 +70,7 @@ export async function GET(request: NextRequest) {
supabaseFilePath = application?.supabaseFilePath || null;
} else {
return NextResponse.json(
- { error: "Invalid type parameter. Must be 'ea' or 'committee'" },
+ { error: "Invalid type parameter. Must be 'executive-associate' or 'committee'" },
{ status: 400 },
);
}
@@ -143,7 +143,7 @@ export async function GET(request: NextRequest) {
} else {
// It's just a file path, use the old method
const bucketName =
- type === "ea" ? "ea-applications" : "committee-applications";
+ type === "executive-associate" ? "executive-associate-applications" : "committee-applications";
const { data, error } = await supabase.storage
.from(bucketName)
diff --git a/src/app/api/admin/download-pdf/route.ts b/src/app/api/admin/download-pdf/route.ts
index 33efb50..52b424b 100644
--- a/src/app/api/admin/download-pdf/route.ts
+++ b/src/app/api/admin/download-pdf/route.ts
@@ -27,7 +27,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const applicationId = searchParams.get("applicationId");
const type = searchParams.get("type"); // 'cv' or 'portfolio'
- const applicationType = searchParams.get("applicationType"); // 'ea' or 'committee'
+ const applicationType = searchParams.get("applicationType"); // 'executive-associate' or 'committee'
if (!applicationId || !type || !applicationType) {
return NextResponse.json(
@@ -41,8 +41,8 @@ export async function GET(request: NextRequest) {
let fileName: string;
// Get application data based on type
- if (applicationType === "ea") {
- application = await prisma.eAApplication.findUnique({
+ if (applicationType === "executive-associate") {
+ application = await prisma.executiveAssociateApplication.findUnique({
where: { id: applicationId },
include: {
user: {
@@ -136,8 +136,8 @@ export async function GET(request: NextRequest) {
} else {
// It's just a file path, determine bucket based on application type
bucketName =
- applicationType === "ea"
- ? "ea-applications"
+ applicationType === "executive-associate"
+ ? "executive-associate-applications"
: "committee-applications";
filePath = supabaseFilePath;
}
diff --git a/src/app/api/admin/eb-profiles/route.ts b/src/app/api/admin/eb-profiles/route.ts
index 4c4dd50..3a89ad6 100644
--- a/src/app/api/admin/eb-profiles/route.ts
+++ b/src/app/api/admin/eb-profiles/route.ts
@@ -2,6 +2,18 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
+import { committeeRoles } from "@/data/committeeRoles";
+
+const normalizeCommitteeId = (value: string) => {
+ const normalizedValue = value.toLowerCase().replace(/&/g, "and");
+ const committee = committeeRoles.find(
+ ({ id, title }) =>
+ id.toLowerCase() === normalizedValue ||
+ title.toLowerCase().replace(/&/g, "and") === normalizedValue,
+ );
+
+ return committee?.id ?? value;
+};
export async function POST(request: NextRequest) {
try {
@@ -32,21 +44,41 @@ export async function POST(request: NextRequest) {
);
}
- // Create or update EB profile
+ const normalizedCommittees = Array.isArray(committees)
+ ? Array.from(
+ new Set(
+ committees
+ .filter(
+ (committee): committee is string =>
+ typeof committee === "string",
+ )
+ .map(normalizeCommitteeId),
+ ),
+ )
+ : [];
+
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+
+ // Create or update EB profile for the current active academic year
const ebProfile = await prisma.eBProfile.upsert({
where: { userId },
update: {
position,
- committees: committees || [],
+ committees: normalizedCommittees,
isActive: isActive ?? true,
meetingLink: meetingLink ?? null,
+ recruitmentCycleId: activeCycle?.id ?? null,
},
create: {
userId,
position,
- committees: committees || [],
+ committees: normalizedCommittees,
isActive: isActive ?? true,
meetingLink: meetingLink ?? null,
+ recruitmentCycleId: activeCycle?.id ?? null,
},
});
diff --git a/src/app/api/admin/export/csv/route.ts b/src/app/api/admin/export/csv/route.ts
index 5c113c5..3bcaf10 100644
--- a/src/app/api/admin/export/csv/route.ts
+++ b/src/app/api/admin/export/csv/route.ts
@@ -2,6 +2,15 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
+import { getDisplayMemberId } from "@/lib/member-id";
+
+async function getActiveCycleId() {
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ return activeCycle?.id ?? "__no_active_cycle__";
+}
export async function GET(request: NextRequest) {
try {
@@ -44,9 +53,9 @@ export async function GET(request: NextRequest) {
filename = `accepted-committee-applications-${committee || "all"}-${new Date().toISOString().split("T")[0]}.csv`;
break;
- case "ea":
- csvData = await exportEAApplications(status);
- filename = `accepted-ea-applications-${new Date().toISOString().split("T")[0]}.csv`;
+ case "executive-associate":
+ csvData = await exportExecutiveAssociateApplications(status);
+ filename = `accepted-executive-associate-applications-${new Date().toISOString().split("T")[0]}.csv`;
break;
default:
@@ -72,8 +81,10 @@ export async function GET(request: NextRequest) {
}
async function exportMemberApplications(_status: string | null) {
+ const activeCycleId = await getActiveCycleId();
const whereClause: Record = {
hasAccepted: true, // Only export accepted member applications
+ recruitmentCycleId: activeCycleId,
};
// Note: For member applications, we only export accepted ones
@@ -89,6 +100,14 @@ async function exportMemberApplications(_status: string | null) {
email: true,
studentNumber: true,
section: true,
+ age: true,
+ dateOfBirth: true,
+ isOldCssMember: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
@@ -100,6 +119,9 @@ async function exportMemberApplications(_status: string | null) {
"Email",
"Student Number",
"Section",
+ "Age",
+ "Birthday",
+ "Old CSS Member",
"Member ID",
"Status",
"Payment Proof",
@@ -107,12 +129,15 @@ async function exportMemberApplications(_status: string | null) {
"Updated Date",
];
- const rows = applications.map((app: typeof applications[number]) => [
+ const rows = applications.map((app: (typeof applications)[number]) => [
app.user.name,
app.user.email,
app.user.studentNumber || "",
app.user.section || "",
- app.user.id.slice(-7).toUpperCase(), // Truncated Member ID
+ app.user.age?.toString() || "",
+ formatDate(app.user.dateOfBirth),
+ formatBoolean(app.user.isOldCssMember),
+ getDisplayMemberId(app.user),
"Accepted", // All member applications in CSV are accepted
app.paymentProof || "",
app.createdAt.toISOString().split("T")[0],
@@ -126,7 +151,10 @@ async function exportCommitteeApplications(
committee: string | null,
_status: string | null,
) {
- const whereClause: Record = {};
+ const activeCycleId = await getActiveCycleId();
+ const whereClause: Record = {
+ recruitmentCycleId: activeCycleId,
+ };
if (committee && committee !== "all") {
// For committee-specific exports, we need to be more precise about what to include:
@@ -180,6 +208,14 @@ async function exportCommitteeApplications(
email: true,
studentNumber: true,
section: true,
+ age: true,
+ dateOfBirth: true,
+ isOldCssMember: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
@@ -191,6 +227,9 @@ async function exportCommitteeApplications(
"Email",
"Student Number",
"Section",
+ "Age",
+ "Birthday",
+ "Old CSS Member",
"Member ID",
"First Option Committee",
"Second Option Committee",
@@ -203,12 +242,15 @@ async function exportCommitteeApplications(
"Updated Date",
];
- const rows = applications.map((app: typeof applications[number]) => [
+ const rows = applications.map((app: (typeof applications)[number]) => [
app.user.name,
app.user.email,
app.user.studentNumber || "",
app.user.section || "",
- app.user.id.slice(-7).toUpperCase(), // Truncated Member ID
+ app.user.age?.toString() || "",
+ formatDate(app.user.dateOfBirth),
+ formatBoolean(app.user.isOldCssMember),
+ getDisplayMemberId(app.user),
app.firstOptionCommittee || "",
app.secondOptionCommittee || "",
app.redirection ? "Redirected" : app.hasAccepted ? "Accepted" : "Pending",
@@ -223,16 +265,18 @@ async function exportCommitteeApplications(
return generateCSV(headers, rows);
}
-async function exportEAApplications(_status: string | null) {
+async function exportExecutiveAssociateApplications(_status: string | null) {
+ const activeCycleId = await getActiveCycleId();
const whereClause: Record = {
hasAccepted: true, // Only export accepted EA applications
redirection: null, // Exclude redirected applications
+ recruitmentCycleId: activeCycleId,
};
// Note: For EA applications, we only export accepted ones that were NOT redirected
// Redirected EA applications should not be included in EA CSV
- const applications = await prisma.eAApplication.findMany({
+ const applications = await prisma.executiveAssociateApplication.findMany({
where: whereClause,
include: {
user: {
@@ -242,6 +286,14 @@ async function exportEAApplications(_status: string | null) {
email: true,
studentNumber: true,
section: true,
+ age: true,
+ dateOfBirth: true,
+ isOldCssMember: true,
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
},
},
@@ -253,6 +305,9 @@ async function exportEAApplications(_status: string | null) {
"Email",
"Student Number",
"Section",
+ "Age",
+ "Birthday",
+ "Old CSS Member",
"Member ID",
"EB Role",
"First Option EB",
@@ -266,12 +321,15 @@ async function exportEAApplications(_status: string | null) {
"Updated Date",
];
- const rows = applications.map((app: typeof applications[number]) => [
+ const rows = applications.map((app: (typeof applications)[number]) => [
app.user.name,
app.user.email,
app.user.studentNumber || "",
app.user.section || "",
- app.user.id.slice(-7).toUpperCase(), // Truncated Member ID
+ app.user.age?.toString() || "",
+ formatDate(app.user.dateOfBirth),
+ formatBoolean(app.user.isOldCssMember),
+ getDisplayMemberId(app.user),
app.ebRole || "",
app.firstOptionEb || "",
app.secondOptionEb || "",
@@ -287,6 +345,15 @@ async function exportEAApplications(_status: string | null) {
return generateCSV(headers, rows);
}
+function formatDate(date: Date | null) {
+ return date ? date.toISOString().split("T")[0] : "";
+}
+
+function formatBoolean(value: boolean | null) {
+ if (value === null) return "";
+ return value ? "Yes" : "No";
+}
+
function generateCSV(headers: string[], rows: string[][]) {
const csvContent = [
headers.join(","),
diff --git a/src/app/api/admin/files/cleanup-staged/route.ts b/src/app/api/admin/files/cleanup-staged/route.ts
index c4ad2c3..2132c3d 100644
--- a/src/app/api/admin/files/cleanup-staged/route.ts
+++ b/src/app/api/admin/files/cleanup-staged/route.ts
@@ -85,7 +85,7 @@ export async function POST(request: NextRequest) {
const cutoff = new Date(Date.now() - maxAgeHours * 60 * 60 * 1000);
const [eaApps, committeeApps] = await Promise.all([
- prisma.eAApplication.findMany({
+ prisma.executiveAssociateApplication.findMany({
select: {
supabaseFilePath: true,
cv: true,
@@ -118,9 +118,9 @@ export async function POST(request: NextRequest) {
}
}
- const buckets = ["ea-applications", "committee-applications"];
+ const buckets = ["executive-associate-applications", "committee-applications"];
const candidatesByBucket: Record = {
- "ea-applications": [],
+ "executive-associate-applications": [],
"committee-applications": [],
};
@@ -151,7 +151,7 @@ export async function POST(request: NextRequest) {
}
const deletedByBucket: Record = {
- "ea-applications": [],
+ "executive-associate-applications": [],
"committee-applications": [],
};
@@ -182,11 +182,11 @@ export async function POST(request: NextRequest) {
cutoffIso: cutoff.toISOString(),
referencedPathCount: referencedPaths.size,
candidates: {
- ea: candidatesByBucket["ea-applications"].length,
+ ea: candidatesByBucket["executive-associate-applications"].length,
committee: candidatesByBucket["committee-applications"].length,
},
deleted: {
- ea: deletedByBucket["ea-applications"].length,
+ ea: deletedByBucket["executive-associate-applications"].length,
committee: deletedByBucket["committee-applications"].length,
},
details: {
diff --git a/src/app/api/admin/interview-slots/[position]/route.ts b/src/app/api/admin/interview-slots/[position]/route.ts
index 3e21595..4cd53d5 100644
--- a/src/app/api/admin/interview-slots/[position]/route.ts
+++ b/src/app/api/admin/interview-slots/[position]/route.ts
@@ -16,16 +16,11 @@ export async function GET(
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- // Check if user has admin access
const userRole = session.user.role;
- const hasAdminAccess = userRole === "admin" || userRole === "super_admin";
-
- if (!hasAdminAccess) {
- return NextResponse.json(
- { error: "Forbidden - Admin access required" },
- { status: 403 },
- );
- }
+ const hasAdminAccess =
+ userRole === "admin" ||
+ userRole === "super_admin" ||
+ userRole === "super-admin";
const { position } = await params;
@@ -34,6 +29,12 @@ export async function GET(
// For position titles or committee names, use as-is but ensure consistent casing
const normalizedPosition = getPositionTitle(position);
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
const applications: Array<{
id: string;
interviewSlotDay: string | null;
@@ -44,6 +45,7 @@ export async function GET(
const committeeApplicationsSlots =
await prisma.committeeApplication.findMany({
where: {
+ recruitmentCycleId: activeCycleId,
interviewBy: {
equals: normalizedPosition,
mode: "insensitive",
@@ -67,43 +69,55 @@ export async function GET(
});
applications.push(...committeeApplicationsSlots);
- const eaApplicationsSlots = await prisma.eAApplication.findMany({
- where: {
- interviewBy: {
- equals: normalizedPosition,
- mode: "insensitive",
+ const executiveAssociateApplicationsSlots =
+ await prisma.executiveAssociateApplication.findMany({
+ where: {
+ recruitmentCycleId: activeCycleId,
+ interviewBy: {
+ equals: normalizedPosition,
+ mode: "insensitive",
+ },
},
- },
- select: {
- id: true,
- interviewSlotDay: true,
- interviewSlotTimeStart: true,
- interviewSlotTimeEnd: true,
- interviewBy: true,
- user: {
- select: {
- name: true,
+ select: {
+ id: true,
+ interviewSlotDay: true,
+ interviewSlotTimeStart: true,
+ interviewSlotTimeEnd: true,
+ interviewBy: true,
+ user: {
+ select: {
+ name: true,
+ },
},
},
- },
- orderBy: [{ interviewSlotDay: "asc" }, { interviewSlotTimeStart: "asc" }],
- });
- applications.push(...eaApplicationsSlots);
+ orderBy: [
+ { interviewSlotDay: "asc" },
+ { interviewSlotTimeStart: "asc" },
+ ],
+ });
+ applications.push(...executiveAssociateApplicationsSlots);
- // Get meeting link from EBProfile
- let meetingLink = null;
- const ebProfile = await prisma.eBProfile.findFirst({
- where: {
- position: normalizedPosition,
- },
- });
- meetingLink = ebProfile?.meetingLink || null;
+ const meetingLink = hasAdminAccess && activeCycle
+ ? (
+ await prisma.eBProfile.findFirst({
+ where: {
+ recruitmentCycleId: activeCycle?.id,
+ isActive: true,
+ position: {
+ equals: normalizedPosition,
+ mode: "insensitive",
+ },
+ },
+ select: { meetingLink: true },
+ })
+ )?.meetingLink || null
+ : null;
const slots = applications.map((application) => ({
id: application.id,
day: application.interviewSlotDay,
- name: application.user.name,
- meetingLink: meetingLink,
+ name: hasAdminAccess ? application.user.name : "Booked",
+ meetingLink,
timeStart: application.interviewSlotTimeStart,
timeEnd: application.interviewSlotTimeEnd,
}));
diff --git a/src/app/api/admin/payment-qr/route.ts b/src/app/api/admin/payment-qr/route.ts
new file mode 100644
index 0000000..8f632fc
--- /dev/null
+++ b/src/app/api/admin/payment-qr/route.ts
@@ -0,0 +1,118 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { supabase } from "@/lib/supabase";
+
+const CONFIG_KEY = "payment_qr_image_path";
+const BUCKET_NAME = "payment";
+
+function isSuperAdmin(role?: string) {
+ return role === "super_admin" || role === "super-admin";
+}
+
+export async function GET() {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!isSuperAdmin(session.user.role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const config = await prisma.systemConfig.findUnique({
+ where: { key: CONFIG_KEY },
+ });
+
+ return NextResponse.json({
+ url: config?.value
+ ? `/api/payment-qr/image?v=${encodeURIComponent(config.value)}`
+ : "",
+ });
+ } catch (error) {
+ console.error("Get admin payment QR error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ if (!isSuperAdmin(session.user.role)) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const formData = await request.formData();
+ const file = formData.get("file") as File | null;
+
+ if (!file) {
+ return NextResponse.json({ error: "QR image is required" }, { status: 400 });
+ }
+
+ if (!file.type.startsWith("image/")) {
+ return NextResponse.json(
+ { error: "Only image files are allowed" },
+ { status: 400 },
+ );
+ }
+
+ const maxSize = 5 * 1024 * 1024;
+ if (file.size > maxSize) {
+ return NextResponse.json(
+ { error: "Image size must be less than 5MB" },
+ { status: 400 },
+ );
+ }
+
+ const extension = file.name.split(".").pop() || "png";
+ const filePath = `payment/payment-qr-${Date.now()}.${extension}`;
+ const arrayBuffer = await file.arrayBuffer();
+
+ const { error: uploadError } = await supabase.storage
+ .from(BUCKET_NAME)
+ .upload(filePath, new Uint8Array(arrayBuffer), {
+ cacheControl: "3600",
+ contentType: file.type,
+ upsert: true,
+ });
+
+ if (uploadError) {
+ console.error("Payment QR upload error:", uploadError);
+ return NextResponse.json(
+ { error: "Failed to upload QR image" },
+ { status: 500 },
+ );
+ }
+
+ await prisma.systemConfig.upsert({
+ where: { key: CONFIG_KEY },
+ update: { value: filePath },
+ create: {
+ key: CONFIG_KEY,
+ value: filePath,
+ description: "Supabase storage path for payment QR image",
+ },
+ });
+
+ return NextResponse.json({
+ url: `/api/payment-qr/image?v=${encodeURIComponent(filePath)}`,
+ });
+ } catch (error) {
+ console.error("Update payment QR error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/admin/payment-receipt-template/route.ts b/src/app/api/admin/payment-receipt-template/route.ts
new file mode 100644
index 0000000..7653e18
--- /dev/null
+++ b/src/app/api/admin/payment-receipt-template/route.ts
@@ -0,0 +1,58 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { supabase } from "@/lib/supabase";
+
+const CONFIG_KEY = "payment_receipt_template_path";
+const BUCKET_NAME = "payment";
+
+function isSuperAdmin(role?: string) {
+ return role === "super_admin" || role === "super-admin";
+}
+
+export async function GET() {
+ const session = await getServerSession(authOptions);
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!isSuperAdmin(session.user.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const config = await prisma.systemConfig.findUnique({ where: { key: CONFIG_KEY } });
+ return NextResponse.json({ url: config?.value ? `/api/payment-receipt-template/file?v=${encodeURIComponent(config.value)}` : "" });
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (!isSuperAdmin(session.user.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const formData = await request.formData();
+ const file = formData.get("file") as File | null;
+ if (!file) return NextResponse.json({ error: "PDF is required" }, { status: 400 });
+ if (file.type !== "application/pdf") return NextResponse.json({ error: "Only PDF files are allowed" }, { status: 400 });
+ if (file.size > 10 * 1024 * 1024) return NextResponse.json({ error: "PDF must be less than 10MB" }, { status: 400 });
+
+ const filePath = `payment/receipt-template-${Date.now()}.pdf`;
+ const buffer = new Uint8Array(await file.arrayBuffer());
+ const { error } = await supabase.storage.from(BUCKET_NAME).upload(filePath, buffer, {
+ cacheControl: "3600",
+ contentType: "application/pdf",
+ upsert: true,
+ });
+ if (error) {
+ console.error("Receipt template upload error:", error);
+ return NextResponse.json({ error: "Failed to upload receipt template" }, { status: 500 });
+ }
+
+ await prisma.systemConfig.upsert({
+ where: { key: CONFIG_KEY },
+ update: { value: filePath },
+ create: { key: CONFIG_KEY, value: filePath, description: "Payment acknowledgement receipt PDF template" },
+ });
+
+ return NextResponse.json({ url: `/api/payment-receipt-template/file?v=${encodeURIComponent(filePath)}` });
+ } catch (error) {
+ console.error("Update receipt template error:", error);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/admin/recruitment-cycle/route.ts b/src/app/api/admin/recruitment-cycle/route.ts
index 4468f7c..9a13dab 100644
--- a/src/app/api/admin/recruitment-cycle/route.ts
+++ b/src/app/api/admin/recruitment-cycle/route.ts
@@ -2,17 +2,49 @@ import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
+import { Prisma } from "@prisma/client";
+
+const toDateOnlyTimestamp = (value: string) => {
+ const [year, month, day] = value.split("-").map(Number);
+ return Date.UTC(year, month - 1, day);
+};
+
+const getTodayDateOnlyTimestamp = () => {
+ const now = new Date();
+ return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
+};
+
+const isPrismaUniqueError = (error: unknown) =>
+ typeof error === "object" &&
+ error !== null &&
+ "code" in error &&
+ error.code === "P2002";
// GET recruitment cycles (all + active)
export async function GET() {
try {
- const [cycles, activeCycle] = await Promise.all([
- prisma.recruitmentCycle.findMany({ orderBy: { createdAt: "desc" } }),
- prisma.recruitmentCycle.findFirst({
- where: { isActive: true },
- orderBy: { createdAt: "desc" },
- }),
- ]);
+ const session = await getServerSession(authOptions);
+
+ if (!session) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const userRole = session.user.role;
+ const hasAdminAccess =
+ userRole === "admin" ||
+ userRole === "super_admin" ||
+ userRole === "super-admin";
+
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ });
+
+ const cycles = hasAdminAccess
+ ? await prisma.recruitmentCycle.findMany({
+ orderBy: { createdAt: "desc" },
+ })
+ : [];
return NextResponse.json({ cycles, activeCycle });
} catch (error) {
@@ -61,41 +93,82 @@ export async function POST(request: NextRequest) {
);
}
- // If this cycle is being set active, deactivate all others
- if (isActive) {
- await prisma.recruitmentCycle.updateMany({
- where: { isActive: true },
- data: { isActive: false },
- });
+ const applicationStartTime = toDateOnlyTimestamp(applicationStart);
+ const interviewStartTime = toDateOnlyTimestamp(interviewStart);
+ const interviewEndTime = toDateOnlyTimestamp(interviewEnd);
+ const todayTime = getTodayDateOnlyTimestamp();
+
+ if (
+ applicationStartTime < todayTime ||
+ interviewStartTime < todayTime ||
+ interviewEndTime < todayTime
+ ) {
+ return NextResponse.json(
+ { error: "Recruitment cycle dates cannot be set in the past" },
+ { status: 400 },
+ );
}
- let cycle;
- if (id) {
- cycle = await prisma.recruitmentCycle.update({
- where: { id },
- data: {
- schoolYear,
- applicationStart: new Date(applicationStart),
- interviewStart: new Date(interviewStart),
- interviewEnd: new Date(interviewEnd),
- isActive: isActive ?? false,
- },
- });
- } else {
- cycle = await prisma.recruitmentCycle.create({
- data: {
- schoolYear,
- applicationStart: new Date(applicationStart),
- interviewStart: new Date(interviewStart),
- interviewEnd: new Date(interviewEnd),
- isActive: isActive ?? false,
- },
- });
+ if (interviewStartTime < applicationStartTime) {
+ return NextResponse.json(
+ { error: "Interview start cannot be before application start" },
+ { status: 400 },
+ );
}
+ if (interviewEndTime < interviewStartTime) {
+ return NextResponse.json(
+ { error: "Interview last day cannot be before interview start" },
+ { status: 400 },
+ );
+ }
+
+ const cycle = await prisma.$transaction(async (tx) => {
+ // Serialize activation changes so concurrent requests cannot leave two
+ // recruitment cycles active.
+ await tx.$queryRaw(Prisma.sql`
+ SELECT pg_advisory_xact_lock(hashtext('active-recruitment-cycle'))
+ `);
+
+ if (isActive) {
+ await tx.recruitmentCycle.updateMany({
+ where: { isActive: true },
+ data: { isActive: false },
+ });
+ }
+
+ const cycleData = {
+ applicationStart: new Date(applicationStart),
+ interviewStart: new Date(interviewStart),
+ interviewEnd: new Date(interviewEnd),
+ isActive: isActive ?? false,
+ };
+
+ if (id) {
+ return tx.recruitmentCycle.update({
+ where: { id },
+ data: { schoolYear, ...cycleData },
+ });
+ }
+
+ return tx.recruitmentCycle.upsert({
+ where: { schoolYear },
+ update: cycleData,
+ create: { schoolYear, ...cycleData },
+ });
+ });
+
return NextResponse.json({ success: true, cycle });
} catch (error) {
console.error("Error managing recruitment cycle:", error);
+
+ if (isPrismaUniqueError(error)) {
+ return NextResponse.json(
+ { error: "A recruitment cycle for this school year already exists" },
+ { status: 409 },
+ );
+ }
+
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
diff --git a/src/app/api/admin/resolve-conflict/route.ts b/src/app/api/admin/resolve-conflict/route.ts
index 6ec2681..57e7770 100644
--- a/src/app/api/admin/resolve-conflict/route.ts
+++ b/src/app/api/admin/resolve-conflict/route.ts
@@ -26,8 +26,8 @@ export async function POST(request: NextRequest) {
let updatedApplication;
- if (applicationType === "ea") {
- updatedApplication = await prisma.eAApplication.update({
+ if (applicationType === "executive-associate") {
+ updatedApplication = await prisma.executiveAssociateApplication.updateMany({
where: { studentNumber },
data: {
interviewSlotDay: null,
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
},
});
} else if (applicationType === "committee") {
- updatedApplication = await prisma.committeeApplication.update({
+ updatedApplication = await prisma.committeeApplication.updateMany({
where: { studentNumber },
data: {
interviewSlotDay: null,
@@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
});
} else {
return NextResponse.json(
- { error: 'Invalid application type. Must be "ea" or "committee"' },
+ { error: 'Invalid application type. Must be "executive-associate" or "committee"' },
{ status: 400 },
);
}
diff --git a/src/app/api/admin/test-email/route.ts b/src/app/api/admin/test-email/route.ts
index 247a1cd..d8363f7 100644
--- a/src/app/api/admin/test-email/route.ts
+++ b/src/app/api/admin/test-email/route.ts
@@ -44,7 +44,7 @@ export async function POST(request: NextRequest) {
html = committeeTemplate.html;
break;
- case "executive_assistant_application":
+ case "executive_associate_application":
const eaTemplate = emailTemplates.executiveAssistantApplication(
testName,
"2024XXXX",
@@ -75,7 +75,7 @@ export async function POST(request: NextRequest) {
html = committeeAcceptedTemplate.html;
break;
- case "executive_assistant_accepted":
+ case "executive_associate_accepted":
const eaAcceptedTemplate = emailTemplates.executiveAssistantAccepted(
testName,
"test123",
@@ -94,7 +94,7 @@ export async function POST(request: NextRequest) {
html = committeeRejectedTemplate.html;
break;
- case "executive_assistant_rejected":
+ case "executive_associate_rejected":
const eaRejectedTemplate = emailTemplates.executiveAssistantRejected(
testName,
"President",
@@ -114,6 +114,33 @@ export async function POST(request: NextRequest) {
html = committeeRedirectedTemplate.html;
break;
+ case "member_id_released":
+ const memberIdReleasedTemplate = emailTemplates.memberIdReleased(
+ testName,
+ "TEST123"
+ );
+ subject = memberIdReleasedTemplate.subject;
+ html = memberIdReleasedTemplate.html;
+ break;
+
+ case "payment_reminder":
+ const paymentReminderTemplate = emailTemplates.paymentReminder(
+ testName
+ );
+ subject = paymentReminderTemplate.subject;
+ html = paymentReminderTemplate.html;
+ break;
+
+ case "css_group_join":
+ const cssGroupJoinTemplate = emailTemplates.cssGroupJoin(
+ testName,
+ "https://fb.me/g/6UCY6FrzU/L7r94Zcj",
+ "Join UST CSS Members 25'-26' Group"
+ );
+ subject = cssGroupJoinTemplate.subject;
+ html = cssGroupJoinTemplate.html;
+ break;
+
default:
return NextResponse.json(
{ error: "Invalid template type" },
diff --git a/src/app/api/admin/unavailable-slots/[id]/route.ts b/src/app/api/admin/unavailable-slots/[id]/route.ts
index a5f29ba..367a27c 100644
--- a/src/app/api/admin/unavailable-slots/[id]/route.ts
+++ b/src/app/api/admin/unavailable-slots/[id]/route.ts
@@ -12,10 +12,9 @@ export async function GET(
try {
const session = await getServerSession(authOptions);
- if (!session) {
+ if (!session || !session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
-
const { id } = await params;
const unavailableSlots = await prisma.availableEBInterviewTime.findMany({
@@ -25,13 +24,15 @@ export async function GET(
orderBy: [{ day: "asc" }, { timeStart: "asc" }],
});
- const unavailableSlotsData = unavailableSlots.map((slot: typeof unavailableSlots[number]) => ({
- id: slot.id,
- date: slot.day,
- timeSlot: `${slot.timeStart}-${slot.timeEnd}`,
- startTime: slot.timeStart,
- endTime: slot.timeEnd,
- }));
+ const unavailableSlotsData = unavailableSlots.map(
+ (slot: (typeof unavailableSlots)[number]) => ({
+ id: slot.id,
+ date: slot.day,
+ timeSlot: `${slot.timeStart}-${slot.timeEnd}`,
+ startTime: slot.timeStart,
+ endTime: slot.timeEnd,
+ }),
+ );
return NextResponse.json({ unavailableSlotsData });
} catch (error) {
diff --git a/src/app/api/admin/users/all/route.ts b/src/app/api/admin/users/all/route.ts
index feea98f..7095e00 100644
--- a/src/app/api/admin/users/all/route.ts
+++ b/src/app/api/admin/users/all/route.ts
@@ -1,3 +1,5 @@
+export const dynamic = "force-dynamic";
+
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
@@ -27,6 +29,11 @@ export async function GET(request: Request) {
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "10");
const skip = (page - 1) * limit;
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
// Get total count for pagination
const totalCount = await prisma.user.count();
@@ -52,7 +59,7 @@ export async function GET(request: Request) {
// Count total applicants across all application types
Promise.all([
prisma.memberApplication.count(),
- prisma.eAApplication.count(),
+ prisma.executiveAssociateApplication.count(),
prisma.committeeApplication.count(),
]).then(
([memberCount, eaCount, committeeCount]) =>
@@ -65,6 +72,7 @@ export async function GET(request: Request) {
select: {
id: true,
email: true,
+ image: true,
name: true,
role: true,
studentNumber: true,
@@ -78,6 +86,11 @@ export async function GET(request: Request) {
meetingLink: true,
},
},
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
},
orderBy: {
createdAt: "desc",
diff --git a/src/app/api/admin/users/search/route.ts b/src/app/api/admin/users/search/route.ts
index bfdc0c4..ece73f8 100644
--- a/src/app/api/admin/users/search/route.ts
+++ b/src/app/api/admin/users/search/route.ts
@@ -1,3 +1,5 @@
+export const dynamic = "force-dynamic";
+
import { authOptions } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
@@ -69,7 +71,7 @@ export async function GET(request: NextRequest) {
updatedAt: true,
},
},
- memberApplication: {
+ memberApplications: {
select: {
id: true,
hasAccepted: true,
@@ -77,7 +79,7 @@ export async function GET(request: NextRequest) {
createdAt: true,
},
},
- eaApplication: {
+ executiveAssociateApplications: {
select: {
id: true,
hasAccepted: true,
@@ -94,7 +96,7 @@ export async function GET(request: NextRequest) {
createdAt: true,
},
},
- committeeApplication: {
+ committeeApplications: {
select: {
id: true,
hasAccepted: true,
@@ -137,7 +139,7 @@ export async function GET(request: NextRequest) {
// Count total applicants across all application types
Promise.all([
prisma.memberApplication.count(),
- prisma.eAApplication.count(),
+ prisma.executiveAssociateApplication.count(),
prisma.committeeApplication.count(),
]).then(
([memberCount, eaCount, committeeCount]) =>
diff --git a/src/app/api/applications/check-existing/route.ts b/src/app/api/applications/check-existing/route.ts
index a5701d2..a8490b0 100644
--- a/src/app/api/applications/check-existing/route.ts
+++ b/src/app/api/applications/check-existing/route.ts
@@ -11,6 +11,14 @@ export async function GET() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
+
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
// Use a more efficient query with only necessary fields
const user = await prisma.user.findUnique({
where: { email: session.user.email },
@@ -19,22 +27,28 @@ export async function GET() {
email: true,
name: true,
role: true,
- memberApplication: {
+ memberApplications: {
+ where: { recruitmentCycleId: activeCycleId },
select: {
id: true,
},
+ take: 1,
},
- committeeApplication: {
+ committeeApplications: {
+ where: { recruitmentCycleId: activeCycleId },
select: {
id: true,
firstOptionCommittee: true,
},
+ take: 1,
},
- eaApplication: {
+ executiveAssociateApplications: {
+ where: { recruitmentCycleId: activeCycleId },
select: {
id: true,
firstOptionEb: true,
},
+ take: 1,
},
},
});
@@ -53,7 +67,7 @@ export async function GET() {
const existingApplications = {
hasMemberApplication: false,
hasCommitteeApplication: false,
- hasEAApplication: false,
+ hasExecutiveAssociateApplication: false,
applications: {
member: null,
committee: null,
@@ -74,17 +88,17 @@ export async function GET() {
}
const existingApplications = {
- hasMemberApplication: !!user.memberApplication,
- hasCommitteeApplication: !!user.committeeApplication,
- hasEAApplication: !!user.eaApplication,
+ hasMemberApplication: !!user.memberApplications?.[0],
+ hasCommitteeApplication: !!user.committeeApplications?.[0],
+ hasExecutiveAssociateApplication: !!user.executiveAssociateApplications?.[0],
applications: {
- member: user.memberApplication,
- committee: user.committeeApplication,
- ea: user.eaApplication,
+ member: user.memberApplications?.[0],
+ committee: user.committeeApplications?.[0],
+ ea: user.executiveAssociateApplications?.[0],
},
// ADD these for proper redirects
- ebRole: user.eaApplication?.firstOptionEb,
- committeeId: user.committeeApplication?.firstOptionCommittee,
+ ebRole: user.executiveAssociateApplications?.[0]?.firstOptionEb,
+ committeeId: user.committeeApplications?.[0]?.firstOptionCommittee,
};
return NextResponse.json(existingApplications);
diff --git a/src/app/api/applications/committee-staff/eb/[committee]/route.ts b/src/app/api/applications/committee-staff/eb/[committee]/route.ts
index ecc2796..6282538 100644
--- a/src/app/api/applications/committee-staff/eb/[committee]/route.ts
+++ b/src/app/api/applications/committee-staff/eb/[committee]/route.ts
@@ -2,7 +2,6 @@ import { authOptions } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";
-import { getCommitteeEBRoleFromCommitteeId } from "@/data/committeeRoles";
// GET all applications with filtering
export async function GET(
@@ -17,14 +16,25 @@ export async function GET(
}
const { committee } = await params;
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
+
+ if (!activeCycle) {
+ return NextResponse.json({ success: true, ebs: [] });
+ }
const ebs = await prisma.eBProfile.findMany({
select: {
position: true,
},
where: {
+ recruitmentCycleId: activeCycle.id,
+ isActive: true,
committees: {
- has: getCommitteeEBRoleFromCommitteeId(committee),
+ has: committee,
},
},
});
diff --git a/src/app/api/applications/committee-staff/route.ts b/src/app/api/applications/committee-staff/route.ts
index 27b5168..2173b5f 100644
--- a/src/app/api/applications/committee-staff/route.ts
+++ b/src/app/api/applications/committee-staff/route.ts
@@ -4,148 +4,122 @@ import { prisma } from "@/lib/prisma";
import { authOptions } from "@/lib/auth";
import { supabase } from "@/lib/supabase";
import { getPositionTitle } from "@/lib/eb-mapping";
+import { committeeApplicationSchema } from "@/lib/schemas";
+import {
+ assertNoOtherApplication,
+ assertStudentNumberOwnership,
+ assertValidCommitteeChoices,
+ getApplicationRuleResponse,
+ getOpenApplicationCycle,
+ lockApplicantCycle,
+} from "@/lib/application-rules";
function normalizeStoragePath(fileRef: string | null | undefined) {
if (!fileRef) return null;
if (!fileRef.startsWith("http")) return fileRef;
- const urlMatch = fileRef.match(
- /\/storage\/v1\/object\/(?:public|sign)\/[^\/]+\/(.+?)(?:\?|$)/,
+ const match = fileRef.match(
+ /\/storage\/v1\/object\/(?:public|sign)\/[^/]+\/(.+?)(?:\?|$)/,
);
-
- return urlMatch?.[1] ?? null;
+ return match?.[1] ?? null;
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
-
- if (!session || !session?.user?.email) {
+ if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- const {
- studentNumber,
- firstName,
- lastName,
- section,
- firstOptionCommittee,
- secondOptionCommittee,
- cv,
- portfolio,
- } = await request.json();
-
- if (
- !studentNumber ||
- !section ||
- !firstOptionCommittee ||
- !secondOptionCommittee ||
- !cv
- ) {
+ const parsed = committeeApplicationSchema.safeParse(await request.json());
+ if (!parsed.success) {
return NextResponse.json(
- { error: "All fields are required" },
+ { error: parsed.error.issues[0]?.message || "Invalid application" },
{ status: 400 },
);
}
- if (!/^\d{10}$/.test(studentNumber)) {
- return NextResponse.json(
- { error: "Student number must be 10 digits" },
- { status: 400 },
- );
- }
+ const cycle = await getOpenApplicationCycle();
+ const data = parsed.data;
+ assertValidCommitteeChoices(
+ data.firstOptionCommittee,
+ data.secondOptionCommittee,
+ );
- const normalizedCvPath = normalizeStoragePath(cv);
- const normalizedPortfolioPath = portfolio
- ? normalizeStoragePath(portfolio)
+ const cvPath = normalizeStoragePath(data.cv);
+ const portfolioPath = data.portfolio
+ ? normalizeStoragePath(data.portfolio)
: null;
-
- if (!normalizedCvPath) {
- return NextResponse.json(
- { error: "Invalid CV file reference" },
- { status: 400 },
- );
- }
-
- if (portfolio && !normalizedPortfolioPath) {
+ if (!cvPath || (data.portfolio && !portfolioPath)) {
return NextResponse.json(
- { error: "Invalid portfolio file reference" },
+ { error: "Invalid uploaded file reference" },
{ status: 400 },
);
}
- const existingUserWithSN = await prisma.user.findUnique({
- where: { studentNumber },
- });
-
- if (existingUserWithSN && existingUserWithSN.email !== session.user.email) {
- return NextResponse.json(
- { error: "This student number is already registered by another user" },
- { status: 400 },
+ const updatedUser = await prisma.$transaction(async (tx) => {
+ await lockApplicantCycle(tx, session.user.email!, cycle.id);
+ await assertStudentNumberOwnership(
+ tx,
+ data.studentNumber,
+ session.user.email!,
);
- }
-
- // Check for already-accepted application BEFORE updating user data
- const existingApplication = await prisma.committeeApplication.findUnique({
- where: { studentNumber },
- });
-
- if (existingApplication?.hasAccepted) {
- return NextResponse.json(
- { error: "You already have an accepted committee application" },
- { status: 400 },
+ await assertNoOtherApplication(
+ tx,
+ session.user.email!,
+ cycle.id,
+ "committee",
);
- }
-
- const updatedUser = await prisma.user.update({
- where: { email: session.user.email },
- data: {
- studentNumber,
- section,
- name: `${firstName} ${lastName}`.trim(),
- },
- });
- if (!existingApplication) {
- await prisma.committeeApplication.create({
- data: {
- studentNumber,
- firstOptionCommittee,
- secondOptionCommittee,
- cv: normalizedCvPath,
- portfolioLink: normalizedPortfolioPath,
- supabaseFilePath: normalizedCvPath,
- interviewSlotDay: "",
- interviewSlotTimeStart: "",
- interviewSlotTimeEnd: "",
- hasAccepted: false,
- hasFinishedInterview: false,
- status: null,
- redirection: null,
+ const existing = await tx.committeeApplication.findFirst({
+ where: {
+ recruitmentCycleId: cycle.id,
+ user: { email: session.user.email! },
},
});
-
- // Application created successfully - email will be sent when schedule is selected
- } else {
- if (existingApplication.hasAccepted) {
- return NextResponse.json(
- { error: "You already have an accepted committee application" },
- { status: 400 },
- );
+ if (existing?.hasAccepted) {
+ throw new Error("ACCEPTED_COMMITTEE_APPLICATION");
}
- // Update existing non-accepted application (NO EMAIL SENT)
- await prisma.committeeApplication.update({
- where: { studentNumber },
+ const user = await tx.user.update({
+ where: { email: session.user.email! },
data: {
- firstOptionCommittee,
- secondOptionCommittee,
- cv: normalizedCvPath,
- portfolioLink: normalizedPortfolioPath,
- supabaseFilePath: normalizedCvPath,
+ studentNumber: data.studentNumber,
+ section: data.section,
+ age: data.age,
+ dateOfBirth: new Date(`${data.dateOfBirth}T00:00:00Z`),
+ isOldCssMember: data.isOldCssMember,
+ name: `${data.firstName} ${data.lastName}`.trim(),
},
});
- }
+
+ const applicationData = {
+ firstOptionCommittee: data.firstOptionCommittee,
+ secondOptionCommittee: data.secondOptionCommittee,
+ cv: cvPath,
+ portfolioLink: portfolioPath,
+ supabaseFilePath: cvPath,
+ };
+
+ if (existing) {
+ await tx.committeeApplication.update({
+ where: { id: existing.id },
+ data: applicationData,
+ });
+ } else {
+ await tx.committeeApplication.create({
+ data: {
+ studentNumber: data.studentNumber,
+ recruitmentCycleId: cycle.id,
+ ...applicationData,
+ hasAccepted: false,
+ hasFinishedInterview: false,
+ },
+ });
+ }
+
+ return user;
+ });
return NextResponse.json({
success: true,
@@ -154,140 +128,179 @@ export async function POST(request: NextRequest) {
"Committee application submitted successfully. Please proceed to schedule your interview.",
});
} catch (error) {
- console.error("Committee application error:", error);
- if (error instanceof Error && error.message.includes("Unique constraint")) {
+ if (error instanceof SyntaxError) {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const ruleError = getApplicationRuleResponse(error);
+ if (ruleError) {
+ return NextResponse.json(ruleError.body, { status: ruleError.status });
+ }
+ if (
+ error instanceof Error &&
+ error.message === "ACCEPTED_COMMITTEE_APPLICATION"
+ ) {
+ return NextResponse.json(
+ { error: "You already have an accepted committee application" },
+ { status: 409 },
+ );
+ }
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "code" in error &&
+ error.code === "P2002"
+ ) {
return NextResponse.json(
{ error: "This student number already has an application" },
- { status: 400 },
+ { status: 409 },
);
}
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
+ console.error(
+ "Committee application error",
+ error instanceof Error ? error.name : "UnknownError",
);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function GET() {
try {
const session = await getServerSession(authOptions);
-
- if (!session) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
const user = await prisma.user.findUnique({
where: { email: session.user.email },
- include: { committeeApplication: true },
+ include: {
+ committeeApplications: {
+ where: { recruitmentCycleId: activeCycleId },
+ take: 1,
+ },
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
+ },
});
-
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
- let meetingLink = null;
- if (user.committeeApplication?.interviewBy) {
- const positionTitle = getPositionTitle(
- user.committeeApplication.interviewBy,
- );
-
- const ebProfile = await prisma.eBProfile.findFirst({
- where: {
- position: positionTitle,
- },
- });
- meetingLink = ebProfile?.meetingLink || null;
+ const application = user.committeeApplications[0] ?? null;
+ let meetingLink: string | null = null;
+ if (application?.interviewBy && activeCycle) {
+ meetingLink =
+ (
+ await prisma.eBProfile.findFirst({
+ where: {
+ recruitmentCycleId: activeCycle.id,
+ isActive: true,
+ position: {
+ equals: getPositionTitle(application.interviewBy),
+ mode: "insensitive",
+ },
+ },
+ select: { meetingLink: true },
+ })
+ )?.meetingLink ?? null;
}
return NextResponse.json({
- hasApplication: !!user.committeeApplication,
- application: user.committeeApplication,
+ hasApplication: Boolean(application),
+ application,
user: {
id: user.id,
studentNumber: user.studentNumber,
name: user.name,
section: user.section,
+ age: user.age,
+ dateOfBirth: user.dateOfBirth,
+ isOldCssMember: user.isOldCssMember,
+ memberships: user.memberships,
},
- meetingLink: meetingLink,
+ meetingLink,
});
} catch (error) {
- console.error("Get Committee Application error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
+ console.error(
+ "Get committee application error",
+ error instanceof Error ? error.name : "UnknownError",
);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function DELETE() {
try {
const session = await getServerSession(authOptions);
-
- if (!session || !session?.user?.email) {
+ if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- // Get user data
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
const user = await prisma.user.findUnique({
where: { email: session.user.email },
- include: { committeeApplication: true },
+ include: {
+ committeeApplications: {
+ where: {
+ recruitmentCycleId: activeCycle?.id ?? "__no_active_cycle__",
+ },
+ take: 1,
+ },
+ },
});
-
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
- if (!user.committeeApplication) {
+ const application = user.committeeApplications[0];
+ if (!application) {
+ return NextResponse.json({ error: "No application found" }, { status: 404 });
+ }
+ if (application.hasAccepted) {
return NextResponse.json(
- { error: "No application found" },
- { status: 404 },
+ { error: "Accepted applications cannot be deleted" },
+ { status: 409 },
);
}
- // Delete files from Supabase storage if they exist
- try {
- const cvPath = normalizeStoragePath(user.committeeApplication.supabaseFilePath);
- if (cvPath) {
- await supabase.storage
- .from("committee-applications")
- .remove([cvPath]);
+ const paths = [
+ normalizeStoragePath(application.supabaseFilePath),
+ normalizeStoragePath(application.portfolioLink),
+ ].filter((path): path is string => Boolean(path));
+ if (paths.length > 0) {
+ const { error: storageError } = await supabase.storage
+ .from("committee-applications")
+ .remove(paths);
+ if (storageError) {
+ console.error("Committee file cleanup failed", storageError.name);
}
-
- // Also check if there's a portfolio file to delete
- if (user.committeeApplication.portfolioLink) {
- const portfolioPath = normalizeStoragePath(
- user.committeeApplication.portfolioLink,
- );
- if (portfolioPath) {
- await supabase.storage
- .from("committee-applications")
- .remove([portfolioPath]);
- }
- }
- } catch (storageError) {
- console.error("Error deleting files from storage:", storageError);
- // Continue with application deletion even if file deletion fails
}
- // Delete the committee application
- await prisma.committeeApplication.delete({
- where: { studentNumber: user.studentNumber! },
- });
-
+ await prisma.committeeApplication.delete({ where: { id: application.id } });
return NextResponse.json({
success: true,
message: "Application deleted successfully",
});
} catch (error) {
- console.error("Delete application error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
+ console.error(
+ "Delete committee application error",
+ error instanceof Error ? error.name : "UnknownError",
);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
diff --git a/src/app/api/applications/committee-staff/schedule/route.ts b/src/app/api/applications/committee-staff/schedule/route.ts
index 375cf9e..e2fa0ad 100644
--- a/src/app/api/applications/committee-staff/schedule/route.ts
+++ b/src/app/api/applications/committee-staff/schedule/route.ts
@@ -7,209 +7,152 @@ import {
sendEmailWithValidation,
getEBEmail,
} from "@/lib/email";
-import { getPositionTitle, getRoleId } from "@/lib/eb-mapping";
+import { getRoleId } from "@/lib/eb-mapping";
import { roles } from "@/data/ebRoles";
+import { scheduleSchema } from "@/lib/schemas";
+import {
+ getActiveCycle,
+ getApplicationRuleResponse,
+ validateAndLockInterviewSlot,
+} from "@/lib/application-rules";
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
-
- if (!session || !session?.user?.email) {
+ if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- const {
- interviewSlotDay,
- interviewSlotTimeStart,
- interviewSlotTimeEnd,
- interviewBy,
- } = await request.json();
-
- if (
- !interviewSlotDay ||
- !interviewSlotTimeStart ||
- !interviewSlotTimeEnd ||
- !interviewBy
- ) {
+ const parsed = scheduleSchema.safeParse(await request.json());
+ if (!parsed.success) {
return NextResponse.json(
- { error: "All schedule fields are required" },
+ { error: parsed.error.issues[0]?.message || "Invalid interview slot" },
{ status: 400 },
);
}
- const user = await prisma.user.findUnique({
- where: { email: session.user.email },
- include: { committeeApplication: true },
- });
-
- if (!user || !user.studentNumber) {
- return NextResponse.json({ error: "User not found" }, { status: 404 });
- }
-
- if (!user.committeeApplication) {
- return NextResponse.json(
- { error: "Committee application not found" },
- { status: 400 },
- );
- }
-
- // Use the student number from the DB (session), not from the request body
- const studentNumber = user.studentNumber;
-
- // Check for slot conflicts before updating - check BOTH EA and Committee applications
- const existingEABookings = await prisma.eAApplication.findMany({
- where: {
- AND: [
- { interviewSlotDay },
- { interviewSlotTimeStart },
- { interviewSlotTimeEnd },
- { interviewBy },
- ],
- },
- });
-
- const existingCommitteeBookings =
- await prisma.committeeApplication.findMany({
- where: {
- AND: [
- { interviewSlotDay },
- { interviewSlotTimeStart },
- { interviewSlotTimeEnd },
- { interviewBy },
- { studentNumber: { not: studentNumber } }, // Exclude current user
- ],
+ const cycle = await getActiveCycle();
+ const slot = parsed.data;
+ const result = await prisma.$transaction(async (tx) => {
+ const user = await tx.user.findUnique({
+ where: { email: session.user.email! },
+ include: {
+ committeeApplications: {
+ where: { recruitmentCycleId: cycle.id },
+ take: 1,
+ },
},
});
+ if (!user?.studentNumber) throw new Error("SCHEDULE_USER_NOT_FOUND");
+
+ const application = user.committeeApplications[0];
+ if (!application) throw new Error("COMMITTEE_APPLICATION_NOT_FOUND");
+ if (application.hasAccepted) throw new Error("APPLICATION_ALREADY_ACCEPTED");
+
+ const profile = await validateAndLockInterviewSlot(tx, cycle, {
+ day: slot.interviewSlotDay,
+ start: slot.interviewSlotTimeStart,
+ end: slot.interviewSlotTimeEnd,
+ interviewBy: slot.interviewBy,
+ applicationType: "committee",
+ applicationId: application.id,
+ committeeId: application.firstOptionCommittee,
+ });
- const totalConflicts =
- existingEABookings.length + existingCommitteeBookings.length;
-
- if (totalConflicts > 0) {
- return NextResponse.json(
- {
- error:
- "This time slot is no longer available. Please select another time slot.",
- conflict: true,
+ const updatedApplication = await tx.committeeApplication.update({
+ where: { id: application.id },
+ data: {
+ interviewBy: profile.position,
+ interviewSlotDay: slot.interviewSlotDay,
+ interviewSlotTimeStart: slot.interviewSlotTimeStart,
+ interviewSlotTimeEnd: slot.interviewSlotTimeEnd,
},
- { status: 409 },
- );
- }
+ });
- const updatedApplication = await prisma.committeeApplication.update({
- where: { studentNumber },
- data: {
- interviewBy,
- interviewSlotDay,
- interviewSlotTimeStart,
- interviewSlotTimeEnd,
- },
+ return { user, application, updatedApplication, profile };
});
- // Send email notification with meeting link when schedule is selected
try {
- // Get the EB profile for the interviewer to get their meeting link
- // Convert EB role ID to position title for the query
- const positionTitle = getPositionTitle(interviewBy);
- const ebProfile = await prisma.eBProfile.findFirst({
- where: {
- position: positionTitle,
- },
- });
-
- const meetingLink = ebProfile?.meetingLink || null;
-
- // Send email to applicant
- const emailTemplate = emailTemplates.committeeApplication(
- user.name ?? "Applicant",
- user.committeeApplication.studentNumber,
- user.committeeApplication.firstOptionCommittee,
- user.committeeApplication.secondOptionCommittee,
- meetingLink || undefined,
- interviewBy,
+ const applicantTemplate = emailTemplates.committeeApplication(
+ result.user.name || "Applicant",
+ result.application.studentNumber,
+ result.application.firstOptionCommittee,
+ result.application.secondOptionCommittee,
+ result.profile.meetingLink || undefined,
+ result.profile.position,
);
await sendEmailWithValidation(
- user.email,
- emailTemplate.subject,
- emailTemplate.html,
+ result.user.email,
+ applicantTemplate.subject,
+ applicantTemplate.html,
"Committee Staff applicant confirmation",
);
- // Send email notification to EB interviewer with enhanced error handling
- try {
- // Convert position title to role ID if needed
- const roleId = getRoleId(interviewBy);
-
- const ebRole = roles.find((r) => r.id === roleId);
- const ebName = ebRole?.ebName || interviewBy;
- const ebEmail = getEBEmail(
- roleId,
- `Committee Staff interview notification for ${user.name}`,
- );
-
- // Format interview date and time
- const interviewDate = new Date(interviewSlotDay).toLocaleDateString(
+ const roleId = getRoleId(result.profile.position);
+ const ebName = roles.find((role) => role.id === roleId)?.ebName || result.profile.position;
+ const ebTemplate = emailTemplates.ebInterviewNotificationCommittee(
+ ebName,
+ result.user.name || "Applicant",
+ result.application.studentNumber,
+ result.application.firstOptionCommittee,
+ new Date(`${slot.interviewSlotDay}T00:00:00+08:00`).toLocaleDateString(
"en-US",
- {
- weekday: "long",
- year: "numeric",
- month: "long",
- day: "numeric",
- },
- );
- const interviewTime = `${interviewSlotTimeStart} - ${interviewSlotTimeEnd}`;
-
- const ebEmailTemplate = emailTemplates.ebInterviewNotificationCommittee(
- ebName,
- user.name ?? "Applicant",
- user.committeeApplication.studentNumber,
- user.committeeApplication.firstOptionCommittee,
- interviewDate,
- interviewTime,
- meetingLink || undefined,
- );
-
- await sendEmailWithValidation(
- ebEmail,
- ebEmailTemplate.subject,
- ebEmailTemplate.html,
- `Committee Staff interview notification to ${ebName}`,
- );
- } catch (ebEmailError) {
- console.error(
- "CRITICAL: Failed to send EB interview notification email:",
- ebEmailError,
- );
- // Don't fail the entire request, but log this as a critical error
- // The admin should be notified about this failure
- }
+ { weekday: "long", year: "numeric", month: "long", day: "numeric" },
+ ),
+ `${slot.interviewSlotTimeStart} - ${slot.interviewSlotTimeEnd}`,
+ result.profile.meetingLink || undefined,
+ );
+ await sendEmailWithValidation(
+ getEBEmail(roleId, "Committee Staff interview notification"),
+ ebTemplate.subject,
+ ebTemplate.html,
+ "Committee Staff interviewer notification",
+ );
} catch (emailError) {
console.error(
- "Failed to send committee staff schedule confirmation email:",
- emailError,
+ "Committee schedule email failed",
+ emailError instanceof Error ? emailError.name : "UnknownError",
);
}
return NextResponse.json({
success: true,
- application: updatedApplication,
+ application: result.updatedApplication,
message: "Interview schedule updated successfully",
});
} catch (error) {
- console.error("Schedule update error:", error);
+ if (error instanceof SyntaxError) {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const ruleError = getApplicationRuleResponse(error);
+ if (ruleError) {
+ return NextResponse.json(ruleError.body, { status: ruleError.status });
+ }
- if (
- error instanceof Error &&
- error.message.includes("Record to update not found")
- ) {
+ const knownErrors: Record = {
+ SCHEDULE_USER_NOT_FOUND: { error: "User not found", status: 404 },
+ COMMITTEE_APPLICATION_NOT_FOUND: {
+ error: "Committee application not found",
+ status: 404,
+ },
+ APPLICATION_ALREADY_ACCEPTED: {
+ error: "Accepted applications cannot be rescheduled",
+ status: 409,
+ },
+ };
+ if (error instanceof Error && knownErrors[error.message]) {
+ const response = knownErrors[error.message];
return NextResponse.json(
- { error: "Committee application not found" },
- { status: 404 },
+ { error: response.error },
+ { status: response.status },
);
}
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
+ console.error(
+ "Committee schedule update error",
+ error instanceof Error ? error.name : "UnknownError",
);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
diff --git a/src/app/api/applications/executive-assistant/route.ts b/src/app/api/applications/executive-assistant/route.ts
deleted file mode 100644
index 7a25048..0000000
--- a/src/app/api/applications/executive-assistant/route.ts
+++ /dev/null
@@ -1,272 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getServerSession } from "next-auth/next";
-import { prisma } from "@/lib/prisma";
-import { authOptions } from "@/lib/auth";
-import { supabase } from "@/lib/supabase";
-import { getPositionTitle } from "@/lib/eb-mapping";
-
-function normalizeStoragePath(fileRef: string | null | undefined) {
- if (!fileRef) return null;
- if (!fileRef.startsWith("http")) return fileRef;
-
- const urlMatch = fileRef.match(
- /\/storage\/v1\/object\/(?:public|sign)\/[^\/]+\/(.+?)(?:\?|$)/,
- );
-
- return urlMatch?.[1] ?? null;
-}
-
-export async function POST(request: NextRequest) {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session || !session?.user?.email) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const {
- studentNumber,
- firstName,
- lastName,
- section,
- ebRole,
- firstOptionEb,
- secondOptionEb,
- cv,
- } = await request.json();
-
- if (
- !studentNumber ||
- !section ||
- !ebRole ||
- !firstOptionEb ||
- !secondOptionEb ||
- !cv
- ) {
- return NextResponse.json(
- { error: "All fields are required" },
- { status: 400 },
- );
- }
-
- if (!/^\d{10}$/.test(studentNumber)) {
- return NextResponse.json(
- { error: "Student number must be 10 digits" },
- { status: 400 },
- );
- }
-
- const normalizedCvPath = normalizeStoragePath(cv);
- if (!normalizedCvPath) {
- return NextResponse.json(
- { error: "Invalid CV file reference" },
- { status: 400 },
- );
- }
-
- const existingUserWithSN = await prisma.user.findUnique({
- where: { studentNumber },
- });
-
- if (existingUserWithSN && existingUserWithSN.email !== session.user.email) {
- return NextResponse.json(
- { error: "This student number is already registered by another user" },
- { status: 400 },
- );
- }
-
- // Check for already-accepted application BEFORE updating user data
- const existingApplication = await prisma.eAApplication.findUnique({
- where: { studentNumber },
- });
-
- if (existingApplication?.hasAccepted) {
- return NextResponse.json(
- { error: "You already have an accepted EA application" },
- { status: 400 },
- );
- }
-
- const updatedUser = await prisma.user.update({
- where: { email: session.user.email },
- data: {
- studentNumber,
- section,
- name: `${firstName} ${lastName}`.trim(),
- },
- });
-
- if (!existingApplication) {
- await prisma.eAApplication.create({
- data: {
- studentNumber,
- ebRole,
- firstOptionEb,
- secondOptionEb,
- cv: normalizedCvPath,
- supabaseFilePath: normalizedCvPath,
- interviewSlotDay: "",
- interviewSlotTimeStart: "",
- interviewSlotTimeEnd: "",
- hasFinishedInterview: false,
- status: null,
- redirection: null,
- hasAccepted: false,
- },
- });
-
- // Application created successfully - email will be sent when schedule is selected
- } else {
- if (existingApplication.hasAccepted) {
- return NextResponse.json(
- { error: "You already have an accepted EA application" },
- { status: 400 },
- );
- }
-
- // Update existing non-accepted application (NO EMAIL SENT)
- await prisma.eAApplication.update({
- where: { studentNumber },
- data: {
- ebRole,
- firstOptionEb,
- secondOptionEb,
- cv: normalizedCvPath,
- supabaseFilePath: normalizedCvPath,
- },
- });
- }
-
- return NextResponse.json({
- success: true,
- user: updatedUser,
- message:
- "EA application submitted successfully. Please proceed to schedule your interview.",
- });
- } catch (error) {
- console.error("EA application error:", error);
- if (error instanceof Error && error.message.includes("Unique constraint")) {
- return NextResponse.json(
- { error: "This student number already has an application" },
- { status: 400 },
- );
- }
-
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
-
-export async function GET() {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- if (!session?.user?.email) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const user = await prisma.user.findUnique({
- where: { email: session.user.email },
- include: { eaApplication: true },
- });
-
- if (!user) {
- return NextResponse.json({ error: "User not found" }, { status: 404 });
- }
-
- // Fetch meeting link from EBProfile if interviewBy is set
- let meetingLink = null;
- if (user.eaApplication?.interviewBy) {
- // Convert EB role ID to position title for database lookup
- const positionTitle = getPositionTitle(user.eaApplication.interviewBy);
-
- const ebProfile = await prisma.eBProfile.findFirst({
- where: {
- position: positionTitle,
- },
- });
- meetingLink = ebProfile?.meetingLink || null;
- }
-
- return NextResponse.json({
- hasApplication: !!user.eaApplication,
- application: user.eaApplication,
- user: {
- id: user.id,
- studentNumber: user.studentNumber,
- name: user.name,
- section: user.section,
- },
- ebRole: user.eaApplication?.ebRole,
- meetingLink: meetingLink,
- });
- } catch (error) {
- console.error("Get EA Application error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
-
-export async function DELETE() {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session || !session?.user?.email) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- // Get user data
- const user = await prisma.user.findUnique({
- where: { email: session.user.email },
- include: { eaApplication: true },
- });
-
- if (!user) {
- return NextResponse.json({ error: "User not found" }, { status: 404 });
- }
-
- if (!user.eaApplication) {
- return NextResponse.json(
- { error: "No application found" },
- { status: 404 },
- );
- }
-
- // Delete files from Supabase storage if they exist
- try {
- const cvPath = normalizeStoragePath(user.eaApplication.supabaseFilePath);
- if (cvPath) {
- await supabase.storage
- .from("ea-applications")
- .remove([cvPath]);
- }
- } catch (storageError) {
- console.error("Error deleting files from storage:", storageError);
- // Continue with application deletion even if file deletion fails
- }
-
- // Delete the EA application
- await prisma.eAApplication.delete({
- where: { studentNumber: user.studentNumber! },
- });
-
- return NextResponse.json({
- success: true,
- message: "Application deleted successfully",
- });
- } catch (error) {
- console.error("Delete application error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
diff --git a/src/app/api/applications/executive-assistant/schedule/route.ts b/src/app/api/applications/executive-assistant/schedule/route.ts
deleted file mode 100644
index 8234fec..0000000
--- a/src/app/api/applications/executive-assistant/schedule/route.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { NextRequest, NextResponse } from "next/server";
-import { getServerSession } from "next-auth/next";
-import { prisma } from "@/lib/prisma";
-import { authOptions } from "@/lib/auth";
-import {
- emailTemplates,
- sendEmailWithValidation,
- getEBEmail,
-} from "@/lib/email";
-import { getPositionTitle, getRoleId } from "@/lib/eb-mapping";
-import { roles } from "@/data/ebRoles";
-
-export async function POST(request: NextRequest) {
- try {
- const session = await getServerSession(authOptions);
-
- if (!session || !session?.user?.email) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
- const {
- interviewSlotDay,
- interviewSlotTimeStart,
- interviewSlotTimeEnd,
- ebRole,
- interviewBy,
- } = await request.json();
-
- if (
- !interviewSlotDay ||
- !interviewSlotTimeStart ||
- !interviewSlotTimeEnd ||
- !ebRole ||
- !interviewBy
- ) {
- return NextResponse.json(
- { error: "All schedule fields are required" },
- { status: 400 },
- );
- }
-
- const user = await prisma.user.findUnique({
- where: { email: session.user.email },
- include: { eaApplication: true },
- });
-
- if (!user || !user.studentNumber) {
- return NextResponse.json({ error: "User not found" }, { status: 404 });
- }
-
- if (!user.eaApplication) {
- return NextResponse.json(
- { error: "EA application not found" },
- { status: 400 },
- );
- }
-
- // Use the student number from the DB (session), not from the request body
- const studentNumber = user.studentNumber;
-
- // Check for slot conflicts before updating - check BOTH EA and Committee applications
- const existingEABookings = await prisma.eAApplication.findMany({
- where: {
- AND: [
- { interviewSlotDay },
- { interviewSlotTimeStart },
- { interviewSlotTimeEnd },
- { interviewBy },
- { studentNumber: { not: studentNumber } }, // Exclude current user
- ],
- },
- });
-
- const existingCommitteeBookings =
- await prisma.committeeApplication.findMany({
- where: {
- AND: [
- { interviewSlotDay },
- { interviewSlotTimeStart },
- { interviewSlotTimeEnd },
- { interviewBy },
- ],
- },
- });
-
- const totalConflicts =
- existingEABookings.length + existingCommitteeBookings.length;
-
- if (totalConflicts > 0) {
- return NextResponse.json(
- {
- error:
- "This time slot is no longer available. Please select another time slot.",
- conflict: true,
- },
- { status: 409 },
- );
- }
-
- const updatedApplication = await prisma.eAApplication.update({
- where: { studentNumber },
- data: {
- interviewSlotDay,
- interviewSlotTimeStart,
- interviewSlotTimeEnd,
- interviewBy,
- },
- });
-
- // Send email notification with meeting link when schedule is selected
- try {
- // Get the EB profile for the interviewer to get their meeting link
- // Convert EB role ID to position title for the query
- const positionTitle = getPositionTitle(interviewBy);
- const ebProfile = await prisma.eBProfile.findFirst({
- where: {
- position: positionTitle,
- },
- });
-
- const meetingLink = ebProfile?.meetingLink || null;
-
- // Send email to applicant
- const emailTemplate = emailTemplates.executiveAssistantApplication(
- user.name ?? "Applicant",
- user.eaApplication.studentNumber,
- user.eaApplication.ebRole,
- user.eaApplication.firstOptionEb,
- user.eaApplication.secondOptionEb,
- meetingLink || undefined,
- interviewBy,
- );
- await sendEmailWithValidation(
- user.email,
- emailTemplate.subject,
- emailTemplate.html,
- "EA applicant confirmation",
- );
-
- // Send email notification to EB interviewer with enhanced error handling
- try {
- // Convert position title to role ID if needed
- const roleId = getRoleId(interviewBy);
-
- const ebRole = roles.find((r) => r.id === roleId);
- const ebName = ebRole?.ebName || interviewBy;
- const ebEmail = getEBEmail(
- roleId,
- `EA interview notification for ${user.name}`,
- );
-
- // Format interview date and time
- const interviewDate = new Date(interviewSlotDay).toLocaleDateString(
- "en-US",
- {
- weekday: "long",
- year: "numeric",
- month: "long",
- day: "numeric",
- },
- );
- const interviewTime = `${interviewSlotTimeStart} - ${interviewSlotTimeEnd}`;
-
- const ebEmailTemplate = emailTemplates.ebInterviewNotificationEA(
- ebName,
- user.name ?? "Applicant",
- user.eaApplication.studentNumber,
- user.eaApplication.ebRole,
- interviewDate,
- interviewTime,
- meetingLink || undefined,
- );
-
- await sendEmailWithValidation(
- ebEmail,
- ebEmailTemplate.subject,
- ebEmailTemplate.html,
- `EA interview notification to ${ebName}`,
- );
- } catch (ebEmailError) {
- console.error(
- "CRITICAL: Failed to send EB interview notification email:",
- ebEmailError,
- );
- // Don't fail the entire request, but log this as a critical error
- // The admin should be notified about this failure
- }
- } catch (emailError) {
- console.error(
- "Failed to send executive assistant schedule confirmation email:",
- emailError,
- );
- }
-
- return NextResponse.json({
- success: true,
- application: updatedApplication,
- message: "Interview schedule updated successfully",
- });
- } catch (error) {
- console.error("Schedule update error:", error);
-
- if (
- error instanceof Error &&
- error.message.includes("Record to update not found")
- ) {
- return NextResponse.json(
- { error: "EA application not found" },
- { status: 404 },
- );
- }
-
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
diff --git a/src/app/api/applications/executive-associate/route.ts b/src/app/api/applications/executive-associate/route.ts
new file mode 100644
index 0000000..a49d22e
--- /dev/null
+++ b/src/app/api/applications/executive-associate/route.ts
@@ -0,0 +1,312 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth/next";
+import { prisma } from "@/lib/prisma";
+import { authOptions } from "@/lib/auth";
+import { supabase } from "@/lib/supabase";
+import { getPositionTitle } from "@/lib/eb-mapping";
+import { executiveAssociateApplicationSchema } from "@/lib/schemas";
+import {
+ assertAvailableExecutiveAssociateChoices,
+ assertNoOtherApplication,
+ assertStudentNumberOwnership,
+ getApplicationRuleResponse,
+ getOpenApplicationCycle,
+ lockApplicantCycle,
+} from "@/lib/application-rules";
+
+function normalizeStoragePath(fileRef: string | null | undefined) {
+ if (!fileRef) return null;
+ if (!fileRef.startsWith("http")) return fileRef;
+
+ const match = fileRef.match(
+ /\/storage\/v1\/object\/(?:public|sign)\/[^/]+\/(.+?)(?:\?|$)/,
+ );
+ return match?.[1] ?? null;
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const parsed = executiveAssociateApplicationSchema.safeParse(
+ await request.json(),
+ );
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message || "Invalid application" },
+ { status: 400 },
+ );
+ }
+
+ const cycle = await getOpenApplicationCycle();
+ const data = parsed.data;
+ await assertAvailableExecutiveAssociateChoices(
+ prisma,
+ data.ebRole,
+ data.firstOptionEb,
+ data.secondOptionEb,
+ );
+
+ const cvPath = normalizeStoragePath(data.cv);
+ if (!cvPath) {
+ return NextResponse.json(
+ { error: "Invalid CV file reference" },
+ { status: 400 },
+ );
+ }
+
+ const updatedUser = await prisma.$transaction(async (tx) => {
+ await lockApplicantCycle(tx, session.user.email!, cycle.id);
+ await assertStudentNumberOwnership(
+ tx,
+ data.studentNumber,
+ session.user.email!,
+ );
+ await assertNoOtherApplication(
+ tx,
+ session.user.email!,
+ cycle.id,
+ "executive-associate",
+ );
+ await assertAvailableExecutiveAssociateChoices(
+ tx,
+ data.ebRole,
+ data.firstOptionEb,
+ data.secondOptionEb,
+ );
+
+ const existing = await tx.executiveAssociateApplication.findFirst({
+ where: {
+ recruitmentCycleId: cycle.id,
+ user: { email: session.user.email! },
+ },
+ });
+ if (existing?.hasAccepted) {
+ throw new Error("ACCEPTED_EXECUTIVE_ASSOCIATE_APPLICATION");
+ }
+
+ const user = await tx.user.update({
+ where: { email: session.user.email! },
+ data: {
+ studentNumber: data.studentNumber,
+ section: data.section,
+ age: data.age,
+ dateOfBirth: new Date(`${data.dateOfBirth}T00:00:00Z`),
+ isOldCssMember: data.isOldCssMember,
+ name: `${data.firstName} ${data.lastName}`.trim(),
+ },
+ });
+
+ const applicationData = {
+ ebRole: data.ebRole,
+ firstOptionEb: data.firstOptionEb,
+ secondOptionEb: data.secondOptionEb,
+ cv: cvPath,
+ supabaseFilePath: cvPath,
+ };
+
+ if (existing) {
+ await tx.executiveAssociateApplication.update({
+ where: { id: existing.id },
+ data: applicationData,
+ });
+ } else {
+ await tx.executiveAssociateApplication.create({
+ data: {
+ studentNumber: data.studentNumber,
+ recruitmentCycleId: cycle.id,
+ ...applicationData,
+ hasFinishedInterview: false,
+ hasAccepted: false,
+ },
+ });
+ }
+
+ return user;
+ });
+
+ return NextResponse.json({
+ success: true,
+ user: updatedUser,
+ message:
+ "Executive Associate application submitted successfully. Please proceed to schedule your interview.",
+ });
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const ruleError = getApplicationRuleResponse(error);
+ if (ruleError) {
+ return NextResponse.json(ruleError.body, { status: ruleError.status });
+ }
+ if (
+ error instanceof Error &&
+ error.message === "ACCEPTED_EXECUTIVE_ASSOCIATE_APPLICATION"
+ ) {
+ return NextResponse.json(
+ { error: "You already have an accepted Executive Associate application" },
+ { status: 409 },
+ );
+ }
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "code" in error &&
+ error.code === "P2002"
+ ) {
+ return NextResponse.json(
+ { error: "This student number already has an application" },
+ { status: 409 },
+ );
+ }
+
+ console.error(
+ "Executive Associate application error",
+ error instanceof Error ? error.name : "UnknownError",
+ );
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
+
+export async function GET() {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ include: {
+ executiveAssociateApplications: {
+ where: { recruitmentCycleId: activeCycleId },
+ take: 1,
+ },
+ memberships: {
+ where: { recruitmentCycleId: activeCycleId },
+ select: { memberId: true },
+ take: 1,
+ },
+ },
+ });
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ const application = user.executiveAssociateApplications[0] ?? null;
+ let meetingLink: string | null = null;
+ if (application?.interviewBy && activeCycle) {
+ meetingLink =
+ (
+ await prisma.eBProfile.findFirst({
+ where: {
+ recruitmentCycleId: activeCycle.id,
+ isActive: true,
+ position: {
+ equals: getPositionTitle(application.interviewBy),
+ mode: "insensitive",
+ },
+ },
+ select: { meetingLink: true },
+ })
+ )?.meetingLink ?? null;
+ }
+
+ return NextResponse.json({
+ hasApplication: Boolean(application),
+ application,
+ user: {
+ id: user.id,
+ studentNumber: user.studentNumber,
+ name: user.name,
+ section: user.section,
+ age: user.age,
+ dateOfBirth: user.dateOfBirth,
+ isOldCssMember: user.isOldCssMember,
+ memberships: user.memberships,
+ },
+ ebRole: application?.ebRole,
+ meetingLink,
+ });
+ } catch (error) {
+ console.error(
+ "Get Executive Associate application error",
+ error instanceof Error ? error.name : "UnknownError",
+ );
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
+
+export async function DELETE() {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
+ const user = await prisma.user.findUnique({
+ where: { email: session.user.email },
+ include: {
+ executiveAssociateApplications: {
+ where: {
+ recruitmentCycleId: activeCycle?.id ?? "__no_active_cycle__",
+ },
+ take: 1,
+ },
+ },
+ });
+ if (!user) {
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
+ }
+
+ const application = user.executiveAssociateApplications[0];
+ if (!application) {
+ return NextResponse.json({ error: "No application found" }, { status: 404 });
+ }
+ if (application.hasAccepted) {
+ return NextResponse.json(
+ { error: "Accepted applications cannot be deleted" },
+ { status: 409 },
+ );
+ }
+
+ const cvPath = normalizeStoragePath(application.supabaseFilePath);
+ if (cvPath) {
+ const { error: storageError } = await supabase.storage
+ .from("ea-applications")
+ .remove([cvPath]);
+ if (storageError) {
+ console.error("Executive Associate file cleanup failed", storageError.name);
+ }
+ }
+
+ await prisma.executiveAssociateApplication.delete({
+ where: { id: application.id },
+ });
+ return NextResponse.json({
+ success: true,
+ message: "Application deleted successfully",
+ });
+ } catch (error) {
+ console.error(
+ "Delete Executive Associate application error",
+ error instanceof Error ? error.name : "UnknownError",
+ );
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/applications/executive-associate/schedule/route.ts b/src/app/api/applications/executive-associate/schedule/route.ts
new file mode 100644
index 0000000..ca96e6c
--- /dev/null
+++ b/src/app/api/applications/executive-associate/schedule/route.ts
@@ -0,0 +1,170 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth/next";
+import { prisma } from "@/lib/prisma";
+import { authOptions } from "@/lib/auth";
+import {
+ emailTemplates,
+ sendEmailWithValidation,
+ getEBEmail,
+} from "@/lib/email";
+import { getRoleId } from "@/lib/eb-mapping";
+import { roles } from "@/data/ebRoles";
+import { eaScheduleSchema } from "@/lib/schemas";
+import {
+ getActiveCycle,
+ getApplicationRuleResponse,
+ validateAndLockInterviewSlot,
+} from "@/lib/application-rules";
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const parsed = eaScheduleSchema.safeParse(await request.json());
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0]?.message || "Invalid interview slot" },
+ { status: 400 },
+ );
+ }
+
+ const cycle = await getActiveCycle();
+ const slot = parsed.data;
+ const result = await prisma.$transaction(async (tx) => {
+ const user = await tx.user.findUnique({
+ where: { email: session.user.email! },
+ include: {
+ executiveAssociateApplications: {
+ where: { recruitmentCycleId: cycle.id },
+ take: 1,
+ },
+ },
+ });
+ if (!user?.studentNumber) throw new Error("SCHEDULE_USER_NOT_FOUND");
+
+ const application = user.executiveAssociateApplications[0];
+ if (!application) throw new Error("EA_APPLICATION_NOT_FOUND");
+ if (application.hasAccepted) throw new Error("APPLICATION_ALREADY_ACCEPTED");
+ if (
+ getRoleId(slot.ebRole) !== getRoleId(application.ebRole) ||
+ getRoleId(application.firstOptionEb) !== getRoleId(application.ebRole)
+ ) {
+ throw new Error("EA_ROLE_MISMATCH");
+ }
+
+ const profile = await validateAndLockInterviewSlot(tx, cycle, {
+ day: slot.interviewSlotDay,
+ start: slot.interviewSlotTimeStart,
+ end: slot.interviewSlotTimeEnd,
+ interviewBy: slot.interviewBy,
+ applicationType: "executive-associate",
+ applicationId: application.id,
+ expectedEbRole: application.ebRole,
+ });
+
+ const updatedApplication =
+ await tx.executiveAssociateApplication.update({
+ where: { id: application.id },
+ data: {
+ interviewBy: profile.position,
+ interviewSlotDay: slot.interviewSlotDay,
+ interviewSlotTimeStart: slot.interviewSlotTimeStart,
+ interviewSlotTimeEnd: slot.interviewSlotTimeEnd,
+ },
+ });
+
+ return { user, application, updatedApplication, profile };
+ });
+
+ try {
+ const applicantTemplate = emailTemplates.executiveAssistantApplication(
+ result.user.name || "Applicant",
+ result.application.studentNumber,
+ result.application.ebRole,
+ result.application.firstOptionEb,
+ result.application.secondOptionEb,
+ result.profile.meetingLink || undefined,
+ result.profile.position,
+ );
+ await sendEmailWithValidation(
+ result.user.email,
+ applicantTemplate.subject,
+ applicantTemplate.html,
+ "Executive Associate applicant confirmation",
+ );
+
+ const roleId = getRoleId(result.profile.position);
+ const ebName = roles.find((role) => role.id === roleId)?.ebName || result.profile.position;
+ const ebTemplate = emailTemplates.ebInterviewNotificationEA(
+ ebName,
+ result.user.name || "Applicant",
+ result.application.studentNumber,
+ result.application.ebRole,
+ new Date(`${slot.interviewSlotDay}T00:00:00+08:00`).toLocaleDateString(
+ "en-US",
+ { weekday: "long", year: "numeric", month: "long", day: "numeric" },
+ ),
+ `${slot.interviewSlotTimeStart} - ${slot.interviewSlotTimeEnd}`,
+ result.profile.meetingLink || undefined,
+ );
+ await sendEmailWithValidation(
+ getEBEmail(roleId, "Executive Associate interview notification"),
+ ebTemplate.subject,
+ ebTemplate.html,
+ "Executive Associate interviewer notification",
+ );
+ } catch (emailError) {
+ console.error(
+ "Executive Associate schedule email failed",
+ emailError instanceof Error ? emailError.name : "UnknownError",
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ application: result.updatedApplication,
+ message: "Interview schedule updated successfully",
+ });
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const ruleError = getApplicationRuleResponse(error);
+ if (ruleError) {
+ return NextResponse.json(ruleError.body, { status: ruleError.status });
+ }
+
+ const knownErrors: Record = {
+ SCHEDULE_USER_NOT_FOUND: { error: "User not found", status: 404 },
+ EA_APPLICATION_NOT_FOUND: {
+ error: "Executive Associate application not found",
+ status: 404,
+ },
+ APPLICATION_ALREADY_ACCEPTED: {
+ error: "Accepted applications cannot be rescheduled",
+ status: 409,
+ },
+ EA_ROLE_MISMATCH: {
+ error: "Interview role does not match the submitted application",
+ status: 400,
+ },
+ };
+ if (error instanceof Error && knownErrors[error.message]) {
+ const response = knownErrors[error.message];
+ return NextResponse.json(
+ { error: response.error },
+ { status: response.status },
+ );
+ }
+
+ console.error(
+ "Executive Associate schedule update error",
+ error instanceof Error ? error.name : "UnknownError",
+ );
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/applications/member/route.ts b/src/app/api/applications/member/route.ts
index 6efc3d8..28e1fe9 100644
--- a/src/app/api/applications/member/route.ts
+++ b/src/app/api/applications/member/route.ts
@@ -3,132 +3,152 @@ import { getServerSession } from "next-auth/next";
import { prisma } from "@/lib/prisma";
import { authOptions } from "@/lib/auth";
import { sendEmail, emailTemplates } from "@/lib/email";
+import { memberApplicationSchema } from "@/lib/schemas";
+import {
+ assertNoOtherApplication,
+ assertStudentNumberOwnership,
+ getApplicationRuleResponse,
+ getOpenApplicationCycle,
+ lockApplicantCycle,
+} from "@/lib/application-rules";
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
-
- if (!session || !session?.user?.email) {
+ if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
- const { studentNumber, section } = await request.json();
-
- if (!studentNumber || !section) {
+ const parsed = memberApplicationSchema.safeParse(await request.json());
+ if (!parsed.success) {
return NextResponse.json(
- { error: "Student number and section are required" },
+ { error: parsed.error.issues[0]?.message || "Invalid application" },
{ status: 400 },
);
}
- if (!/^\d{10}$/.test(studentNumber)) {
- return NextResponse.json(
- { error: "Student number must be 10 digits" },
- { status: 400 },
- );
- }
-
- const existingUserWithSN = await prisma.user.findUnique({
- where: { studentNumber },
+ const cycle = await getOpenApplicationCycle();
+ const { studentNumber, section, age, dateOfBirth, isOldCssMember } =
+ parsed.data;
+
+ const result = await prisma.$transaction(async (tx) => {
+ await lockApplicantCycle(tx, session.user.email!, cycle.id);
+ await assertStudentNumberOwnership(tx, studentNumber, session.user.email!);
+ await assertNoOtherApplication(tx, session.user.email!, cycle.id, "member");
+
+ const existingApplication = await tx.memberApplication.findFirst({
+ where: {
+ recruitmentCycleId: cycle.id,
+ user: { email: session.user.email! },
+ },
+ });
+
+ if (existingApplication?.hasAccepted) {
+ throw new Error("ACCEPTED_MEMBER_APPLICATION");
+ }
+
+ const updatedUser = await tx.user.update({
+ where: { email: session.user.email! },
+ data: {
+ studentNumber,
+ section,
+ age,
+ dateOfBirth: new Date(`${dateOfBirth}T00:00:00Z`),
+ isOldCssMember,
+ },
+ });
+
+ const application = existingApplication
+ ? existingApplication
+ : await tx.memberApplication.create({
+ data: {
+ studentNumber,
+ recruitmentCycleId: cycle.id,
+ paymentProof: "",
+ hasAccepted: false,
+ },
+ });
+
+ return { updatedUser, application };
});
- if (existingUserWithSN && existingUserWithSN.email !== session.user.email) {
- return NextResponse.json(
- { error: "This student number is already registered by another user" },
- { status: 400 },
- );
- }
-
- // Check for already-accepted application BEFORE updating user data
- const existingApplication = await prisma.memberApplication.findUnique({
- where: { studentNumber },
- });
-
- if (existingApplication?.hasAccepted) {
- return NextResponse.json(
- { error: "You already have an accepted member application" },
- { status: 400 },
- );
- }
-
- const updatedUser = await prisma.user.update({
- where: { email: session.user.email },
- data: {
- studentNumber,
- section,
- },
- });
-
- // Use upsert to handle race conditions atomically
- const application = await prisma.memberApplication.upsert({
- where: { studentNumber },
- update: {},
- create: {
- studentNumber,
- paymentProof: "",
- hasAccepted: false,
- },
- });
-
- // Send confirmation email
try {
- const emailTemplate = emailTemplates.memberApplication(
- updatedUser.name,
+ const template = emailTemplates.memberApplication(
+ result.updatedUser.name,
studentNumber,
);
- await sendEmail(
- updatedUser.email,
- emailTemplate.subject,
- emailTemplate.html,
- );
- console.log(
- "Member application confirmation email sent to:",
- updatedUser.email,
- );
+ await sendEmail(result.updatedUser.email, template.subject, template.html);
} catch (emailError) {
console.error(
- "Failed to send member application confirmation email:",
- emailError,
+ "Failed to send member application confirmation",
+ emailError instanceof Error ? emailError.name : "UnknownError",
);
}
return NextResponse.json({
success: true,
- user: updatedUser,
- application,
+ user: result.updatedUser,
+ application: result.application,
message: "Application info saved. Please proceed to payment.",
});
} catch (error) {
- console.error("Member application error:", error);
- if (error instanceof Error && error.message.includes("Unique constraint")) {
+ if (error instanceof SyntaxError) {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const ruleError = getApplicationRuleResponse(error);
+ if (ruleError) {
+ return NextResponse.json(ruleError.body, { status: ruleError.status });
+ }
+
+ if (error instanceof Error && error.message === "ACCEPTED_MEMBER_APPLICATION") {
+ return NextResponse.json(
+ { error: "You already have an accepted member application" },
+ { status: 409 },
+ );
+ }
+
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "P2002") {
return NextResponse.json(
{ error: "This student number already has an application" },
- { status: 400 },
+ { status: 409 },
);
}
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
+ console.error(
+ "Member application error",
+ error instanceof Error ? error.name : "UnknownError",
);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function GET() {
try {
const session = await getServerSession(authOptions);
-
- if (!session) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
-
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
+
const user = await prisma.user.findUnique({
where: { email: session.user.email },
- include: { memberApplication: true },
+ include: {
+ memberApplications: {
+ where: { recruitmentCycleId: activeCycle?.id ?? "__no_active_cycle__" },
+ take: 1,
+ },
+ memberships: {
+ where: { recruitmentCycleId: activeCycle?.id ?? "__no_active_cycle__" },
+ select: { memberId: true },
+ take: 1,
+ },
+ },
});
if (!user) {
@@ -136,20 +156,24 @@ export async function GET() {
}
return NextResponse.json({
- hasApplication: !!user.memberApplication,
- application: user.memberApplication,
+ hasApplication: Boolean(user.memberApplications[0]),
+ application: user.memberApplications[0] ?? null,
user: {
id: user.id,
studentNumber: user.studentNumber,
name: user.name,
section: user.section,
+ age: user.age,
+ dateOfBirth: user.dateOfBirth,
+ isOldCssMember: user.isOldCssMember,
+ memberships: user.memberships,
},
});
} catch (error) {
- console.error("Get Member Application error:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
+ console.error(
+ "Get member application error",
+ error instanceof Error ? error.name : "UnknownError",
);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
diff --git a/src/app/api/applications/payment-proof/route.ts b/src/app/api/applications/payment-proof/route.ts
new file mode 100644
index 0000000..e19d131
--- /dev/null
+++ b/src/app/api/applications/payment-proof/route.ts
@@ -0,0 +1,148 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { sendEmail, emailTemplates } from "@/lib/email";
+import { ensureCycleMemberId } from "@/lib/member-id";
+import { paymentProofSchema } from "@/lib/schemas";
+import {
+ getActiveCycle,
+ getApplicationRuleResponse,
+ isGoogleDriveUrl,
+} from "@/lib/application-rules";
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const parsed = paymentProofSchema.safeParse(await request.json());
+ if (!parsed.success || !isGoogleDriveUrl(parsed.data?.paymentProof ?? "")) {
+ return NextResponse.json(
+ { error: "A valid Google Drive receipt link is required" },
+ { status: 400 },
+ );
+ }
+
+ const cycle = await getActiveCycle();
+ const proof = parsed.data.paymentProof.trim();
+ const result = await prisma.$transaction(async (tx) => {
+ const user = await tx.user.findUnique({
+ where: { email: session.user.email! },
+ include: {
+ memberApplications: {
+ where: { recruitmentCycleId: cycle.id, hasAccepted: true },
+ take: 2,
+ },
+ committeeApplications: {
+ where: { recruitmentCycleId: cycle.id, hasAccepted: true },
+ take: 2,
+ },
+ executiveAssociateApplications: {
+ where: { recruitmentCycleId: cycle.id, hasAccepted: true },
+ take: 2,
+ },
+ },
+ });
+ if (!user) throw new Error("PAYMENT_USER_NOT_FOUND");
+
+ const acceptedApplications = [
+ ...user.memberApplications.map((application) => ({
+ id: application.id,
+ type: "member" as const,
+ })),
+ ...user.committeeApplications.map((application) => ({
+ id: application.id,
+ type: "committee" as const,
+ })),
+ ...user.executiveAssociateApplications.map((application) => ({
+ id: application.id,
+ type: "executive-associate" as const,
+ })),
+ ];
+
+ if (acceptedApplications.length === 0) {
+ throw new Error("NO_ACCEPTED_APPLICATION");
+ }
+ if (acceptedApplications.length > 1) {
+ throw new Error("MULTIPLE_ACCEPTED_APPLICATIONS");
+ }
+
+ const application = acceptedApplications[0];
+ if (application.type === "member") {
+ await tx.memberApplication.update({
+ where: { id: application.id },
+ data: { paymentProof: proof },
+ });
+ } else if (application.type === "committee") {
+ await tx.committeeApplication.update({
+ where: { id: application.id },
+ data: { paymentProof: proof },
+ });
+ } else {
+ await tx.executiveAssociateApplication.update({
+ where: { id: application.id },
+ data: { paymentProof: proof },
+ });
+ }
+
+ const memberId = await ensureCycleMemberId(tx, user.id, cycle.id);
+ return { memberId, user };
+ });
+
+ try {
+ const template = emailTemplates.memberIdReleased(
+ result.user.name || "Valued Member",
+ result.memberId,
+ );
+ await sendEmail(result.user.email, template.subject, template.html);
+ } catch (emailError) {
+ console.error(
+ "Member ID email failed",
+ emailError instanceof Error ? emailError.name : "UnknownError",
+ );
+ }
+
+ return NextResponse.json({
+ success: true,
+ paymentProof: proof,
+ memberId: result.memberId,
+ });
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const ruleError = getApplicationRuleResponse(error);
+ if (ruleError) {
+ return NextResponse.json(ruleError.body, { status: ruleError.status });
+ }
+
+ const knownErrors: Record = {
+ PAYMENT_USER_NOT_FOUND: { error: "User not found", status: 404 },
+ NO_ACCEPTED_APPLICATION: {
+ error: "No accepted application found",
+ status: 404,
+ },
+ MULTIPLE_ACCEPTED_APPLICATIONS: {
+ error: "Multiple accepted applications require administrator review",
+ status: 409,
+ },
+ };
+ if (error instanceof Error && knownErrors[error.message]) {
+ const response = knownErrors[error.message];
+ return NextResponse.json(
+ { error: response.error },
+ { status: response.status },
+ );
+ }
+
+ console.error(
+ "Payment proof submission error",
+ error instanceof Error ? error.name : "UnknownError",
+ );
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/applications/redirection-response/route.ts b/src/app/api/applications/redirection-response/route.ts
index 59126ea..8fdffff 100644
--- a/src/app/api/applications/redirection-response/route.ts
+++ b/src/app/api/applications/redirection-response/route.ts
@@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { committeeRolesSubmitted } from "@/data/committeeRoles";
import { roles } from "@/data/ebRoles";
+import { ensureCycleMemberId } from "@/lib/member-id";
const isMemberRedirection = (value?: string | null) =>
value?.toLowerCase() === "member";
@@ -29,6 +30,44 @@ const getEaRoleIdFromRedirection = (value?: string | null) => {
return role ? role.id : null;
};
+const acceptMemberApplication = async (
+ studentNumber: string,
+ userId: string,
+ cycleId: string | null,
+) =>
+ prisma.$transaction(async (tx) => {
+ const existingMember = await tx.memberApplication.findFirst({
+ where: { studentNumber, recruitmentCycleId: cycleId },
+ orderBy: { createdAt: "desc" },
+ });
+
+ const acceptedMember = existingMember
+ ? await tx.memberApplication.update({
+ where: { id: existingMember.id },
+ data: { hasAccepted: true },
+ })
+ : await tx.memberApplication.create({
+ data: {
+ studentNumber,
+ recruitmentCycleId: cycleId,
+ hasAccepted: true,
+ paymentProof: "",
+ },
+ });
+
+ await ensureCycleMemberId(tx, userId, acceptedMember.recruitmentCycleId);
+
+ await tx.memberApplication.deleteMany({
+ where: {
+ studentNumber,
+ recruitmentCycleId: cycleId,
+ id: { not: acceptedMember.id },
+ },
+ });
+
+ return acceptedMember;
+ });
+
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
@@ -40,17 +79,25 @@ export async function POST(request: NextRequest) {
const decision = body?.decision;
if (decision !== "accept" && decision !== "reject") {
- return NextResponse.json(
- { error: "Invalid decision" },
- { status: 400 },
- );
+ return NextResponse.json({ error: "Invalid decision" }, { status: 400 });
}
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true },
+ });
+ const cycleId = activeCycle?.id ?? null;
const user = await prisma.user.findUnique({
where: { email: session.user.email },
include: {
- committeeApplication: true,
- eaApplication: true,
+ committeeApplications: {
+ where: { recruitmentCycleId: cycleId },
+ take: 1,
+ },
+ executiveAssociateApplications: {
+ where: { recruitmentCycleId: cycleId },
+ take: 1,
+ },
},
});
@@ -59,18 +106,22 @@ export async function POST(request: NextRequest) {
}
const committeeApp =
- user.committeeApplication?.status === "redirected" &&
- user.committeeApplication?.redirection
- ? user.committeeApplication
+ user.committeeApplications?.[0]?.status === "redirected" &&
+ user.committeeApplications?.[0]?.redirection
+ ? user.committeeApplications?.[0]
: null;
const eaApp =
- user.eaApplication?.status === "redirected" &&
- user.eaApplication?.redirection
- ? user.eaApplication
+ user.executiveAssociateApplications?.[0]?.status === "redirected" &&
+ user.executiveAssociateApplications?.[0]?.redirection
+ ? user.executiveAssociateApplications?.[0]
: null;
- const sourceType = committeeApp ? "committee" : eaApp ? "ea" : null;
+ const sourceType = committeeApp
+ ? "committee"
+ : eaApp
+ ? "executive-associate"
+ : null;
const sourceApp = committeeApp ?? eaApp;
if (!sourceType || !sourceApp) {
@@ -86,76 +137,96 @@ export async function POST(request: NextRequest) {
if (decision === "accept") {
if (isMemberRedirection(redirection)) {
- await prisma.memberApplication.upsert({
- where: { studentNumber: user.studentNumber },
- update: { hasAccepted: true },
- create: {
- studentNumber: user.studentNumber,
- hasAccepted: true,
- paymentProof: "",
- },
- });
+ await acceptMemberApplication(user.studentNumber, user.id, cycleId);
} else if (sourceType === "committee" && eaRoleId) {
- await prisma.eAApplication.upsert({
- where: { studentNumber: user.studentNumber },
- update: {
- ebRole: eaRoleId,
- firstOptionEb: eaRoleId,
- secondOptionEb: "",
- status: "passed",
- hasAccepted: true,
- redirection: null,
- cv: sourceApp.cv || "",
- supabaseFilePath: sourceApp.supabaseFilePath || "",
- },
- create: {
- studentNumber: user.studentNumber,
- ebRole: eaRoleId,
- firstOptionEb: eaRoleId,
- secondOptionEb: "",
- cv: sourceApp.cv || "",
- supabaseFilePath: sourceApp.supabaseFilePath || "",
- hasFinishedInterview: false,
- status: "passed",
- hasAccepted: true,
- redirection: null,
+ const existingEa = await prisma.executiveAssociateApplication.findFirst(
+ {
+ where: {
+ studentNumber: user.studentNumber,
+ recruitmentCycleId: cycleId,
+ },
},
- });
- } else if (sourceType === "ea" && committeeId) {
- await prisma.committeeApplication.upsert({
- where: { studentNumber: user.studentNumber },
- update: {
- firstOptionCommittee: committeeId,
- secondOptionCommittee: "",
- status: "passed",
- hasAccepted: true,
- redirection: null,
- cv: sourceApp.cv || "",
- supabaseFilePath: sourceApp.supabaseFilePath || "",
- interviewSlotDay: sourceApp.interviewSlotDay,
- interviewSlotTimeStart: sourceApp.interviewSlotTimeStart,
- interviewSlotTimeEnd: sourceApp.interviewSlotTimeEnd,
- interviewBy: sourceApp.interviewBy,
- },
- create: {
+ );
+ if (existingEa)
+ await prisma.executiveAssociateApplication.update({
+ where: { id: existingEa.id },
+ data: {
+ ebRole: eaRoleId,
+ firstOptionEb: eaRoleId,
+ secondOptionEb: "",
+ status: "passed",
+ hasAccepted: true,
+ redirection: null,
+ cv: sourceApp.cv || "",
+ supabaseFilePath: sourceApp.supabaseFilePath || "",
+ },
+ });
+ else
+ await prisma.executiveAssociateApplication.create({
+ data: {
+ studentNumber: user.studentNumber,
+ recruitmentCycleId: cycleId,
+ ebRole: eaRoleId,
+ firstOptionEb: eaRoleId,
+ secondOptionEb: "",
+ cv: sourceApp.cv || "",
+ supabaseFilePath: sourceApp.supabaseFilePath || "",
+ hasFinishedInterview: false,
+ status: "passed",
+ hasAccepted: true,
+ redirection: null,
+ },
+ });
+ } else if (sourceType === "executive-associate" && committeeId) {
+ const existingCommittee = await prisma.committeeApplication.findFirst({
+ where: {
studentNumber: user.studentNumber,
- firstOptionCommittee: committeeId,
- secondOptionCommittee: "",
- cv: sourceApp.cv || "",
- supabaseFilePath: sourceApp.supabaseFilePath || "",
- portfolioLink: null,
- interviewSlotDay: sourceApp.interviewSlotDay,
- interviewSlotTimeStart: sourceApp.interviewSlotTimeStart,
- interviewSlotTimeEnd: sourceApp.interviewSlotTimeEnd,
- interviewBy: sourceApp.interviewBy,
- hasFinishedInterview: false,
- status: "passed",
- hasAccepted: true,
- redirection: null,
+ recruitmentCycleId: cycleId,
},
});
+ if (existingCommittee)
+ await prisma.committeeApplication.update({
+ where: { id: existingCommittee.id },
+ data: {
+ firstOptionCommittee: committeeId,
+ secondOptionCommittee: "",
+ status: "passed",
+ hasAccepted: true,
+ redirection: null,
+ cv: sourceApp.cv || "",
+ supabaseFilePath: sourceApp.supabaseFilePath || "",
+ interviewSlotDay: sourceApp.interviewSlotDay,
+ interviewSlotTimeStart: sourceApp.interviewSlotTimeStart,
+ interviewSlotTimeEnd: sourceApp.interviewSlotTimeEnd,
+ interviewBy: sourceApp.interviewBy,
+ },
+ });
+ else
+ await prisma.committeeApplication.create({
+ data: {
+ studentNumber: user.studentNumber,
+ recruitmentCycleId: cycleId,
+ firstOptionCommittee: committeeId,
+ secondOptionCommittee: "",
+ cv: sourceApp.cv || "",
+ supabaseFilePath: sourceApp.supabaseFilePath || "",
+ portfolioLink: null,
+ interviewSlotDay: sourceApp.interviewSlotDay,
+ interviewSlotTimeStart: sourceApp.interviewSlotTimeStart,
+ interviewSlotTimeEnd: sourceApp.interviewSlotTimeEnd,
+ interviewBy: sourceApp.interviewBy,
+ hasFinishedInterview: false,
+ status: "passed",
+ hasAccepted: true,
+ redirection: null,
+ },
+ });
}
+ await prisma.$transaction((tx) =>
+ ensureCycleMemberId(tx, user.id, cycleId),
+ );
+
if (sourceType === "committee") {
await prisma.committeeApplication.update({
where: { id: sourceApp.id },
@@ -165,7 +236,7 @@ export async function POST(request: NextRequest) {
},
});
} else {
- await prisma.eAApplication.update({
+ await prisma.executiveAssociateApplication.update({
where: { id: sourceApp.id },
data: {
hasAccepted: true,
@@ -180,15 +251,7 @@ export async function POST(request: NextRequest) {
});
}
- await prisma.memberApplication.upsert({
- where: { studentNumber: user.studentNumber },
- update: { hasAccepted: true },
- create: {
- studentNumber: user.studentNumber,
- hasAccepted: true,
- paymentProof: "",
- },
- });
+ await acceptMemberApplication(user.studentNumber, user.id, cycleId);
if (sourceType === "committee") {
await prisma.committeeApplication.update({
@@ -200,7 +263,7 @@ export async function POST(request: NextRequest) {
},
});
} else {
- await prisma.eAApplication.update({
+ await prisma.executiveAssociateApplication.update({
where: { id: sourceApp.id },
data: {
hasAccepted: true,
diff --git a/src/app/api/community-link/route.ts b/src/app/api/community-link/route.ts
new file mode 100644
index 0000000..445d62f
--- /dev/null
+++ b/src/app/api/community-link/route.ts
@@ -0,0 +1,28 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+
+const URL_KEY = "community_group_url";
+const LABEL_KEY = "community_group_label";
+const ENABLED_KEY = "community_group_enabled";
+
+export async function GET() {
+ try {
+ const configs = await prisma.systemConfig.findMany({
+ where: { key: { in: [URL_KEY, LABEL_KEY, ENABLED_KEY] } },
+ });
+
+ const configMap = new Map(configs.map((config) => [config.key, config.value]));
+
+ return NextResponse.json({
+ enabled: configMap.get(ENABLED_KEY) !== "false",
+ url: configMap.get(URL_KEY)?.trim() || "",
+ label: configMap.get(LABEL_KEY)?.trim() || "Join Community Group",
+ });
+ } catch (error) {
+ console.error("Get community link error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/eb-roles/route.ts b/src/app/api/eb-roles/route.ts
new file mode 100644
index 0000000..873d662
--- /dev/null
+++ b/src/app/api/eb-roles/route.ts
@@ -0,0 +1,94 @@
+import { NextResponse } from "next/server";
+import { roles } from "@/data/ebRoles";
+import { getPositionTitle } from "@/lib/eb-mapping";
+import { prisma } from "@/lib/prisma";
+
+function toTitleCase(value: string) {
+ return value
+ .toLowerCase()
+ .split(" ")
+ .filter(Boolean)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
+export async function GET() {
+ try {
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ select: { id: true, schoolYear: true },
+ });
+
+ let ebProfiles: Array<{
+ position: string;
+ meetingLink: string | null;
+ user: { name: string };
+ }>;
+
+ try {
+ ebProfiles = await prisma.eBProfile.findMany({
+ where: {
+ isActive: true,
+ ...(activeCycle ? { recruitmentCycleId: activeCycle.id } : {}),
+ },
+ select: {
+ position: true,
+ meetingLink: true,
+ user: { select: { name: true } },
+ },
+ });
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ "code" in error &&
+ error.code === "P2022"
+ ) {
+ console.warn(
+ "EBProfile.recruitmentCycleId is missing. Falling back to active EB profiles. Run `npx prisma db push` to enable AY-specific EB roles.",
+ );
+
+ ebProfiles = await prisma.eBProfile.findMany({
+ where: { isActive: true },
+ select: {
+ position: true,
+ meetingLink: true,
+ user: { select: { name: true } },
+ },
+ });
+ } else {
+ throw error;
+ }
+ }
+
+ const availabilityConfig = await prisma.systemConfig.findUnique({
+ where: { key: "available_executive_associate_roles" },
+ });
+
+ const availability = availabilityConfig
+ ? JSON.parse(availabilityConfig.value) as Record
+ : {};
+
+ const profileByPosition = new Map(
+ ebProfiles.map((profile) => [profile.position, profile]),
+ );
+
+ const dynamicRoles = roles.filter((role) => availability[role.id] !== false).map((role) => {
+ const profile = profileByPosition.get(getPositionTitle(role.id));
+
+ return {
+ ...role,
+ ebName: profile?.user.name ? toTitleCase(profile.user.name) : "-",
+ meetingLink: profile?.meetingLink || null,
+ schoolYear: activeCycle?.schoolYear || null,
+ };
+ });
+
+ return NextResponse.json({ roles: dynamicRoles, activeCycle });
+ } catch (error) {
+ console.error("Get EB roles error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/files/access/route.ts b/src/app/api/files/access/route.ts
index 8958e03..e9daac9 100644
--- a/src/app/api/files/access/route.ts
+++ b/src/app/api/files/access/route.ts
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const fileType = searchParams.get("fileType"); // 'cv' or 'portfolio'
- const applicationType = searchParams.get("applicationType"); // 'ea' or 'committee'
+ const applicationType = searchParams.get("applicationType"); // 'executive-associate' or 'committee'
if (!fileType || !applicationType) {
return NextResponse.json(
@@ -45,8 +45,8 @@ export async function GET(request: NextRequest) {
let application;
let supabaseFilePath: string | null = null;
- if (applicationType === "ea") {
- application = await prisma.eAApplication.findUnique({
+ if (applicationType === "executive-associate") {
+ application = await prisma.executiveAssociateApplication.findFirst({
where: { studentNumber: user.studentNumber },
select: {
supabaseFilePath: true,
@@ -55,7 +55,7 @@ export async function GET(request: NextRequest) {
});
supabaseFilePath = application?.supabaseFilePath || null;
} else if (applicationType === "committee") {
- application = await prisma.committeeApplication.findUnique({
+ application = await prisma.committeeApplication.findFirst({
where: { studentNumber: user.studentNumber },
select: {
supabaseFilePath: true,
@@ -73,7 +73,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json(
{
error:
- "Invalid applicationType parameter. Must be 'ea' or 'committee'",
+ "Invalid applicationType parameter. Must be 'executive-associate' or 'committee'",
},
{ status: 400 },
);
@@ -129,8 +129,8 @@ export async function GET(request: NextRequest) {
} else {
// It's just a file path, use the bucket name
const bucketName =
- applicationType === "ea"
- ? "ea-applications"
+ applicationType === "executive-associate"
+ ? "executive-associate-applications"
: "committee-applications";
const { data, error } = await supabase.storage
diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts
index 28ad836..ca0be82 100644
--- a/src/app/api/files/upload/route.ts
+++ b/src/app/api/files/upload/route.ts
@@ -19,7 +19,7 @@ export async function POST(request: NextRequest) {
const file = formData.get("file") as File;
const studentNumber = formData.get("studentNumber") as string;
const fileType = formData.get("fileType") as string;
- const applicationType = formData.get("applicationType") as string; // 'ea' or 'committee'
+ const applicationType = formData.get("applicationType") as string; // 'executive-associate' or 'committee'
if (!file || !studentNumber || !fileType || !applicationType) {
console.error("Missing required fields:", {
@@ -48,14 +48,14 @@ export async function POST(request: NextRequest) {
);
}
- if (!["ea", "committee"].includes(applicationType)) {
+ if (!["executive-associate", "committee"].includes(applicationType)) {
return NextResponse.json(
{ error: "Invalid application type" },
{ status: 400 },
);
}
- if (applicationType === "ea" && fileType !== "cv") {
+ if (applicationType === "executive-associate" && fileType !== "cv") {
return NextResponse.json(
{ error: "EA applications only support CV uploads" },
{ status: 400 },
@@ -105,7 +105,9 @@ export async function POST(request: NextRequest) {
// Determine the bucket and application type
const bucketName =
- applicationType === "ea" ? "ea-applications" : "committee-applications";
+ applicationType === "executive-associate"
+ ? "ea-applications"
+ : "committee-applications";
// Generate unique file name
const timestamp = Date.now();
diff --git a/src/app/api/payment-qr/image/route.ts b/src/app/api/payment-qr/image/route.ts
new file mode 100644
index 0000000..42d62ca
--- /dev/null
+++ b/src/app/api/payment-qr/image/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { supabase } from "@/lib/supabase";
+
+const CONFIG_KEY = "payment_qr_image_path";
+const BUCKET_NAME = "payment";
+
+export async function GET() {
+ try {
+ const config = await prisma.systemConfig.findUnique({
+ where: { key: CONFIG_KEY },
+ });
+
+ if (!config?.value) {
+ return NextResponse.json({ error: "Payment QR not configured" }, { status: 404 });
+ }
+
+ const { data, error } = await supabase.storage
+ .from(BUCKET_NAME)
+ .download(config.value);
+
+ if (error || !data) {
+ console.error("Payment QR download error:", error);
+ return NextResponse.json({ error: "Payment QR not found" }, { status: 404 });
+ }
+
+ return new NextResponse(data, {
+ headers: {
+ "Content-Type": data.type || "image/png",
+ "Cache-Control": "public, max-age=300",
+ },
+ });
+ } catch (error) {
+ console.error("Payment QR image error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/payment-qr/route.ts b/src/app/api/payment-qr/route.ts
new file mode 100644
index 0000000..aab12a5
--- /dev/null
+++ b/src/app/api/payment-qr/route.ts
@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { PAYMENT_QR_IMAGE_URL } from "@/lib/payment-config";
+
+const CONFIG_KEY = "payment_qr_image_path";
+
+export async function GET() {
+ try {
+ const config = await prisma.systemConfig.findUnique({
+ where: { key: CONFIG_KEY },
+ });
+
+ return NextResponse.json({
+ url: config?.value?.trim()
+ ? `/api/payment-qr/image?v=${encodeURIComponent(config.value)}`
+ : PAYMENT_QR_IMAGE_URL || "",
+ });
+ } catch (error) {
+ console.error("Get payment QR error:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/api/payment-receipt-template/file/route.ts b/src/app/api/payment-receipt-template/file/route.ts
new file mode 100644
index 0000000..483e48d
--- /dev/null
+++ b/src/app/api/payment-receipt-template/file/route.ts
@@ -0,0 +1,27 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { supabase } from "@/lib/supabase";
+
+const CONFIG_KEY = "payment_receipt_template_path";
+const BUCKET_NAME = "payment";
+
+export async function GET() {
+ try {
+ const config = await prisma.systemConfig.findUnique({ where: { key: CONFIG_KEY } });
+ if (!config?.value) return NextResponse.json({ error: "Template not configured" }, { status: 404 });
+
+ const { data, error } = await supabase.storage.from(BUCKET_NAME).download(config.value);
+ if (error || !data) return NextResponse.json({ error: "Template not found" }, { status: 404 });
+
+ return new NextResponse(data, {
+ headers: {
+ "Content-Type": "application/pdf",
+ "Content-Disposition": 'inline; filename="payment-acknowledgement-receipt.pdf"',
+ "Cache-Control": "public, max-age=300",
+ },
+ });
+ } catch (error) {
+ console.error("Payment receipt template file error:", error);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/payment-receipt-template/route.ts b/src/app/api/payment-receipt-template/route.ts
new file mode 100644
index 0000000..fdef32a
--- /dev/null
+++ b/src/app/api/payment-receipt-template/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+
+const CONFIG_KEY = "payment_receipt_template_path";
+
+export async function GET() {
+ try {
+ const config = await prisma.systemConfig.findUnique({ where: { key: CONFIG_KEY } });
+ return NextResponse.json({ url: config?.value ? `/api/payment-receipt-template/file?v=${encodeURIComponent(config.value)}` : "" });
+ } catch (error) {
+ console.error("Get payment receipt template error:", error);
+ return NextResponse.json({ error: "Internal server error" }, { status: 500 });
+ }
+}
diff --git a/src/app/api/quiz-submissions/route.ts b/src/app/api/quiz-submissions/route.ts
index 9b9c057..3d4b7e2 100644
--- a/src/app/api/quiz-submissions/route.ts
+++ b/src/app/api/quiz-submissions/route.ts
@@ -1,12 +1,24 @@
import { NextResponse } from "next/server";
import { supabase } from "@/lib/supabase";
+const RECENT_SUBMISSION_TTL_MS = 5 * 60 * 1000;
+const recentSubmissionKeys = new Map();
+
+const pruneRecentSubmissionKeys = (now: number) => {
+ for (const [key, expiresAt] of recentSubmissionKeys) {
+ if (expiresAt <= now) recentSubmissionKeys.delete(key);
+ }
+};
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
export async function POST(req: Request) {
try {
const body = await req.json();
const submission = body?.submission ?? body;
- if (!submission || typeof submission !== "object") {
+ if (!isRecord(submission)) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
@@ -19,12 +31,31 @@ export async function POST(req: Request) {
);
}
+ const { idempotencyKey, ...submissionRecord } = submission;
+
+ if (typeof idempotencyKey === "string" && idempotencyKey.length > 0) {
+ const now = Date.now();
+ pruneRecentSubmissionKeys(now);
+
+ if (recentSubmissionKeys.has(idempotencyKey)) {
+ return NextResponse.json(
+ { data: [], duplicate: true },
+ { status: 200 },
+ );
+ }
+
+ recentSubmissionKeys.set(idempotencyKey, now + RECENT_SUBMISSION_TTL_MS);
+ }
+
const { data, error } = await supabase
.from("quiz_submissions")
- .insert([submission])
+ .insert([submissionRecord])
.select();
if (error) {
+ if (typeof idempotencyKey === "string") {
+ recentSubmissionKeys.delete(idempotencyKey);
+ }
return NextResponse.json({ error: error.message }, { status: 500 });
}
diff --git a/src/app/api/recruitment-cycle/active/route.ts b/src/app/api/recruitment-cycle/active/route.ts
new file mode 100644
index 0000000..9a78657
--- /dev/null
+++ b/src/app/api/recruitment-cycle/active/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+
+export async function GET() {
+ try {
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true,
+ schoolYear: true,
+ applicationStart: true,
+ interviewStart: true,
+ interviewEnd: true,
+ },
+ });
+
+ return NextResponse.json({ activeCycle });
+ } catch (error) {
+ console.error(
+ "Failed to load the active recruitment cycle",
+ error instanceof Error ? error.name : "UnknownError",
+ );
+ return NextResponse.json(
+ { error: "Unable to load the active recruitment cycle" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/src/app/auth/error/page.tsx b/src/app/auth/error/page.tsx
index fae73d1..93550be 100644
--- a/src/app/auth/error/page.tsx
+++ b/src/app/auth/error/page.tsx
@@ -2,6 +2,7 @@
import { useSearchParams } from "next/navigation";
import ErrorPage from "@/components/ErrorPage";
+import LoadingScreen from "@/components/LoadingScreen";
import { Suspense } from "react";
function AuthErrorContent() {
@@ -35,7 +36,7 @@ function AuthErrorContent() {
export default function AuthError() {
return (
- Loading...
}>
+
}>
);
diff --git a/src/app/globals.css b/src/app/globals.css
index 5272c54..e64e90b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -566,7 +566,7 @@ body {
display: none !important;
}
-/* Sonner toast theming — CSS Apply blue palette */
+/* Sonner toast theming — CSSApply blue palette */
[data-sonner-toaster] {
--normal-bg: #ffffff;
--normal-text: #134687;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 8f209b3..c4e3558 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -30,7 +30,7 @@ export const metadata: Metadata = {
template: "%s | CSSApply",
},
description:
- "CSSApply Recruitment 101 Portal - Apply for positions in the Computer Science Society. Join our team as a member, committee staff, or executive assistant.",
+ "CSSApply Recruitment 101 Portal - Apply for positions in the Computer Science Society. Join our team as a member, committee staff, or executive associate.",
keywords: [
"CSSApply",
"Computer Science Society",
@@ -55,12 +55,12 @@ export const metadata: Metadata = {
openGraph: {
title: "CSSApply - Computer Science Society Recruitment Portal",
description:
- "Apply for positions in the Computer Science Society. Join our team as a member, committee staff, or executive assistant.",
+ "Apply for positions in the Computer Science Society. Join our team as a member, committee staff, or executive associate.",
url: "/",
siteName: "CSSApply",
images: [
{
- url: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/logos/Logo_CSS Apply.svg",
+ url: "/assets/css-apply-static-images/assets/logos/Logo_CSS%20Apply.svg",
width: 1200,
height: 630,
alt: "CSSApply Logo",
@@ -73,9 +73,9 @@ export const metadata: Metadata = {
card: "summary_large_image",
title: "CSSApply - Computer Science Society Recruitment Portal",
description:
- "Apply for positions in the Computer Science Society. Join our team as a member, committee staff, or executive assistant.",
+ "Apply for positions in the Computer Science Society. Join our team as a member, committee staff, or executive associate.",
images: [
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/logos/Logo_CSS Apply.svg",
+ "/assets/css-apply-static-images/assets/logos/Logo_CSS%20Apply.svg",
],
creator: "@cssociety", // Replace with actual Twitter handle if available
},
@@ -91,11 +91,11 @@ export const metadata: Metadata = {
},
},
icons: {
- icon: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/logos/Logo_CSS Apply.svg",
+ icon: "/assets/css-apply-static-images/assets/logos/Logo_CSS%20Apply.svg",
shortcut:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/logos/Logo_CSS Apply.svg",
+ "/assets/css-apply-static-images/assets/logos/Logo_CSS%20Apply.svg",
apple:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/logos/Logo_CSS Apply.svg",
+ "/assets/css-apply-static-images/assets/logos/Logo_CSS%20Apply.svg",
},
};
diff --git a/src/app/loading.tsx b/src/app/loading.tsx
new file mode 100644
index 0000000..dddb059
--- /dev/null
+++ b/src/app/loading.tsx
@@ -0,0 +1,5 @@
+import LoadingScreen from "@/components/LoadingScreen";
+
+export default function Loading() {
+ return
;
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index b1daecd..b9f1487 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -2,6 +2,7 @@
// REF: di kailangan na use client ung buong file
import Footer from "@/components/Footer";
+import LoadingScreen from "@/components/LoadingScreen";
import Image from "next/image";
import Link from "next/link";
@@ -133,44 +134,44 @@ function HomeContent() {
shape?: string;
}> = [
{
- src: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/partners/BiteSlice.jpg",
+ src: "/assets/css-apply-static-images/assets/partners/BiteSlice.webp",
alt: "BiteSlice",
size: "h-20 w-20",
facebookUrl: "https://www.facebook.com/profile.php?id=100064060713967",
},
{
- src: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/partners/HomeRoom.jpg",
+ src: "/assets/css-apply-static-images/assets/partners/HomeRoom.webp",
alt: "HomeRoom",
size: "h-20 w-20",
facebookUrl: "https://www.facebook.com/homeroomcoworkingph",
},
{
- src: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/partners/MindZone.jpg",
+ src: "/assets/css-apply-static-images/assets/partners/MindZone.webp",
alt: "MindZone",
size: "h-20 w-20",
facebookUrl: "https://www.facebook.com/mindzoneespanaph",
},
{
- src: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/partners/NomuCafe.png",
+ src: "/assets/css-apply-static-images/assets/partners/NomuCafe.webp",
alt: "NomuCafe",
size: "h-20 w-20",
facebookUrl: "https://www.facebook.com/nomuPH",
},
{
- src: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/partners/TheCatalyst.jpg",
+ src: "/assets/css-apply-static-images/assets/partners/TheCatalyst.webp",
alt: "TheCatalyst",
size: "h-28 w-28",
facebookUrl: "https://www.facebook.com/coworking.thecatalyst",
},
{
- src: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/partners/Yorokobi.jpg",
+ src: "/assets/css-apply-static-images/assets/partners/Yorokobi.webp",
alt: "Yorokobi",
size: "h-20 w-20",
facebookUrl: "https://www.facebook.com/yorokobimnl",
},
{
- src: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/partners/ZeroCafe.png",
+ src: "/assets/css-apply-static-images/assets/partners/ZeroCafe.webp",
alt: "ZeroCafe",
size: "h-20 w-20",
facebookUrl: "https://www.facebook.com/ZeroCafePH",
@@ -195,8 +196,8 @@ function HomeContent() {
>
{/* REF: Use next image instead of img */}
@@ -631,7 +632,7 @@ function HomeContent() {
className="relative w-full h-60 bg-cover bg-center flex items-end"
style={{
backgroundImage:
- "url('https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/pictures/s4_mobile_pic2.png')",
+ "url('/assets/css-apply-static-images/assets/pictures/s4_mobile_pic2.webp')",
}}
>
@@ -652,7 +653,7 @@ function HomeContent() {
className="relative w-full h-60 bg-cover bg-center flex items-end"
style={{
backgroundImage:
- "url('https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/pictures/s4_mobile_pic3.png')",
+ "url('/assets/css-apply-static-images/assets/pictures/s4_mobile_pic3.webp')",
}}
>
@@ -672,7 +673,7 @@ function HomeContent() {
className="relative w-full h-60 bg-cover bg-center flex items-end"
style={{
backgroundImage:
- "url('https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/pictures/s4_mobile_pic4.png')",
+ "url('/assets/css-apply-static-images/assets/pictures/s4_mobile_pic4.webp')",
}}
>
@@ -700,7 +701,7 @@ function HomeContent() {
className="w-[28%] h-full bg-cover bg-center flex items-center pl-10"
style={{
backgroundImage:
- "url('https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/pictures/s4_desktop_pic1.png')",
+ "url('/assets/css-apply-static-images/assets/pictures/s4_desktop_pic1.webp')",
}}
>
@@ -719,7 +720,7 @@ function HomeContent() {
className="relative w-[28%] h-full bg-cover bg-center flex flex-col justify-end"
style={{
backgroundImage:
- "url('https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/pictures/s4_desktop_pic2.png')",
+ "url('/assets/css-apply-static-images/assets/pictures/s4_desktop_pic2.webp')",
}}
>
@@ -736,7 +737,7 @@ function HomeContent() {
className="relative w-[28%] h-full bg-cover bg-center flex flex-col justify-end"
style={{
backgroundImage:
- "url('https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/pictures/s4_desktop_pic3.png')",
+ "url('/assets/css-apply-static-images/assets/pictures/s4_desktop_pic3.webp')",
}}
>
@@ -753,7 +754,7 @@ function HomeContent() {
className="relative w-[28%] h-full bg-cover bg-center flex flex-col justify-end"
style={{
backgroundImage:
- "url('https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/pictures/s4_desktop_pic4.png')",
+ "url('/assets/css-apply-static-images/assets/pictures/s4_desktop_pic4.webp')",
}}
>
@@ -836,7 +837,7 @@ function HomeContent() {
-
+
@@ -909,11 +910,11 @@ function HomeContent() {
- _handleApplyClick("/user/apply/executive-assistant")
+ _handleApplyClick("/user/apply/executive-associate")
}
className="bg-white lg:w-72 px-7 py-2 lg:py-4 rounded-3xl shadow-[0_12px_36px_rgba(0,0,0,0.55)] hover:shadow-[0_16px_44px_rgba(0,0,0,0.65)] hover:bg-[#d5d5d5] hover:scale-105 transition-all duration-300 cursor-pointer"
>
- Apply as Executive Assistant
+ Apply as Executive Associate
@@ -927,7 +928,7 @@ function HomeContent() {
export default function Home() {
return (
-
Loading... }>
+
}>
);
diff --git a/src/app/user/apply/committee-staff/[committee]/application/page.tsx b/src/app/user/apply/committee-staff/[committee]/application/page.tsx
index b9b1964..8fc9138 100644
--- a/src/app/user/apply/committee-staff/[committee]/application/page.tsx
+++ b/src/app/user/apply/committee-staff/[committee]/application/page.tsx
@@ -8,6 +8,7 @@ import { committeeRoles } from "@/data/committeeRoles";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import LoadingScreen from "@/components/LoadingScreen";
+import LoadingSpinner from "@/components/LoadingSpinner";
import { parseFullName } from "@/lib/name-parsing";
import { useFormPersistence } from "@/lib/useFormPersistence";
import { useApplicationStatus } from "@/lib/useApplicationStatus";
@@ -45,6 +46,9 @@ export default function CommitteeApplication() {
firstName: "",
lastName: "",
section: "",
+ age: "",
+ dateOfBirth: "",
+ isOldCssMember: false,
secondChoice: "",
cv: "",
portfolioLink: "",
@@ -93,6 +97,18 @@ export default function CommitteeApplication() {
if (!formData.section && data.user?.section) {
updates.section = data.user.section;
}
+
+ if (!formData.age && data.user?.age) {
+ updates.age = String(data.user.age);
+ }
+
+ if (!formData.dateOfBirth && data.user?.dateOfBirth) {
+ updates.dateOfBirth = data.user.dateOfBirth.slice(0, 10);
+ }
+
+ if (data.user?.isOldCssMember !== null && data.user?.isOldCssMember !== undefined) {
+ updates.isOldCssMember = data.user.isOldCssMember;
+ }
if (!formData.secondChoice && data.application?.secondOptionCommittee) {
updates.secondChoice = data.application.secondOptionCommittee;
@@ -111,7 +127,20 @@ export default function CommitteeApplication() {
};
fetchApplicationData();
- }, [session, status, isLoaded, updateFormData, hasFetchedData, formData.studentNumber, formData.section, formData.cv, formData.portfolioLink, formData.secondChoice]);
+ }, [
+ session,
+ status,
+ isLoaded,
+ updateFormData,
+ hasFetchedData,
+ formData.studentNumber,
+ formData.section,
+ formData.age,
+ formData.dateOfBirth,
+ formData.cv,
+ formData.portfolioLink,
+ formData.secondChoice,
+ ]);
// Redirect if user already has an application
useEffect(() => {
@@ -122,9 +151,9 @@ export default function CommitteeApplication() {
router.push(
`/user/apply/committee-staff/${appStatus.committeeId}/progress`,
);
- else if (appStatus.hasEAApplication && appStatus.ebRole)
+ else if (appStatus.hasExecutiveAssociateApplication && appStatus.ebRole)
router.push(
- `/user/apply/executive-assistant/${appStatus.ebRole}/progress`,
+ `/user/apply/executive-associate/${appStatus.ebRole}/progress`,
);
}, [appStatus, status, router]);
@@ -134,18 +163,18 @@ export default function CommitteeApplication() {
const getCommitteeImage = (committeeId: string) => {
const imageMap: { [key: string]: string } = {
- academics: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_ACADEMICS.png",
- community: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_COMMDEV.png",
- creatives: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_CREATIVES.png",
- documentation: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_DOCU.png",
- external: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_EXTERNALS.png",
- finance: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_FINANCE.png",
- logistics: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_LOGISTICS.png",
- publicity: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_PUBLICITY.png",
- sports: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_SPOTA.png",
- technology: "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_TECHDEV.png",
+ academics: "/assets/css-apply-static-images/assets/committee_test/CSAR_ACADEMICS.webp",
+ community: "/assets/css-apply-static-images/assets/committee_test/CSAR_COMMDEV.webp",
+ creatives: "/assets/css-apply-static-images/assets/committee_test/CSAR_CREATIVES.webp",
+ documentation: "/assets/css-apply-static-images/assets/committee_test/CSAR_DOCU.webp",
+ external: "/assets/css-apply-static-images/assets/committee_test/CSAR_EXTERNALS.webp",
+ finance: "/assets/css-apply-static-images/assets/committee_test/CSAR_FINANCE.webp",
+ logistics: "/assets/css-apply-static-images/assets/committee_test/CSAR_LOGISTICS.webp",
+ publicity: "/assets/css-apply-static-images/assets/committee_test/CSAR_PUBLICITY.webp",
+ sports: "/assets/css-apply-static-images/assets/committee_test/CSAR_SPOTA.webp",
+ technology: "/assets/css-apply-static-images/assets/committee_test/CSAR_TECHDEV.webp",
};
- return imageMap[committeeId] || "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/Questions CSAR.png";
+ return imageMap[committeeId] || "/assets/css-apply-static-images/assets/committee_test/Questions%20CSAR.webp";
};
useEffect(() => {
@@ -171,7 +200,7 @@ export default function CommitteeApplication() {
appStatus &&
(appStatus.hasMemberApplication ||
appStatus.hasCommitteeApplication ||
- appStatus.hasEAApplication)
+ appStatus.hasExecutiveAssociateApplication)
)
return
;
if (!applicationsOpen) return
;
@@ -184,6 +213,9 @@ export default function CommitteeApplication() {
if (name === "studentNumber") {
const numericValue = value.replace(/[^0-9]/g, "").slice(0, 10);
updateFormData({ [name]: numericValue });
+ } else if (name === "age") {
+ const numericValue = value.replace(/[^0-9]/g, "").slice(0, 3);
+ updateFormData({ [name]: numericValue });
} else {
updateFormData({ [name]: value });
}
@@ -218,6 +250,12 @@ export default function CommitteeApplication() {
return;
}
+ if (!formData.age || !formData.dateOfBirth) {
+ setError("Please enter your age and date of birth");
+ setLoading(false);
+ return;
+ }
+
if (!formData.secondChoice) {
setError("Please select a second choice committee");
setLoading(false);
@@ -314,6 +352,9 @@ export default function CommitteeApplication() {
firstName: formData.firstName,
lastName: formData.lastName,
section: formData.section,
+ age: Number(formData.age),
+ dateOfBirth: formData.dateOfBirth,
+ isOldCssMember: formData.isOldCssMember,
firstOptionCommittee: committeeId,
secondOptionCommittee: formData.secondChoice,
cv: cvUploadResult.filePath,
@@ -389,7 +430,7 @@ export default function CommitteeApplication() {
}
return (
-
+
@@ -552,7 +593,7 @@ export default function CommitteeApplication() {
: "text-[#888888]"
}`}
style={{
- backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
+ backgroundImage: "url('/icons/chevron-down-dropdown.svg')",
}}
>
{formData.secondChoice
@@ -588,6 +629,25 @@ export default function CommitteeApplication() {
+
+
+
+
Curriculum Vitae (in pdf):
@@ -708,31 +768,28 @@ export default function CommitteeApplication() {
)}
-
+
setIsChecked(e.target.checked)}
- className="w-4 h-4 lg:w-6 lg:h-6 appearance-none rounded-full border-2 border-gray-400 transition-all duration-200 focus:outline-none hover:border-[#134687] checked:bg-blue-500 shadow-inner cursor-pointer"
+ className="absolute inset-0 block h-full w-full appearance-none rounded-full border-2 border-gray-400 transition-all duration-200 focus:outline-none hover:border-[#134687] checked:bg-blue-500 shadow-inner cursor-pointer"
required
/>
-
-
-
+ style={{
+ maskImage: "url(/icons/check.svg)",
+ WebkitMaskImage: "url(/icons/check.svg)",
+ maskSize: "contain",
+ maskRepeat: "no-repeat",
+ maskPosition: "center",
+ }}
+ />
- {loading
- ? uploading.cv || uploading.portfolio
- ? "Uploading files..."
- : "Submitting..."
- : "Next"}
+ {loading ? (
+
+
+ {uploading.cv || uploading.portfolio
+ ? "Uploading files..."
+ : "Submitting..."}
+
+ ) : (
+ "Next"
+ )}
diff --git a/src/app/user/apply/committee-staff/[committee]/progress/content.tsx b/src/app/user/apply/committee-staff/[committee]/progress/content.tsx
index 570c9fa..e9a8ca0 100644
--- a/src/app/user/apply/committee-staff/[committee]/progress/content.tsx
+++ b/src/app/user/apply/committee-staff/[committee]/progress/content.tsx
@@ -4,13 +4,19 @@ import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import Image from "next/image";
import Header from "@/components/Header";
+import LoadingSpinner from "@/components/LoadingSpinner";
import Footer from "@/components/Footer";
import { committeeRolesSubmitted } from "@/data/committeeRoles";
import { roles } from "@/data/ebRoles";
import { useSession } from "next-auth/react";
-import { truncateToLast7 } from "@/lib/truncate-utils";
+import { usePaymentQr } from "@/lib/usePaymentQr";
+import { useCommunityLink } from "@/lib/useCommunityLink";
+import { usePaymentReceiptTemplate } from "@/lib/usePaymentReceiptTemplate";
export default function CommitteeProgressPageContent() {
+ const { communityEnabled, communityUrl, communityLabel } = useCommunityLink();
+ const { paymentQrUrl } = usePaymentQr();
+ const { receiptTemplateUrl } = usePaymentReceiptTemplate();
const router = useRouter();
const { data: session } = useSession();
const { committee: committeeId } = useParams<{ committee: string }>();
@@ -35,11 +41,14 @@ export default function CommitteeProgressPageContent() {
hasAccepted: boolean;
createdAt: string;
updatedAt: string;
+ paymentProof?: string;
};
user: {
+ id: string;
studentNumber: string;
name: string;
section: string;
+ memberships?: Array<{ memberId: string }>;
};
ebRole: string;
meetingLink?: string;
@@ -51,13 +60,62 @@ export default function CommitteeProgressPageContent() {
const [isRespondingRedirect, setIsRespondingRedirect] = useState(false);
const [redirectError, setRedirectError] = useState("");
+ const [paymentProof, setPaymentProof] = useState("");
+ const [submittingPaymentProof, setSubmittingPaymentProof] = useState(false);
+ const [paymentProofError, setPaymentProofError] = useState("");
+ const hasPaymentProof = !!applicationData?.application?.paymentProof;
+
+ const handlePaymentProofSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setPaymentProofError("");
+ setSubmittingPaymentProof(true);
+
+ try {
+ const response = await fetch("/api/applications/payment-proof", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ paymentProof }),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to submit payment proof");
+
+ setApplicationData((current) =>
+ current && current.application
+ ? {
+ ...current,
+ application: {
+ ...current.application,
+ paymentProof: data.paymentProof,
+ },
+ user: {
+ ...current.user,
+ memberships: [{ memberId: data.memberId }],
+ },
+ }
+ : current,
+ );
+ setPaymentProof("");
+ } catch (error) {
+ setPaymentProofError(
+ error instanceof Error
+ ? error.message
+ : "Failed to submit payment proof",
+ );
+ } finally {
+ setSubmittingPaymentProof(false);
+ }
+ };
+
const getRedirectionDisplayName = (redirection?: string) => {
if (!redirection) return "";
if (redirection.toLowerCase() === "member") return "Member";
if (redirection.startsWith("committee-")) {
const committeeId = redirection.replace("committee-", "");
- const committee = committeeRolesSubmitted.find((c) => c.id === committeeId);
+ const committee = committeeRolesSubmitted.find(
+ (c) => c.id === committeeId,
+ );
return committee?.title || committeeId;
}
@@ -67,7 +125,7 @@ export default function CommitteeProgressPageContent() {
if (committee) return committee.title;
const eaRole = roles.find((r) => r.id === redirection);
- if (eaRole) return `Executive Assistant for ${eaRole.title}`;
+ if (eaRole) return `Executive Associate for ${eaRole.title}`;
return redirection;
};
@@ -128,7 +186,9 @@ export default function CommitteeProgressPageContent() {
if (!response.ok) {
const result = await response.json();
- setRedirectError(result.error || "Failed to update redirection response");
+ setRedirectError(
+ result.error || "Failed to update redirection response",
+ );
return;
}
@@ -194,20 +254,16 @@ export default function CommitteeProgressPageContent() {
{/* Header section with icon and title */}
@@ -311,7 +367,9 @@ export default function CommitteeProgressPageContent() {
);
const hasPendingRedirectionDecision =
application.status === "redirected" && !!application.redirection;
- const memberIdDisplay = truncateToLast7(application.id).toUpperCase();
+ const memberIdDisplay =
+ applicationData.user.memberships?.[0]?.memberId ??
+ applicationData.user.id.slice(-7).toUpperCase();
const hideMeetingAccess =
application.status === "evaluating" ||
application.status === "failed" ||
@@ -331,7 +389,7 @@ export default function CommitteeProgressPageContent() {
: "";
return (
-
+
@@ -483,7 +541,11 @@ export default function CommitteeProgressPageContent() {
- {application.hasAccepted ? memberIdDisplay : "Pending"}
+ {application.hasAccepted && hasPaymentProof
+ ? memberIdDisplay
+ : application.hasAccepted
+ ? "Submit payment proof first"
+ : "Pending"}
@@ -540,11 +602,14 @@ export default function CommitteeProgressPageContent() {
Member ID: {" "}
- {memberIdDisplay}
+ {hasPaymentProof
+ ? memberIdDisplay
+ : "Submit payment proof first"}
{application.redirection ? (
- Accepted at: {getRedirectionDisplayName(application.redirection)}
+ Accepted at: {" "}
+ {getRedirectionDisplayName(application.redirection)}
) : (
@@ -575,7 +640,11 @@ export default function CommitteeProgressPageContent() {
Application Redirected
- You were offered a redirection to {getRedirectionDisplayName(application.redirection)} .
+ You were offered a redirection to{" "}
+
+ {getRedirectionDisplayName(application.redirection)}
+
+ .
{hasPendingRedirectionDecision && (
@@ -585,7 +654,9 @@ export default function CommitteeProgressPageContent() {
disabled={isRespondingRedirect}
className="px-4 py-2 rounded-lg bg-[#044FAF] text-white text-sm font-medium hover:bg-[#033c87] disabled:opacity-50"
>
- {isRespondingRedirect ? "Processing..." : "Accept Redirection"}
+ {isRespondingRedirect
+ ? "Processing..."
+ : "Accept Redirection"}
handleRedirectionResponse("reject")}
@@ -615,42 +686,100 @@ export default function CommitteeProgressPageContent() {
Payment Instructions
-
-
- To complete your membership, please proceed with the payment
- of{" "}
-
- ₱250.00
- {" "}
- using the GCash QR code below:
-
+
+ {!hasPaymentProof && (
+ <>
+
+ To complete your membership, please proceed with the
+ payment of{" "}
+
+ ₱250.00
+ {" "}
+ using the GCash QR code below:
+
-
-
-
+
+ {paymentQrUrl ? (
+
+ ) : (
+
+ Payment QR code is currently unavailable. Please
+ contact css.cics@ust.edu.ph for payment instructions.
+
+ )}
+
+ >
+ )}
-
+
Important Payment Message
- When sending your payment via GCash QR, you MUST include
- this message:
+ After payment, fill out the acknowledgement receipt PDF and
+ upload it to Google Drive, then submit the shareable link
+ below.
-
-
- Member ID: {memberIdDisplay}
-
-
+ {receiptTemplateUrl && (
+
+ )}
+ {!hasPaymentProof ? (
+
+ ) : (
+
+ Payment proof submitted. Your Member ID is now available
+ above.
+
+ )}
- This message is required for payment verification and
- processing.
+ Your Member ID will be shown after submitting your
+ acknowledgement receipt link.
@@ -663,7 +792,7 @@ export default function CommitteeProgressPageContent() {
)}
{/* Join Our Community - Only show for accepted applications */}
- {application.hasAccepted && (
+ {application.hasAccepted && communityEnabled && communityUrl && (
diff --git a/src/app/user/apply/committee-staff/[committee]/schedule/content.tsx b/src/app/user/apply/committee-staff/[committee]/schedule/content.tsx
index 96a2fcb..e175d07 100644
--- a/src/app/user/apply/committee-staff/[committee]/schedule/content.tsx
+++ b/src/app/user/apply/committee-staff/[committee]/schedule/content.tsx
@@ -140,15 +140,23 @@ export default function SchedulePageContent() {
const end = new Date();
end.setDate(end.getDate() + 14); // Default fallback
try {
- const cycleRes = await fetch('/api/admin/recruitment-cycle');
+ const cycleRes = await fetch('/api/recruitment-cycle/active');
if (cycleRes.ok) {
const cycleData = await cycleRes.json();
if (cycleData.activeCycle?.interviewStart) {
- start.setTime(new Date(cycleData.activeCycle.interviewStart).getTime());
+ const [year, month, day] = cycleData.activeCycle.interviewStart
+ .slice(0, 10)
+ .split("-")
+ .map(Number);
+ start.setFullYear(year, month - 1, day);
start.setHours(0, 0, 0, 0);
}
if (cycleData.activeCycle?.interviewEnd) {
- end.setTime(new Date(cycleData.activeCycle.interviewEnd).getTime());
+ const [year, month, day] = cycleData.activeCycle.interviewEnd
+ .slice(0, 10)
+ .split("-")
+ .map(Number);
+ end.setFullYear(year, month - 1, day);
}
}
} catch {
@@ -456,7 +464,7 @@ export default function SchedulePageContent() {
}
return (
-
+
diff --git a/src/app/user/apply/committee-staff/[committee]/success/content.tsx b/src/app/user/apply/committee-staff/[committee]/success/content.tsx
index b0a7280..4aff44f 100644
--- a/src/app/user/apply/committee-staff/[committee]/success/content.tsx
+++ b/src/app/user/apply/committee-staff/[committee]/success/content.tsx
@@ -20,7 +20,7 @@ export default function SuccessPageContent() {
if (!selectedCommittee) {
return (
-
+
@@ -39,13 +39,13 @@ export default function SuccessPageContent() {
}
return (
-
+
;
}
@@ -63,34 +63,34 @@ export default function StaffApplication() {
const getCommitteeImage = (committeeId: string) => {
const imageMap: { [key: string]: string } = {
academics:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_ACADEMICS.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_ACADEMICS.webp",
community:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_COMMDEV.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_COMMDEV.webp",
creatives:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_CREATIVES.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_CREATIVES.webp",
documentation:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_DOCU.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_DOCU.webp",
external:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_EXTERNALS.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_EXTERNALS.webp",
finance:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_FINANCE.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_FINANCE.webp",
logistics:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_LOGISTICS.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_LOGISTICS.webp",
publicity:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_PUBLICITY.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_PUBLICITY.webp",
sports:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_SPOTA.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_SPOTA.webp",
technology:
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/CSAR_TECHDEV.png",
+ "/assets/css-apply-static-images/assets/committee_test/CSAR_TECHDEV.webp",
};
return (
imageMap[committeeId] ||
- "https://odjmlznlgvuslhceobtz.supabase.co/storage/v1/object/public/css-apply-static-images/assets/committee_test/Questions CSAR.png"
+ "/assets/css-apply-static-images/assets/committee_test/Questions%20CSAR.webp"
);
};
return (
-
+
@@ -265,7 +265,7 @@ export default function StaffApplication() {
(null);
+ const { roles, isLoading: isRolesLoading } = useEbRoles();
const selectedRole = roles.find((r) => r.id === ebId);
// SWR hook — shared cache with user dashboard, no duplicate fetch
@@ -43,6 +45,9 @@ export default function ExecutiveAssistantApplication() {
firstName: "",
lastName: "",
section: "",
+ age: "",
+ dateOfBirth: "",
+ isOldCssMember: false,
secondOptionEb: "",
cv: "",
};
@@ -75,7 +80,7 @@ export default function ExecutiveAssistantApplication() {
}
// Fetch existing user data
- const response = await fetch("/api/applications/executive-assistant");
+ const response = await fetch("/api/applications/executive-associate");
if (response.ok) {
const data = await response.json();
@@ -89,6 +94,18 @@ export default function ExecutiveAssistantApplication() {
if (!formData.section && data.user?.section) {
updates.section = data.user.section;
}
+
+ if (!formData.age && data.user?.age) {
+ updates.age = String(data.user.age);
+ }
+
+ if (!formData.dateOfBirth && data.user?.dateOfBirth) {
+ updates.dateOfBirth = data.user.dateOfBirth.slice(0, 10);
+ }
+
+ if (data.user?.isOldCssMember !== null && data.user?.isOldCssMember !== undefined) {
+ updates.isOldCssMember = data.user.isOldCssMember;
+ }
if (!formData.secondOptionEb && data.application?.secondOptionEb) {
updates.secondOptionEb = data.application.secondOptionEb;
@@ -107,7 +124,19 @@ export default function ExecutiveAssistantApplication() {
};
fetchApplicationData();
- }, [session, status, isLoaded, updateFormData, hasFetchedData, formData.studentNumber, formData.section, formData.cv, formData.secondOptionEb]);
+ }, [
+ session,
+ status,
+ isLoaded,
+ updateFormData,
+ hasFetchedData,
+ formData.studentNumber,
+ formData.section,
+ formData.age,
+ formData.dateOfBirth,
+ formData.cv,
+ formData.secondOptionEb,
+ ]);
// Redirect if user already has an application
useEffect(() => {
@@ -118,9 +147,9 @@ export default function ExecutiveAssistantApplication() {
router.push(
`/user/apply/committee-staff/${appStatus.committeeId}/progress`,
);
- else if (appStatus.hasEAApplication && appStatus.ebRole)
+ else if (appStatus.hasExecutiveAssociateApplication && appStatus.ebRole)
router.push(
- `/user/apply/executive-assistant/${appStatus.ebRole}/progress`,
+ `/user/apply/executive-associate/${appStatus.ebRole}/progress`,
);
}, [appStatus, status, router]);
@@ -142,12 +171,12 @@ export default function ExecutiveAssistantApplication() {
}, [uiState.isOpen, updateUIState]);
// Early returns AFTER all hooks
- if (status === "loading" || isAppLoading) return ;
+ if (status === "loading" || isAppLoading || isRolesLoading) return ;
if (
appStatus &&
(appStatus.hasMemberApplication ||
appStatus.hasCommitteeApplication ||
- appStatus.hasEAApplication)
+ appStatus.hasExecutiveAssociateApplication)
)
return ;
if (!applicationsOpen) return ;
@@ -157,6 +186,9 @@ export default function ExecutiveAssistantApplication() {
if (name === "studentNumber") {
const numericValue = value.replace(/[^0-9]/g, "").slice(0, 10);
updateFormData({ [name]: numericValue });
+ } else if (name === "age") {
+ const numericValue = value.replace(/[^0-9]/g, "").slice(0, 3);
+ updateFormData({ [name]: numericValue });
} else {
updateFormData({ [name]: value });
}
@@ -191,6 +223,12 @@ export default function ExecutiveAssistantApplication() {
return;
}
+ if (!formData.age || !formData.dateOfBirth) {
+ setError("Please enter your age and date of birth");
+ setLoading(false);
+ return;
+ }
+
if (!formData.secondOptionEb) {
setError("Please select a second choice EB role");
setLoading(false);
@@ -210,7 +248,7 @@ export default function ExecutiveAssistantApplication() {
uploadFormData.append("studentNumber", formData.studentNumber);
uploadFormData.append("section", formData.section);
uploadFormData.append("fileType", "cv");
- uploadFormData.append("applicationType", "ea");
+ uploadFormData.append("applicationType", "executive-associate");
const uploadResponse = await fetch("/api/files/upload", {
method: "POST",
@@ -227,7 +265,7 @@ export default function ExecutiveAssistantApplication() {
setUploading({ cv: false });
- const response = await fetch("/api/applications/executive-assistant", {
+ const response = await fetch("/api/applications/executive-associate", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -237,6 +275,9 @@ export default function ExecutiveAssistantApplication() {
firstName: formData.firstName,
lastName: formData.lastName,
section: formData.section,
+ age: Number(formData.age),
+ dateOfBirth: formData.dateOfBirth,
+ isOldCssMember: formData.isOldCssMember,
ebRole: ebId,
firstOptionEb: ebId,
secondOptionEb: formData.secondOptionEb,
@@ -248,7 +289,7 @@ export default function ExecutiveAssistantApplication() {
if (response.ok) {
clearFormData(); // Clear the form data from localStorage
- router.push(`/user/apply/executive-assistant/${ebId}/schedule`);
+ router.push(`/user/apply/executive-associate/${ebId}/schedule`);
} else {
setError(
responseData.error ||
@@ -300,7 +341,7 @@ export default function ExecutiveAssistantApplication() {
EB role not found
router.push("/user/apply/executive-assistant")}
+ onClick={() => router.push("/user/apply/executive-associate")}
className="bg-[#044FAF] text-white px-6 py-3 rounded-md font-inter font-normal text-sm hover:bg-[#04387B] transition-all duration-150 active:scale-95"
>
Back to EB Selection
@@ -313,7 +354,7 @@ export default function ExecutiveAssistantApplication() {
}
return (
-
+
@@ -323,12 +364,12 @@ export default function ExecutiveAssistantApplication() {
className="rounded-3xl sm:bg-white sm:shadow-[0_4px_4px_0_rgba(0,0,0,0.31)] p-10 md:p-16 lg:py-20 lg:px-24"
>
- Apply as EA to the
+ Apply as Executive Associate to the
{selectedRole.title}
- Executive Assistants work closely with the CSS Executive Boards to
+ Executive Associates work closely with the CSS Executive Boards to
help them with their tasks in events and committees. This role
requires responsibility, attention to detail, and strong
communication skills.
@@ -340,7 +381,7 @@ export default function ExecutiveAssistantApplication() {
router.push("/user/apply/executive-assistant")}
+ onClick={() => router.push("/user/apply/executive-associate")}
className="flex items-center justify-center rounded-full bg-[#D9D9D9] w-5 h-5 lg:w-10 lg:h-10 cursor-pointer hover:bg-[#DAE2ED] transition-colors"
>
@@ -476,7 +517,7 @@ export default function ExecutiveAssistantApplication() {
: "text-[#888888]"
}`}
style={{
- backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e")`,
+ backgroundImage: "url('/icons/chevron-down-dropdown.svg')",
}}
>
{formData.secondOptionEb
@@ -513,6 +554,25 @@ export default function ExecutiveAssistantApplication() {
+
+
+
+
Curriculum Vitae (in pdf):
@@ -567,31 +627,28 @@ export default function ExecutiveAssistantApplication() {
-
+
setIsChecked(e.target.checked)}
- className="w-4 h-4 lg:w-6 lg:h-6 appearance-none rounded-full border-2 border-gray-400 transition-all duration-200 focus:outline-none hover:border-[#134687] checked:bg-blue-500 shadow-inner cursor-pointer"
+ className="absolute inset-0 block h-full w-full appearance-none rounded-full border-2 border-gray-400 transition-all duration-200 focus:outline-none hover:border-[#134687] checked:bg-blue-500 shadow-inner cursor-pointer"
required
/>
-
-
-
+ style={{
+ maskImage: "url(/icons/check.svg)",
+ WebkitMaskImage: "url(/icons/check.svg)",
+ maskSize: "contain",
+ maskRepeat: "no-repeat",
+ maskPosition: "center",
+ }}
+ />
router.push("/user/apply/executive-assistant")}
+ onClick={() => router.push("/user/apply/executive-associate")}
className="hidden lg:block bg-[#E7E3E3] text-gray-700 px-15 py-3 rounded-lg font-inter font-semibold text-sm hover:bg-[#CDCCCC] transition-all duration-150 active:scale-95"
>
Back
@@ -629,11 +686,17 @@ export default function ExecutiveAssistantApplication() {
disabled={loading}
className="whitespace-nowrap font-inter text-sm font-semibold text-[#134687] px-15 py-3 rounded-lg border-2 border-[#134687] bg-white hover:bg-[#B1CDF0] transition-all duration-150 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
>
- {loading
- ? uploading.cv
- ? "Uploading CV..."
- : "Submitting..."
- : "Next"}
+ {loading ? (
+
+
+ {uploading.cv ? "Uploading CV..." : "Submitting..."}
+
+ ) : (
+ "Next"
+ )}
diff --git a/src/app/user/apply/executive-assistant/[eb-role]/progress/content.tsx b/src/app/user/apply/executive-associate/[eb-role]/progress/content.tsx
similarity index 76%
rename from src/app/user/apply/executive-assistant/[eb-role]/progress/content.tsx
rename to src/app/user/apply/executive-associate/[eb-role]/progress/content.tsx
index e8b8b7a..0357f51 100644
--- a/src/app/user/apply/executive-assistant/[eb-role]/progress/content.tsx
+++ b/src/app/user/apply/executive-associate/[eb-role]/progress/content.tsx
@@ -4,17 +4,25 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Header from "@/components/Header";
+import LoadingSpinner from "@/components/LoadingSpinner";
import Footer from "@/components/Footer";
-import { roles } from "@/data/ebRoles";
+import { useEbRoles } from "@/lib/useEbRoles";
import { committeeRolesSubmitted } from "@/data/committeeRoles";
import { useSession } from "next-auth/react";
-import { truncateToLast7 } from "@/lib/truncate-utils";
+import { usePaymentQr } from "@/lib/usePaymentQr";
+import { useCommunityLink } from "@/lib/useCommunityLink";
+import { usePaymentReceiptTemplate } from "@/lib/usePaymentReceiptTemplate";
+
import type React from "react";
export default function EAProgressPageContent() {
+ const { communityEnabled, communityUrl, communityLabel } = useCommunityLink();
+ const { paymentQrUrl } = usePaymentQr();
+ const { receiptTemplateUrl } = usePaymentReceiptTemplate();
const router = useRouter();
const { data: session } = useSession();
+ const { roles } = useEbRoles();
const [applicationData, setApplicationData] = useState<{
hasApplication: boolean;
application: {
@@ -35,11 +43,14 @@ export default function EAProgressPageContent() {
hasAccepted: boolean;
createdAt: string;
updatedAt: string;
+ paymentProof?: string;
};
user: {
+ id: string;
studentNumber: string;
name: string;
section: string;
+ memberships?: Array<{ memberId: string }>;
};
ebRole: string;
meetingLink?: string;
@@ -51,13 +62,62 @@ export default function EAProgressPageContent() {
const [isRespondingRedirect, setIsRespondingRedirect] = useState(false);
const [redirectError, setRedirectError] = useState("");
+ const [paymentProof, setPaymentProof] = useState("");
+ const [submittingPaymentProof, setSubmittingPaymentProof] = useState(false);
+ const [paymentProofError, setPaymentProofError] = useState("");
+ const hasPaymentProof = !!applicationData?.application?.paymentProof;
+
+ const handlePaymentProofSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setPaymentProofError("");
+ setSubmittingPaymentProof(true);
+
+ try {
+ const response = await fetch("/api/applications/payment-proof", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ paymentProof }),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to submit payment proof");
+
+ setApplicationData((current) =>
+ current && current.application
+ ? {
+ ...current,
+ application: {
+ ...current.application,
+ paymentProof: data.paymentProof,
+ },
+ user: {
+ ...current.user,
+ memberships: [{ memberId: data.memberId }],
+ },
+ }
+ : current,
+ );
+ setPaymentProof("");
+ } catch (error) {
+ setPaymentProofError(
+ error instanceof Error
+ ? error.message
+ : "Failed to submit payment proof",
+ );
+ } finally {
+ setSubmittingPaymentProof(false);
+ }
+ };
+
const getRedirectionDisplayName = (redirection?: string) => {
if (!redirection) return "";
if (redirection.toLowerCase() === "member") return "Member";
if (redirection.startsWith("committee-")) {
const committeeId = redirection.replace("committee-", "");
- const committee = committeeRolesSubmitted.find((c) => c.id === committeeId);
+ const committee = committeeRolesSubmitted.find(
+ (c) => c.id === committeeId,
+ );
return committee?.title || committeeId;
}
@@ -67,14 +127,14 @@ export default function EAProgressPageContent() {
if (committee) return committee.title;
const eaRole = roles.find((r) => r.id === redirection);
- if (eaRole) return `Executive Assistant for ${eaRole.title}`;
+ if (eaRole) return `Executive Associate for ${eaRole.title}`;
return redirection;
};
const fetchApplicationData = async () => {
try {
- const response = await fetch("/api/applications/executive-assistant");
+ const response = await fetch("/api/applications/executive-associate");
if (response.ok) {
const data = await response.json();
setApplicationData(data);
@@ -128,7 +188,9 @@ export default function EAProgressPageContent() {
if (!response.ok) {
const result = await response.json();
- setRedirectError(result.error || "Failed to update redirection response");
+ setRedirectError(
+ result.error || "Failed to update redirection response",
+ );
return;
}
@@ -145,14 +207,14 @@ export default function EAProgressPageContent() {
const handleDeleteApplication = async () => {
setIsDeleting(true);
try {
- const response = await fetch("/api/applications/executive-assistant", {
+ const response = await fetch("/api/applications/executive-associate", {
method: "DELETE",
});
const result = await response.json();
if (response.ok) {
- router.push("/user/apply/executive-assistant");
+ router.push("/user/apply/executive-associate");
} else {
alert(result.error || "Failed to delete application");
}
@@ -193,20 +255,16 @@ export default function EAProgressPageContent() {
{/* Header section with icon and title */}
@@ -226,7 +284,9 @@ export default function EAProgressPageContent() {
Warning:
- This will delete your current EA application.
+
+ This will delete your current Executive Associate application.
+
You will need to start a new application to apply again.
@@ -286,10 +346,10 @@ export default function EAProgressPageContent() {
No Application Found
- You don't have an active EA application.
+ You don't have an active Executive Associate application.
router.push("/user/apply/executive-assistant")}
+ onClick={() => router.push("/user/apply/executive-associate")}
className="bg-[#134687] hover:bg-[#0d3569] text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Apply Now
@@ -306,7 +366,9 @@ export default function EAProgressPageContent() {
const secondEB = roles.find((role) => role.id === application.secondOptionEb);
const hasPendingRedirectionDecision =
application.status === "redirected" && !!application.redirection;
- const memberIdDisplay = truncateToLast7(application.id).toUpperCase();
+ const memberIdDisplay =
+ applicationData.user.memberships?.[0]?.memberId ??
+ applicationData.user.id.slice(-7).toUpperCase();
const hideMeetingAccess =
application.status === "evaluating" ||
application.status === "failed" ||
@@ -326,7 +388,7 @@ export default function EAProgressPageContent() {
: "";
return (
-
+
@@ -337,7 +399,7 @@ export default function EAProgressPageContent() {
Welcome, {firstName} 👋
- Track your Executive Assistant application journey.
+ Track your Executive Associate application journey.
@@ -478,7 +540,11 @@ export default function EAProgressPageContent() {
- {application.hasAccepted ? memberIdDisplay : "Pending"}
+ {application.hasAccepted && hasPaymentProof
+ ? memberIdDisplay
+ : application.hasAccepted
+ ? "Submit payment proof first"
+ : "Pending"}
@@ -535,15 +601,18 @@ export default function EAProgressPageContent() {
Member ID: {" "}
- {memberIdDisplay}
+ {hasPaymentProof
+ ? memberIdDisplay
+ : "Submit payment proof first"}
{application.redirection ? (
- Accepted at: {getRedirectionDisplayName(application.redirection)}
+ Accepted at: {" "}
+ {getRedirectionDisplayName(application.redirection)}
) : (
- Accepted at: Executive Assistant for{" "}
+ Accepted at: Executive Associate for{" "}
{firstEB?.title}
)}
@@ -570,7 +639,11 @@ export default function EAProgressPageContent() {
Application Redirected
- You were offered a redirection to {getRedirectionDisplayName(application.redirection)} .
+ You were offered a redirection to{" "}
+
+ {getRedirectionDisplayName(application.redirection)}
+
+ .
{hasPendingRedirectionDecision && (
@@ -580,7 +653,9 @@ export default function EAProgressPageContent() {
disabled={isRespondingRedirect}
className="px-4 py-2 rounded-lg bg-[#044FAF] text-white text-sm font-medium hover:bg-[#033c87] disabled:opacity-50"
>
- {isRespondingRedirect ? "Processing..." : "Accept Redirection"}
+ {isRespondingRedirect
+ ? "Processing..."
+ : "Accept Redirection"}
handleRedirectionResponse("reject")}
@@ -610,42 +685,100 @@ export default function EAProgressPageContent() {
Payment Instructions
-
-
- To complete your membership, please proceed with the payment
- of{" "}
-
- ₱250.00
- {" "}
- using the GCash QR code below:
-
+
+ {!hasPaymentProof && (
+ <>
+
+ To complete your membership, please proceed with the
+ payment of{" "}
+
+ ₱250.00
+ {" "}
+ using the GCash QR code below:
+
-
-
-
+
+ {paymentQrUrl ? (
+
+ ) : (
+
+ Payment QR code is currently unavailable. Please
+ contact css.cics@ust.edu.ph for payment instructions.
+
+ )}
+
+ >
+ )}
-
+
Important Payment Message
- When sending your payment via GCash QR, you MUST include
- this message:
+ After payment, fill out the acknowledgement receipt PDF and
+ upload it to Google Drive, then submit the shareable link
+ below.
-
-
- Member ID: {memberIdDisplay}
-
-
+ {receiptTemplateUrl && (
+
+ )}
+ {!hasPaymentProof ? (
+
+ ) : (
+
+ Payment proof submitted. Your Member ID is now available
+ above.
+
+ )}
- This message is required for payment verification and
- processing.
+ Your Member ID will be shown after submitting your
+ acknowledgement receipt link.
@@ -658,7 +791,7 @@ export default function EAProgressPageContent() {
)}
{/* Join Our Community - Only show for accepted applications */}
- {application.hasAccepted && (
+ {application.hasAccepted && communityEnabled && communityUrl && (
@@ -692,7 +825,7 @@ export default function EAProgressPageContent() {
router.push(
- `/user/apply/executive-assistant/${application.firstOptionEb}/schedule`,
+ `/user/apply/executive-associate/${application.firstOptionEb}/schedule`,
)
}
className="bg-[#134687] border-[#0d3569] border-2 text-white px-15 py-3 rounded-lg font-inter font-semibold text-xs lg:text-sm hover:bg-[#0d3569] transition-all duration-150 active:scale-95 whitespace-nowrap"
diff --git a/src/app/user/apply/executive-assistant/[eb-role]/progress/page.tsx b/src/app/user/apply/executive-associate/[eb-role]/progress/page.tsx
similarity index 81%
rename from src/app/user/apply/executive-assistant/[eb-role]/progress/page.tsx
rename to src/app/user/apply/executive-associate/[eb-role]/progress/page.tsx
index 74896f1..e76586e 100644
--- a/src/app/user/apply/executive-assistant/[eb-role]/progress/page.tsx
+++ b/src/app/user/apply/executive-associate/[eb-role]/progress/page.tsx
@@ -3,7 +3,7 @@ import ExecutiveAssistantProgressPageContent from "./content";
export default function ExecutiveAssistantProgressPage() {
return (
-
+
);
diff --git a/src/app/user/apply/executive-assistant/[eb-role]/schedule/content.tsx b/src/app/user/apply/executive-associate/[eb-role]/schedule/content.tsx
similarity index 96%
rename from src/app/user/apply/executive-assistant/[eb-role]/schedule/content.tsx
rename to src/app/user/apply/executive-associate/[eb-role]/schedule/content.tsx
index a973379..d9e1ea3 100644
--- a/src/app/user/apply/executive-assistant/[eb-role]/schedule/content.tsx
+++ b/src/app/user/apply/executive-associate/[eb-role]/schedule/content.tsx
@@ -7,7 +7,7 @@ import { useRouter, useParams } from "next/navigation";
import Header from "@/components/Header";
import ConfirmationModal from "@/components/Modal";
import Footer from "@/components/Footer";
-import { roles } from "@/data/ebRoles";
+import { useEbRoles } from "@/lib/useEbRoles";
import { useApplicationsOpen } from "@/lib/useApplicationsOpen";
export default function SchedulePageContent() {
@@ -119,17 +119,23 @@ export default function SchedulePageContent() {
const end = new Date();
end.setDate(end.getDate() + 14); // Default fallback
try {
- const cycleRes = await fetch("/api/admin/recruitment-cycle");
+ const cycleRes = await fetch("/api/recruitment-cycle/active");
if (cycleRes.ok) {
const cycleData = await cycleRes.json();
if (cycleData.activeCycle?.interviewStart) {
- start.setTime(
- new Date(cycleData.activeCycle.interviewStart).getTime(),
- );
+ const [year, month, day] = cycleData.activeCycle.interviewStart
+ .slice(0, 10)
+ .split("-")
+ .map(Number);
+ start.setFullYear(year, month - 1, day);
start.setHours(0, 0, 0, 0);
}
if (cycleData.activeCycle?.interviewEnd) {
- end.setTime(new Date(cycleData.activeCycle.interviewEnd).getTime());
+ const [year, month, day] = cycleData.activeCycle.interviewEnd
+ .slice(0, 10)
+ .split("-")
+ .map(Number);
+ end.setFullYear(year, month - 1, day);
}
}
} catch {
@@ -282,7 +288,7 @@ export default function SchedulePageContent() {
try {
const userResponse = await fetch(
- "/api/applications/executive-assistant",
+ "/api/applications/executive-associate",
);
let studentNumber = "";
@@ -296,7 +302,7 @@ export default function SchedulePageContent() {
}
const response = await fetch(
- "/api/applications/executive-assistant/schedule",
+ "/api/applications/executive-associate/schedule",
{
method: "POST",
headers: {
@@ -338,7 +344,7 @@ export default function SchedulePageContent() {
`${formattedDate} at ${formattedTime}`,
);
- router.push(`/user/apply/executive-assistant/${ebId}/success`);
+ router.push(`/user/apply/executive-associate/${ebId}/success`);
} else {
if (result.conflict) {
// Handle slot conflict - refresh the page to get updated availability
@@ -363,6 +369,7 @@ export default function SchedulePageContent() {
setShowModal(false);
};
+ const { roles } = useEbRoles();
const selectedEB = roles.find((role) => role.id === ebId);
if (!selectedEB) {
@@ -374,7 +381,7 @@ export default function SchedulePageContent() {
Executive Board role not found
router.push("/user/apply/executive-assistant")}
+ onClick={() => router.push("/user/apply/executive-associate")}
className="bg-[#044FAF] text-white px-6 py-3 rounded-md font-inter font-normal text-sm hover:bg-[#04387B] transition-all duration-150 active:scale-95"
>
Back to Role Selection
@@ -398,20 +405,20 @@ export default function SchedulePageContent() {
}
return (
-
+
- Apply as Executive Assistant for{" "}
+ Apply as Executive Associate for{" "}
{selectedEB.title}
- Executive Assistants work closely with the CSS Executive Boards to
+ Executive Associates work closely with the CSS Executive Boards to
help them with their tasks in events and committees. This role
requires responsibility, attention to detail, and strong
communication skills.
@@ -424,7 +431,7 @@ export default function SchedulePageContent() {
router.push("/user/apply/executive-assistant")}
+ onClick={() => router.push("/user/apply/executive-associate")}
className="flex items-center justify-center rounded-full bg-[#D9D9D9] w-5 h-5 lg:w-10 lg:h-10 cursor-pointer hover:bg-[#DAE2ED] transition-colors"
>
@@ -437,7 +444,7 @@ export default function SchedulePageContent() {
router.push(
- `/user/apply/executive-assistant/application?eb=${ebId}`,
+ `/user/apply/executive-associate/application?eb=${ebId}`,
)
}
className="flex items-center justify-center rounded-full bg-[#D9D9D9] w-5 h-5 lg:w-10 lg:h-10 cursor-pointer hover:bg-[#DAE2ED] transition-colors"
@@ -645,7 +652,7 @@ export default function SchedulePageContent() {
type="button"
onClick={() =>
router.push(
- `/user/apply/executive-assistant/${ebId}/application`,
+ `/user/apply/executive-associate/${ebId}/application`,
)
}
className="cursor-pointer hidden lg:block bg-[#E7E3E3] text-gray-700 px-15 py-3 rounded-lg font-inter font-semibold text-sm hover:bg-[#CDCCCC] transition-all duration-150 active:scale-95"
diff --git a/src/app/user/apply/executive-assistant/[eb-role]/schedule/page.tsx b/src/app/user/apply/executive-associate/[eb-role]/schedule/page.tsx
similarity index 78%
rename from src/app/user/apply/executive-assistant/[eb-role]/schedule/page.tsx
rename to src/app/user/apply/executive-associate/[eb-role]/schedule/page.tsx
index e752af2..43f0423 100644
--- a/src/app/user/apply/executive-assistant/[eb-role]/schedule/page.tsx
+++ b/src/app/user/apply/executive-associate/[eb-role]/schedule/page.tsx
@@ -3,7 +3,7 @@ import SchedulePageContent from "./content";
export default function SchedulePage() {
return (
-
+
);
diff --git a/src/app/user/apply/executive-assistant/[eb-role]/success/content.tsx b/src/app/user/apply/executive-associate/[eb-role]/success/content.tsx
similarity index 91%
rename from src/app/user/apply/executive-assistant/[eb-role]/success/content.tsx
rename to src/app/user/apply/executive-associate/[eb-role]/success/content.tsx
index 86adb52..eaacd61 100644
--- a/src/app/user/apply/executive-assistant/[eb-role]/success/content.tsx
+++ b/src/app/user/apply/executive-associate/[eb-role]/success/content.tsx
@@ -5,7 +5,7 @@ import { useRouter, useParams } from "next/navigation";
import { useState } from "react";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
-import { roles } from "@/data/ebRoles";
+import { useEbRoles } from "@/lib/useEbRoles";
export default function SuccessPageContent() {
const router = useRouter();
@@ -14,20 +14,21 @@ export default function SuccessPageContent() {
() => localStorage.getItem("scheduledTime") || "",
);
+ const { roles } = useEbRoles();
const selectedEB = roles.find((role) => role.id === ebRole);
if (!selectedEB) {
return (
-
+
Executive Board role not found
router.push("/user/apply/executive-assistant")}
+ onClick={() => router.push("/user/apply/executive-associate")}
className="bg-[#044FAF] text-white px-6 py-3 rounded-md font-inter font-normal text-sm hover:bg-[#04387B] transition-all duration-150 active:scale-95"
>
Back to Role Selection
@@ -41,13 +42,13 @@ export default function SuccessPageContent() {
}
return (
-
+
Thank you for applying as
- Executive Assistant to the {selectedEB.title}
+ Executive Associate to the {selectedEB.title}
@@ -145,7 +146,7 @@ export default function SuccessPageContent() {
router.push(
- `/user/apply/executive-assistant/${ebRole}/progress`,
+ `/user/apply/executive-associate/${ebRole}/progress`,
)
}
className="bg-[#044FAF] text-white px-6 sm:px-8 py-3 rounded-lg font-inter font-semibold text-sm hover:bg-[#04387B] transition-all duration-150 active:scale-95"
diff --git a/src/app/user/apply/executive-assistant/[eb-role]/success/page.tsx b/src/app/user/apply/executive-associate/[eb-role]/success/page.tsx
similarity index 78%
rename from src/app/user/apply/executive-assistant/[eb-role]/success/page.tsx
rename to src/app/user/apply/executive-associate/[eb-role]/success/page.tsx
index 5238711..c0f6bf8 100644
--- a/src/app/user/apply/executive-assistant/[eb-role]/success/page.tsx
+++ b/src/app/user/apply/executive-associate/[eb-role]/success/page.tsx
@@ -3,7 +3,7 @@ import SuccessPageContent from "./content";
export default function SuccessPage() {
return (
-
+
);
diff --git a/src/app/user/apply/executive-assistant/page.tsx b/src/app/user/apply/executive-associate/page.tsx
similarity index 93%
rename from src/app/user/apply/executive-assistant/page.tsx
rename to src/app/user/apply/executive-associate/page.tsx
index b37ca1a..50be177 100644
--- a/src/app/user/apply/executive-assistant/page.tsx
+++ b/src/app/user/apply/executive-associate/page.tsx
@@ -8,13 +8,14 @@ import Header from "@/components/Header";
import LoadingScreen from "@/components/LoadingScreen";
import { useApplicationStatus } from "@/lib/useApplicationStatus";
import { useApplicationsOpen } from "@/lib/useApplicationsOpen";
-import { roles } from "@/data/ebRoles";
+import { useEbRoles } from "@/lib/useEbRoles";
export default function AssistantApplication() {
const [selectedRole, setSelectedRole] = useState("president");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { status } = useSession();
const router = useRouter();
+ const { roles, isLoading: isRolesLoading } = useEbRoles();
// SWR hook — shared with user dashboard, no duplicate fetch
const { data: appStatus, isLoading: isAppLoading } = useApplicationStatus(
@@ -34,14 +35,14 @@ export default function AssistantApplication() {
router.push(
`/user/apply/committee-staff/${appStatus.committeeId}/progress`,
);
- } else if (appStatus.hasEAApplication && appStatus.ebRole) {
+ } else if (appStatus.hasExecutiveAssociateApplication && appStatus.ebRole) {
router.push(
- `/user/apply/executive-assistant/${appStatus.ebRole}/progress`,
+ `/user/apply/executive-associate/${appStatus.ebRole}/progress`,
);
}
}, [appStatus, status, router]);
- if (status === "loading" || isAppLoading) {
+ if (status === "loading" || isAppLoading || isRolesLoading) {
return ;
}
@@ -50,7 +51,7 @@ export default function AssistantApplication() {
appStatus &&
(appStatus.hasMemberApplication ||
appStatus.hasCommitteeApplication ||
- appStatus.hasEAApplication)
+ appStatus.hasExecutiveAssociateApplication)
) {
return ;
}
@@ -59,7 +60,7 @@ export default function AssistantApplication() {
if (!applicationsOpen) return ;
return (
-
+
@@ -67,11 +68,11 @@ export default function AssistantApplication() {
Apply as
- Executive Assistant
+ Executive Associate
- Executive Assistants work closely with the CSS Executive Boards to
+ Executive Associates work closely with the CSS Executive Boards to
help them with their tasks in events and committees. This role
requires responsibility, attention to detail, and strong
communication skills.
@@ -191,7 +192,7 @@ export default function AssistantApplication() {
-
Be an Executive Assistant of
+
Be an Executive Associate of
{role.title}
@@ -230,7 +231,7 @@ export default function AssistantApplication() {
- Select an EB role to be their Executive Assistant
+ Select an EB role to be their Executive Associate
@@ -254,7 +255,7 @@ export default function AssistantApplication() {
router.push(
- `/user/apply/executive-assistant/${selectedRole}/application`,
+ `/user/apply/executive-associate/${selectedRole}/application`,
)
}
className="cursor-pointer bg-[#044FAF] text-white px-15 py-3 rounded-lg font-inter font-normal text-sm hover:bg-[#04387B] transition-colors"
diff --git a/src/app/user/apply/member/page.tsx b/src/app/user/apply/member/page.tsx
index e0e2698..e065222 100644
--- a/src/app/user/apply/member/page.tsx
+++ b/src/app/user/apply/member/page.tsx
@@ -8,6 +8,7 @@ import { useSession } from "next-auth/react";
import Image from "next/image";
import Header from "@/components/Header";
import LoadingScreen from "@/components/LoadingScreen";
+import LoadingSpinner from "@/components/LoadingSpinner";
import { parseFullName } from "@/lib/name-parsing";
import { useFormPersistence } from "@/lib/useFormPersistence";
import { useApplicationStatus } from "@/lib/useApplicationStatus";
@@ -33,6 +34,9 @@ export default function MemberApplication() {
const initialFormData = {
studentNumber: "",
section: "",
+ age: "",
+ dateOfBirth: "",
+ isOldCssMember: false,
firstName: "",
lastName: "",
};
@@ -50,9 +54,9 @@ export default function MemberApplication() {
router.push(
`/user/apply/committee-staff/${appStatus.committeeId}/progress`,
);
- else if (appStatus.hasEAApplication && appStatus.ebRole)
+ else if (appStatus.hasExecutiveAssociateApplication && appStatus.ebRole)
router.push(
- `/user/apply/executive-assistant/${appStatus.ebRole}/progress`,
+ `/user/apply/executive-associate/${appStatus.ebRole}/progress`,
);
}, [appStatus, status, router]);
@@ -85,6 +89,18 @@ export default function MemberApplication() {
if (!formData.section && data.user?.section) {
updates.section = data.user.section;
}
+
+ if (!formData.age && data.user?.age) {
+ updates.age = String(data.user.age);
+ }
+
+ if (!formData.dateOfBirth && data.user?.dateOfBirth) {
+ updates.dateOfBirth = data.user.dateOfBirth.slice(0, 10);
+ }
+
+ if (data.user?.isOldCssMember !== null && data.user?.isOldCssMember !== undefined) {
+ updates.isOldCssMember = data.user.isOldCssMember;
+ }
// Only update if there are changes to make
if (Object.keys(updates).length > 0) {
@@ -99,7 +115,17 @@ export default function MemberApplication() {
};
fetchApplicationData();
- }, [session, status, isLoaded, updateFormData, hasFetchedData, formData.studentNumber, formData.section]);
+ }, [
+ session,
+ status,
+ isLoaded,
+ updateFormData,
+ hasFetchedData,
+ formData.studentNumber,
+ formData.section,
+ formData.age,
+ formData.dateOfBirth,
+ ]);
// Early returns AFTER all hooks
if (status === "loading" || isAppLoading) return ;
@@ -107,7 +133,7 @@ export default function MemberApplication() {
appStatus &&
(appStatus.hasMemberApplication ||
appStatus.hasCommitteeApplication ||
- appStatus.hasEAApplication)
+ appStatus.hasExecutiveAssociateApplication)
)
return ;
if (!applicationsOpen) return ;
@@ -117,6 +143,9 @@ export default function MemberApplication() {
if (name === "studentNumber") {
const numericValue = value.replace(/[^0-9]/g, "").slice(0, 10);
updateFormData({ [name]: numericValue });
+ } else if (name === "age") {
+ const numericValue = value.replace(/[^0-9]/g, "").slice(0, 3);
+ updateFormData({ [name]: numericValue });
} else {
updateFormData({ [name]: value });
}
@@ -130,6 +159,9 @@ export default function MemberApplication() {
const parsed = memberApplicationSchema.safeParse({
studentNumber: formData.studentNumber,
section: formData.section,
+ age: formData.age,
+ dateOfBirth: formData.dateOfBirth,
+ isOldCssMember: formData.isOldCssMember,
});
if (!parsed.success) {
@@ -153,6 +185,9 @@ export default function MemberApplication() {
body: JSON.stringify({
studentNumber: formData.studentNumber,
section: formData.section,
+ age: Number(formData.age),
+ dateOfBirth: formData.dateOfBirth,
+ isOldCssMember: formData.isOldCssMember,
}),
});
@@ -172,7 +207,7 @@ export default function MemberApplication() {
};
return (
-
+
@@ -255,35 +290,85 @@ export default function MemberApplication() {
+
+
+
+
-
+
setIsChecked(e.target.checked)}
required
- className="w-4 h-4 lg:w-6 lg:h-6 appearance-none rounded-full border-2 border-gray-400 transition-all duration-200 focus:outline-none
+ className="absolute inset-0 block h-full w-full appearance-none rounded-full border-2 border-gray-400 transition-all duration-200 focus:outline-none
hover:border-[#134687]
checked:bg-blue-500
shadow-inner cursor-pointer"
/>
-
-
-
+ style={{
+ maskImage: "url(/icons/check.svg)",
+ WebkitMaskImage: "url(/icons/check.svg)",
+ maskSize: "contain",
+ maskRepeat: "no-repeat",
+ maskPosition: "center",
+ }}
+ />
@@ -302,7 +387,7 @@ export default function MemberApplication() {
- {loading ? "Submitting..." : "Submit"}
+ {loading ? (
+
+
+ Submitting...
+
+ ) : (
+ "Submit"
+ )}
diff --git a/src/app/user/apply/member/progress/content.tsx b/src/app/user/apply/member/progress/content.tsx
index d9f557b..c04a568 100644
--- a/src/app/user/apply/member/progress/content.tsx
+++ b/src/app/user/apply/member/progress/content.tsx
@@ -4,11 +4,17 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Header from "@/components/Header";
+import LoadingSpinner from "@/components/LoadingSpinner";
import Footer from "@/components/Footer";
import { useSession } from "next-auth/react";
-import { truncateToLast7 } from "@/lib/truncate-utils";
+import { usePaymentQr } from "@/lib/usePaymentQr";
+import { useCommunityLink } from "@/lib/useCommunityLink";
+import { usePaymentReceiptTemplate } from "@/lib/usePaymentReceiptTemplate";
export default function MemberProgressPageContent() {
+ const { communityEnabled, communityUrl, communityLabel } = useCommunityLink();
+ const { paymentQrUrl } = usePaymentQr();
+ const { receiptTemplateUrl } = usePaymentReceiptTemplate();
const router = useRouter();
const [applicationData, setApplicationData] = useState<{
hasApplication: boolean;
@@ -18,11 +24,64 @@ export default function MemberProgressPageContent() {
paymentProof?: string;
createdAt: string;
} | null;
- user: { id: string; studentNumber: string; name: string; section: string };
+ user: {
+ id: string;
+ studentNumber: string;
+ name: string;
+ section: string;
+ memberships?: Array<{ memberId: string }>;
+ };
} | null>(null);
const [loading, setLoading] = useState(true);
+ const [paymentProof, setPaymentProof] = useState("");
+ const [submittingPaymentProof, setSubmittingPaymentProof] = useState(false);
+ const [paymentProofError, setPaymentProofError] = useState("");
const { data: session } = useSession();
+ const hasPaymentProof = !!applicationData?.application?.paymentProof;
+
+ const handlePaymentProofSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setPaymentProofError("");
+ setSubmittingPaymentProof(true);
+
+ try {
+ const response = await fetch("/api/applications/payment-proof", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ paymentProof }),
+ });
+ const data = await response.json();
+ if (!response.ok)
+ throw new Error(data.error || "Failed to submit payment proof");
+
+ setApplicationData((current) =>
+ current && current.application
+ ? {
+ ...current,
+ application: {
+ ...current.application,
+ paymentProof: data.paymentProof,
+ },
+ user: {
+ ...current.user,
+ memberships: [{ memberId: data.memberId }],
+ },
+ }
+ : current,
+ );
+ setPaymentProof("");
+ } catch (error) {
+ setPaymentProofError(
+ error instanceof Error
+ ? error.message
+ : "Failed to submit payment proof",
+ );
+ } finally {
+ setSubmittingPaymentProof(false);
+ }
+ };
+
useEffect(() => {
const fetchApplicationData = async () => {
try {
@@ -84,7 +143,7 @@ export default function MemberProgressPageContent() {
: "";
return (
-
+
@@ -140,9 +199,13 @@ export default function MemberProgressPageContent() {
- {applicationData.application?.hasAccepted
- ? truncateToLast7(applicationData.user.id).toUpperCase()
- : "Pending"}
+ {applicationData.application?.hasAccepted &&
+ hasPaymentProof
+ ? (applicationData.user.memberships?.[0]?.memberId ??
+ applicationData.user.id.slice(-7).toUpperCase())
+ : applicationData.application?.hasAccepted
+ ? "Submit payment proof first"
+ : "Pending"}
@@ -155,41 +218,98 @@ export default function MemberProgressPageContent() {
Payment Instructions
-
-
- To complete your membership, please proceed with the payment
- of{" "}
-
- ₱250.00
- {" "}
- using the GCash QR code below:
-
-
-
-
-
+
+ {!hasPaymentProof && (
+ <>
+
+ To complete your membership, please proceed with the
+ payment of{" "}
+
+ ₱250.00
+ {" "}
+ using the GCash QR code below:
+
+
+ {paymentQrUrl ? (
+
+ ) : (
+
+ Payment QR code is currently unavailable. Please
+ contact css.cics@ust.edu.ph for payment instructions.
+
+ )}
+
+ >
+ )}
+
Important Payment Message
- When sending your payment via GCash QR, you MUST include
- this message:
+ After payment, fill out the acknowledgement receipt PDF and
+ upload it to Google Drive, then submit the shareable link
+ below.
-
-
- Member ID:{" "}
- {truncateToLast7(applicationData.user.id).toUpperCase()}
-
-
+ {receiptTemplateUrl && (
+
+ )}
+ {!hasPaymentProof ? (
+
+ ) : (
+
+ Payment proof submitted. Your Member ID is now available
+ above.
+
+ )}
- This message is required for payment verification and
- processing.
+ Your Member ID will be shown after submitting your
+ acknowledgement receipt link.
@@ -200,33 +320,35 @@ export default function MemberProgressPageContent() {
)}
- {applicationData.application?.hasAccepted && (
-
-
- Join Our Community
-
-
-
- Join our exclusive private FB group for members to stay
- connected and receive updates:
-
-
-
- Join UST CSS Members 25'-26' Group
-
+ {applicationData.application?.hasAccepted &&
+ communityEnabled &&
+ communityUrl && (
+
+
+ Join Our Community
+
+
+
+ Join our exclusive private FB group for members to stay
+ connected and receive updates:
+
+
+
+ Connect with fellow members and stay updated with exclusive
+ announcements!
+
-
- Connect with fellow members and stay updated with exclusive
- announcements!
-
-
- )}
+ )}
diff --git a/src/app/user/apply/member/success/content.tsx b/src/app/user/apply/member/success/content.tsx
index d75970f..1315cc2 100644
--- a/src/app/user/apply/member/success/content.tsx
+++ b/src/app/user/apply/member/success/content.tsx
@@ -9,14 +9,14 @@ export default function SuccessPageContent() {
const router = useRouter();
return (
-
+
fetch(url).then((r) => r.json());
+function DashboardSessionLoading() {
+ return (
+
+
+
+
+
+ Welcome,
+
+
+ 👋
+
+
+
+ Loading your dashboard...
+
+
+
+
+ );
+}
export default function UserDashboard() {
const { data: session, status } = useSession();
@@ -30,41 +55,21 @@ export default function UserDashboard() {
hasAnyApplication,
} = useApplicationStatus(status === "authenticated");
- // Check if applications are open (active cycle with interview period not ended)
- const { data: cycleData } = useSWR<{
- activeCycle: { interviewEnd: string } | null;
- }>(
- status === "authenticated" ? "/api/admin/recruitment-cycle" : null,
- swrFetcher,
- { revalidateOnFocus: false },
- );
-
- const activeCycle = cycleData?.activeCycle ?? null;
- const interviewEnded = activeCycle
- ? new Date(activeCycle.interviewEnd) < new Date()
- : true;
- const applicationsOpen = !!activeCycle && !interviewEnded;
+ const {
+ isOpen: applicationsOpen,
+ isLoading: isApplicationsLoading,
+ } = useApplicationsOpenState();
- // Redirect to /user if applications are closed and user tries to access them
useEffect(() => {
- if (cycleData && !applicationsOpen && !hasAnyApplication) {
- // Already on /user, just show the closed message
+ if (status === "unauthenticated") {
+ router.replace("/");
}
- }, [cycleData, applicationsOpen, hasAnyApplication]);
+ }, [status, router]);
// Redirect authenticated users with existing applications to their progress page
useEffect(() => {
if (status !== "authenticated" || !session || !appStatus) return;
- if (
- session.user.email.match(/\.cics@ust\.edu\.ph$/) &&
- session.user.role !== "admin" &&
- session.user.role !== "super_admin"
- ) {
- router.push("/");
- return;
- }
-
if (!hasAnyApplication) return;
if (appStatus.hasMemberApplication) {
@@ -73,21 +78,24 @@ export default function UserDashboard() {
router.push(
`/user/apply/committee-staff/${appStatus.committeeId}/progress`,
);
- } else if (appStatus.hasEAApplication && appStatus.ebRole) {
+ } else if (appStatus.hasExecutiveAssociateApplication && appStatus.ebRole) {
router.push(
- `/user/apply/executive-assistant/${appStatus.ebRole}/progress`,
+ `/user/apply/executive-associate/${appStatus.ebRole}/progress`,
);
}
}, [status, session, appStatus, hasAnyApplication, router]);
// Show loading while session, app check, or redirect is pending
- if (status === "loading" || isAppLoading) {
- return ;
+ if (status === "loading") {
+ return ;
+ }
+
+ if (isAppLoading || isApplicationsLoading) {
+ return ;
}
if (status === "unauthenticated") {
- router.push("/");
- return null;
+ return ;
}
if (!session) return null;
@@ -106,19 +114,19 @@ export default function UserDashboard() {
return (
-
+
-
-
-
-
+
+
+
{!applicationsOpen ? (
-
+
Applications Closed
@@ -238,7 +246,7 @@ export default function UserDashboard() {
- {/* Slide 3 - Executive Assistant */}
+ {/* Slide 3 - Executive Associate */}
- Executive Assistant
+ Executive Associate
@@ -368,7 +376,7 @@ export default function UserDashboard() {
/>
- Executive Assistant
+ Executive Associate
@@ -397,7 +405,7 @@ export default function UserDashboard() {
)}
-
+
diff --git a/src/components/AdminMobileSB.tsx b/src/components/AdminMobileSB.tsx
index 617935c..7cf6a2d 100644
--- a/src/components/AdminMobileSB.tsx
+++ b/src/components/AdminMobileSB.tsx
@@ -15,8 +15,8 @@ const MobileSidebar = ({ children }: MobileSidebarProps) => {
{/* CSS logo */}
@@ -27,20 +27,16 @@ const MobileSidebar = ({ children }: MobileSidebarProps) => {
className="text-gray-700 transition-transform duration-300 hover:scale-110"
>
{/* hamburger menu icon */}
-
-
-
+
diff --git a/src/components/AdminSidebar.tsx b/src/components/AdminSidebar.tsx
index 57485b0..6adfbb5 100644
--- a/src/components/AdminSidebar.tsx
+++ b/src/components/AdminSidebar.tsx
@@ -2,6 +2,9 @@ import Image from "next/image";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { signOut } from "next-auth/react";
+import useSWR from "swr";
+
+const swrFetcher = (url: string) => fetch(url).then((r) => r.json());
interface SidebarContentProps {
activePage: string;
@@ -11,6 +14,22 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
const { data: session } = useSession();
const isSuperAdmin = session?.user?.role === "super_admin";
+ const { data: countsData } = useSWR(
+ session ? "/api/admin/applications/counts" : null,
+ swrFetcher,
+ {
+ revalidateOnFocus: true,
+ dedupingInterval: 10000,
+ },
+ );
+
+ const counts = countsData?.counts || {
+ member: 0,
+ ea: 0,
+ committee: 0,
+ total: 0,
+ };
+
const handleLogout = async () => {
try {
await signOut({ callbackUrl: "/", redirect: true });
@@ -21,11 +40,11 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
return (
<>
{/* CSS logo inside sidebar */}
-
+
@@ -41,20 +60,16 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
className="flex items-center px-4 py-3 text-gray-600 border border-gray-300 rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-md"
style={{ backgroundColor: "#fefefe" }}
>
-
-
-
-
-
-
-
-
+
Interview Schedule
@@ -64,20 +79,16 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
href="/admin"
className="group flex items-center px-4 py-3 text-gray-600 hover:bg-blue-50 rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-md"
>
-
-
-
-
-
-
-
-
+
Interview Schedule
@@ -90,40 +101,48 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
className="flex items-center px-4 py-3 text-gray-600 border border-gray-300 rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-md"
style={{ backgroundColor: "#fefefe" }}
>
-
+
All Applications
+ {counts.total > 0 && (
+
+ {counts.total > 9 ? "9+" : counts.total}
+
+ )}
) : (
-
+
All Applications
+ {counts.total > 0 && (
+
+ {counts.total > 9 ? "9+" : counts.total}
+
+ )}
)}
@@ -133,46 +152,48 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
className="flex items-center px-4 py-3 text-gray-600 border border-gray-300 rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-md"
style={{ backgroundColor: "#fefefe" }}
>
-
+
Members
+ {counts.member > 0 && (
+
+ {counts.member > 9 ? "9+" : counts.member}
+
+ )}
) : (
-
+
Members
+ {counts.member > 0 && (
+
+ {counts.member > 9 ? "9+" : counts.member}
+
+ )}
)}
@@ -182,93 +203,99 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
className="flex items-center px-4 py-3 text-gray-600 border border-gray-300 rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-md"
style={{ backgroundColor: "#fefefe" }}
>
-
+
Committee Staff
+ {counts.committee > 0 && (
+
+ {counts.committee > 9 ? "9+" : counts.committee}
+
+ )}
) : (
-
+
Committee Staff
+ {counts.committee > 0 && (
+
+ {counts.committee > 9 ? "9+" : counts.committee}
+
+ )}
)}
- {/* executive assistants */}
+ {/* executive associates */}
{activePage === "eas" ? (
-
+
- Executive Assistants
+ Executive Associates
+ {counts.ea > 0 && (
+
+ {counts.ea > 9 ? "9+" : counts.ea}
+
+ )}
) : (
-
+
- Executive Assistants
+ Executive Associates
+ {counts.ea > 0 && (
+
+ {counts.ea > 9 ? "9+" : counts.ea}
+
+ )}
)}
@@ -280,17 +307,16 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
className="flex items-center px-4 py-3 text-gray-600 border border-gray-300 rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-md"
style={{ backgroundColor: "#fefefe" }}
>
-
+
EB Management
@@ -300,17 +326,16 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
href="/admin/super-admin"
className="group flex items-center px-4 py-3 text-gray-600 hover:bg-blue-50 rounded-lg transition-all duration-300 transform hover:scale-[1.02] hover:shadow-md"
>
-
+
EB Management
@@ -322,24 +347,21 @@ const SidebarContent = ({ activePage }: SidebarContentProps) => {
{/* logout button */}
-
+
-
+
Log Out
diff --git a/src/components/ApplicationGuard.tsx b/src/components/ApplicationGuard.tsx
index 980a331..732aeb2 100644
--- a/src/components/ApplicationGuard.tsx
+++ b/src/components/ApplicationGuard.tsx
@@ -8,14 +8,14 @@ import { useApplicationStatus } from "@/lib/useApplicationStatus";
interface ApplicationGuardProps {
children: React.ReactNode;
- applicationType: "member" | "committee" | "ea";
+ applicationType: "member" | "committee" | "executive-associate";
redirectPath?: string;
}
const DEFAULT_REDIRECTS: Record = {
member: "/user/apply/member",
committee: "/user/apply/committee-staff",
- ea: "/user/apply/executive-assistant",
+ ea: "/user/apply/executive-associate",
};
/**
@@ -55,7 +55,7 @@ export default function ApplicationGuard({
? appStatus.hasMemberApplication
: applicationType === "committee"
? appStatus.hasCommitteeApplication
- : appStatus.hasEAApplication
+ : appStatus.hasExecutiveAssociateApplication
: false;
useEffect(() => {
diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx
index 9df9b72..318213b 100644
--- a/src/components/ErrorPage.tsx
+++ b/src/components/ErrorPage.tsx
@@ -50,7 +50,7 @@ export default function ErrorPage({
};
return (
-
+
@@ -58,11 +58,11 @@ export default function ErrorPage({
{/* Error Icon/Image */}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 1530004..95c5428 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -6,7 +6,7 @@ function Footer() {
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index aa40504..334c0b8 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -17,12 +17,12 @@ export default function Header() {
{
- const interval = setInterval(() => {
- setProgress((prev) => {
- if (prev >= 100) {
- clearInterval(interval);
- return 100;
- }
- return prev + 10;
- });
- }, 200);
-
- const textTimer = setTimeout(() => {
- setShowText(true);
- }, 500);
-
- // Cycle through committee images
- const imageInterval = setInterval(() => {
- setCurrentImageIndex((prev) => (prev + 1) % committeeImages.length);
- }, 800);
-
- return () => {
- clearInterval(interval);
- clearTimeout(textTimer);
- clearInterval(imageInterval);
- };
- }, [committeeImages.length]);
+interface LoadingScreenProps {
+ message?: string;
+}
+export default function LoadingScreen({
+ message = "Loading CSSApply",
+}: LoadingScreenProps) {
return (
- <>
-
-
- {/* Committee Image Animation */}
-
-
-
- {/* Subtle glow effect */}
-
-
-
-
- {/* Progress Bar */}
-
-
-
- Processing...
-
-
- {progress}%
-
-
-
-
- {/* Shimmer effect */}
-
-
-
-
-
- {/* Loading Text */}
-
-
- Compiling your journey...
-
-
- Initializing CSS Apply system
-
-
-
- {/* Loading Dots */}
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
+
+
+
+
+ {message}
+
+
+ Please wait a moment.
+
- >
+
);
}
diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx
new file mode 100644
index 0000000..2dca1e3
--- /dev/null
+++ b/src/components/LoadingSpinner.tsx
@@ -0,0 +1,28 @@
+interface LoadingSpinnerProps {
+ label?: string;
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+const sizeClasses = {
+ sm: "h-4 w-4 border-2",
+ md: "h-6 w-6 border-2",
+ lg: "h-10 w-10 border-[3px]",
+} as const;
+
+export default function LoadingSpinner({
+ label = "Loading",
+ size = "md",
+ className = "",
+}: LoadingSpinnerProps) {
+ return (
+
+ {label}
+
+ );
+}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index d5e7e71..0c8f379 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -1,7 +1,10 @@
// components/modals/ConfirmationModal.tsx
"use client";
+import LoadingSpinner from "@/components/LoadingSpinner";
+
import { useEffect } from "react";
+import Image from "next/image";
interface ConfirmationModalProps {
isOpen: boolean;
@@ -56,18 +59,14 @@ export default function ConfirmationModal({
{/* Header section with icon and title */}
-
@@ -80,7 +79,7 @@ export default function ConfirmationModal({
{/* Note section */}
-
+
Note:
@@ -113,7 +112,7 @@ export default function ConfirmationModal({
>
{isLoading ? (
<>
-
+
Submitting...
>
) : (
diff --git a/src/data/ebRoles.ts b/src/data/ebRoles.ts
index 53cbfe8..87db5c3 100644
--- a/src/data/ebRoles.ts
+++ b/src/data/ebRoles.ts
@@ -88,7 +88,7 @@ export const roles = [
title: "Chief of Staff",
ebName: "Carylle Keona Ilano",
description:
- "Leads the pool of staff and executive assistants, making sure manpower is allocated properly during events and projects. They coordinate with the EB to deliver logistical support and ensure that every mission is carried out smoothly.",
+ "Leads the pool of staff and executive associates, making sure manpower is allocated properly during events and projects. They coordinate with the EB to deliver logistical support and ensure that every mission is carried out smoothly.",
},
{
id: "director-digital-productions",
diff --git a/src/lib/application-rules.ts b/src/lib/application-rules.ts
new file mode 100644
index 0000000..5358a64
--- /dev/null
+++ b/src/lib/application-rules.ts
@@ -0,0 +1,469 @@
+import { Prisma } from "@prisma/client";
+import { committeeRoles } from "@/data/committeeRoles";
+import { roles } from "@/data/ebRoles";
+import { getPositionTitle, getRoleId } from "@/lib/eb-mapping";
+import { prisma } from "@/lib/prisma";
+
+const BUSINESS_TIME_ZONE = "Asia/Manila";
+const EA_AVAILABILITY_CONFIG_KEY = "available_executive_associate_roles";
+
+export type ApplicationType = "member" | "committee" | "executive-associate";
+
+type DbClient = Prisma.TransactionClient | typeof prisma;
+
+type ActiveCycle = {
+ id: string;
+ schoolYear: string;
+ applicationStart: Date;
+ interviewStart: Date;
+ interviewEnd: Date;
+};
+
+export class ApplicationRuleError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ public readonly code: string,
+ ) {
+ super(message);
+ this.name = "ApplicationRuleError";
+ }
+}
+
+export function getApplicationRuleResponse(error: unknown) {
+ if (!(error instanceof ApplicationRuleError)) return null;
+
+ return {
+ body: { error: error.message, code: error.code },
+ status: error.status,
+ };
+}
+
+function getBusinessDateKey(date = new Date()) {
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: BUSINESS_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).formatToParts(date);
+ const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
+ return `${values.year}-${values.month}-${values.day}`;
+}
+
+function getStoredDateKey(date: Date) {
+ return date.toISOString().slice(0, 10);
+}
+
+export async function getActiveCycle(db: DbClient = prisma): Promise
{
+ const cycle = await db.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: {
+ id: true,
+ schoolYear: true,
+ applicationStart: true,
+ interviewStart: true,
+ interviewEnd: true,
+ },
+ });
+
+ if (!cycle) {
+ throw new ApplicationRuleError(
+ "There is no active recruitment cycle",
+ 409,
+ "NO_ACTIVE_CYCLE",
+ );
+ }
+
+ return cycle;
+}
+
+export async function getOpenApplicationCycle(
+ db: DbClient = prisma,
+): Promise {
+ const cycle = await getActiveCycle(db);
+ const today = getBusinessDateKey();
+
+ if (
+ today < getStoredDateKey(cycle.applicationStart) ||
+ today > getStoredDateKey(cycle.interviewEnd)
+ ) {
+ throw new ApplicationRuleError(
+ "Applications are currently closed",
+ 409,
+ "APPLICATIONS_CLOSED",
+ );
+ }
+
+ return cycle;
+}
+
+export async function lockApplicantCycle(
+ tx: Prisma.TransactionClient,
+ applicantEmail: string,
+ recruitmentCycleId: string,
+) {
+ const lockKey = `application:${recruitmentCycleId}:${applicantEmail.toLowerCase()}`;
+ await tx.$queryRaw(Prisma.sql`
+ SELECT pg_advisory_xact_lock(hashtext(${lockKey}))
+ `);
+}
+
+export async function assertNoOtherApplication(
+ tx: Prisma.TransactionClient,
+ applicantEmail: string,
+ recruitmentCycleId: string,
+ requestedType: ApplicationType,
+) {
+ const applicationOwner = { email: applicantEmail };
+ const [memberCount, committeeCount, executiveAssociateCount] =
+ await Promise.all([
+ requestedType === "member"
+ ? 0
+ : tx.memberApplication.count({
+ where: { recruitmentCycleId, user: applicationOwner },
+ }),
+ requestedType === "committee"
+ ? 0
+ : tx.committeeApplication.count({
+ where: { recruitmentCycleId, user: applicationOwner },
+ }),
+ requestedType === "executive-associate"
+ ? 0
+ : tx.executiveAssociateApplication.count({
+ where: { recruitmentCycleId, user: applicationOwner },
+ }),
+ ]);
+
+ if (memberCount + committeeCount + executiveAssociateCount > 0) {
+ throw new ApplicationRuleError(
+ "You already have a different application for this recruitment cycle",
+ 409,
+ "APPLICATION_TYPE_CONFLICT",
+ );
+ }
+}
+
+export async function assertStudentNumberOwnership(
+ tx: Prisma.TransactionClient,
+ studentNumber: string,
+ email: string,
+) {
+ const owner = await tx.user.findUnique({
+ where: { studentNumber },
+ select: { email: true },
+ });
+
+ if (owner && owner.email !== email) {
+ throw new ApplicationRuleError(
+ "This student number is already registered by another user",
+ 409,
+ "STUDENT_NUMBER_IN_USE",
+ );
+ }
+}
+
+export function assertValidCommitteeChoices(first: string, second: string) {
+ const validIds = new Set(committeeRoles.map(({ id }) => id));
+
+ if (!validIds.has(first) || !validIds.has(second)) {
+ throw new ApplicationRuleError(
+ "Select valid committee choices",
+ 400,
+ "INVALID_COMMITTEE",
+ );
+ }
+
+ if (first === second) {
+ throw new ApplicationRuleError(
+ "Committee choices must be different",
+ 400,
+ "DUPLICATE_COMMITTEE_CHOICE",
+ );
+ }
+}
+
+export async function assertAvailableExecutiveAssociateChoices(
+ db: DbClient,
+ ebRole: string,
+ firstOptionEb: string,
+ secondOptionEb: string,
+) {
+ const validIds = new Set(roles.map(({ id }) => id));
+
+ if (
+ ebRole !== firstOptionEb ||
+ !validIds.has(firstOptionEb) ||
+ !validIds.has(secondOptionEb)
+ ) {
+ throw new ApplicationRuleError(
+ "Select valid Executive Associate role choices",
+ 400,
+ "INVALID_EB_ROLE",
+ );
+ }
+
+ if (firstOptionEb === secondOptionEb) {
+ throw new ApplicationRuleError(
+ "Executive Board choices must be different",
+ 400,
+ "DUPLICATE_EB_ROLE_CHOICE",
+ );
+ }
+
+ const config = await db.systemConfig.findUnique({
+ where: { key: EA_AVAILABILITY_CONFIG_KEY },
+ select: { value: true },
+ });
+
+ let availability: Record = {};
+ if (config) {
+ try {
+ availability = JSON.parse(config.value) as Record;
+ } catch {
+ throw new ApplicationRuleError(
+ "Executive Associate role configuration is invalid",
+ 503,
+ "INVALID_ROLE_CONFIGURATION",
+ );
+ }
+ }
+
+ if (
+ availability[firstOptionEb] === false ||
+ availability[secondOptionEb] === false
+ ) {
+ throw new ApplicationRuleError(
+ "One of the selected Executive Board roles is not accepting applications",
+ 409,
+ "EB_ROLE_UNAVAILABLE",
+ );
+ }
+}
+
+export function isGoogleDriveUrl(value: string) {
+ try {
+ const url = new URL(value.trim());
+ return url.protocol === "https:" && url.hostname === "drive.google.com";
+ } catch {
+ return false;
+ }
+}
+
+function parseTime(value: string) {
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
+ if (!match) return null;
+
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ if (hours > 23 || minutes > 59) return null;
+ return hours * 60 + minutes;
+}
+
+function assertValidInterviewTime(
+ cycle: ActiveCycle,
+ day: string,
+ start: string,
+ end: string,
+) {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
+ throw new ApplicationRuleError(
+ "Select a valid interview date",
+ 400,
+ "INVALID_INTERVIEW_DATE",
+ );
+ }
+
+ const selectedDate = new Date(`${day}T00:00:00+08:00`);
+ if (Number.isNaN(selectedDate.getTime()) || getBusinessDateKey(selectedDate) !== day) {
+ throw new ApplicationRuleError(
+ "Select a valid interview date",
+ 400,
+ "INVALID_INTERVIEW_DATE",
+ );
+ }
+
+ if (
+ day < getStoredDateKey(cycle.interviewStart) ||
+ day > getStoredDateKey(cycle.interviewEnd)
+ ) {
+ throw new ApplicationRuleError(
+ "The selected date is outside the interview period",
+ 400,
+ "INTERVIEW_DATE_OUT_OF_RANGE",
+ );
+ }
+
+ const startMinutes = parseTime(start);
+ const endMinutes = parseTime(end);
+ if (
+ startMinutes === null ||
+ endMinutes === null ||
+ startMinutes < 7 * 60 ||
+ endMinutes > 21 * 60 ||
+ startMinutes % 30 !== 0 ||
+ endMinutes - startMinutes !== 30
+ ) {
+ throw new ApplicationRuleError(
+ "Select a valid 30-minute interview slot",
+ 400,
+ "INVALID_INTERVIEW_TIME",
+ );
+ }
+
+ const selectedStart = new Date(`${day}T${start}:00+08:00`);
+ if (selectedStart.getTime() <= Date.now()) {
+ throw new ApplicationRuleError(
+ "Past interview slots cannot be selected",
+ 400,
+ "INTERVIEW_SLOT_IN_PAST",
+ );
+ }
+
+ return { startMinutes, endMinutes };
+}
+
+interface InterviewSlotInput {
+ day: string;
+ start: string;
+ end: string;
+ interviewBy: string;
+ applicationType: Exclude;
+ applicationId: string;
+ expectedEbRole?: string;
+ committeeId?: string;
+}
+
+export async function validateAndLockInterviewSlot(
+ tx: Prisma.TransactionClient,
+ cycle: ActiveCycle,
+ input: InterviewSlotInput,
+) {
+ const { startMinutes, endMinutes } = assertValidInterviewTime(
+ cycle,
+ input.day,
+ input.start,
+ input.end,
+ );
+ const requestedRoleId = getRoleId(input.interviewBy);
+ const canonicalPosition = getPositionTitle(requestedRoleId);
+
+ const profile = await tx.eBProfile.findFirst({
+ where: {
+ recruitmentCycleId: cycle.id,
+ isActive: true,
+ position: { equals: canonicalPosition, mode: "insensitive" },
+ },
+ select: {
+ position: true,
+ committees: true,
+ meetingLink: true,
+ },
+ });
+
+ if (!profile) {
+ throw new ApplicationRuleError(
+ "The selected interviewer is unavailable for this recruitment cycle",
+ 409,
+ "INTERVIEWER_UNAVAILABLE",
+ );
+ }
+
+ if (input.committeeId && !profile.committees.includes(input.committeeId)) {
+ throw new ApplicationRuleError(
+ "The selected interviewer is not assigned to this committee",
+ 400,
+ "INTERVIEWER_COMMITTEE_MISMATCH",
+ );
+ }
+
+ if (
+ input.expectedEbRole &&
+ getRoleId(profile.position) !== getRoleId(input.expectedEbRole)
+ ) {
+ throw new ApplicationRuleError(
+ "The selected interviewer does not match the applied Executive Board role",
+ 400,
+ "INTERVIEWER_ROLE_MISMATCH",
+ );
+ }
+
+ const slotLockKey = `interview:${cycle.id}:${profile.position.toLowerCase()}:${input.day}:${input.start}:${input.end}`;
+ await tx.$queryRaw(Prisma.sql`
+ SELECT pg_advisory_xact_lock(hashtext(${slotLockKey}))
+ `);
+
+ const unavailableBlocks = await tx.availableEBInterviewTime.findMany({
+ where: {
+ eb: { equals: profile.position, mode: "insensitive" },
+ day: input.day,
+ maxSlots: 0,
+ },
+ select: { timeStart: true, timeEnd: true },
+ });
+ const isUnavailable = unavailableBlocks.some((block) => {
+ const unavailableStart = parseTime(block.timeStart);
+ const unavailableEnd = parseTime(block.timeEnd);
+ return (
+ unavailableStart !== null &&
+ unavailableEnd !== null &&
+ startMinutes < unavailableEnd &&
+ endMinutes > unavailableStart
+ );
+ });
+
+ if (isUnavailable) {
+ throw new ApplicationRuleError(
+ "The selected interviewer is unavailable during this time slot",
+ 409,
+ "INTERVIEW_SLOT_UNAVAILABLE",
+ );
+ }
+
+ const interviewerValues = Array.from(
+ new Set([profile.position, getRoleId(profile.position), input.interviewBy]),
+ );
+ const interviewerFilter = {
+ OR: interviewerValues.map((value) => ({
+ interviewBy: { equals: value, mode: Prisma.QueryMode.insensitive },
+ })),
+ };
+ const timeFilter = {
+ interviewSlotDay: input.day,
+ interviewSlotTimeStart: { lt: input.end },
+ interviewSlotTimeEnd: { gt: input.start },
+ };
+
+ const [committeeConflicts, executiveAssociateConflicts] = await Promise.all([
+ tx.committeeApplication.count({
+ where: {
+ recruitmentCycleId: cycle.id,
+ ...timeFilter,
+ ...interviewerFilter,
+ ...(input.applicationType === "committee"
+ ? { id: { not: input.applicationId } }
+ : {}),
+ },
+ }),
+ tx.executiveAssociateApplication.count({
+ where: {
+ recruitmentCycleId: cycle.id,
+ ...timeFilter,
+ ...interviewerFilter,
+ ...(input.applicationType === "executive-associate"
+ ? { id: { not: input.applicationId } }
+ : {}),
+ },
+ }),
+ ]);
+
+ if (committeeConflicts + executiveAssociateConflicts > 0) {
+ throw new ApplicationRuleError(
+ "This time slot is no longer available. Please select another time slot.",
+ 409,
+ "INTERVIEW_SLOT_CONFLICT",
+ );
+ }
+
+ return profile;
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 57bd6d3..ba2008b 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -2,6 +2,15 @@ import { type NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import { prisma } from "@/lib/prisma";
+const ALLOWED_SIGNIN_EMAIL_DOMAIN =
+ process.env.ALLOWED_SIGNIN_EMAIL_DOMAIN?.trim().toLowerCase() || "ust.edu.ph";
+
+function isAllowedSignInEmail(email?: string | null) {
+ if (!email) return false;
+
+ return email.toLowerCase().endsWith(`@${ALLOWED_SIGNIN_EMAIL_DOMAIN}`);
+}
+
interface UserSession {
id?: string;
name?: string | null;
@@ -29,7 +38,7 @@ interface UserSession {
};
};
hasMemberApplication?: boolean;
- hasEAApplication?: boolean;
+ hasExecutiveAssociateApplication?: boolean;
hasCommitteeApplication?: boolean;
ebRole?: string;
committeeId?: string;
@@ -57,12 +66,25 @@ export const authOptions: NextAuthOptions = {
callbacks: {
async signIn({ user }) {
try {
- // Check if user exists in database
+ if (!isAllowedSignInEmail(user.email)) {
+ console.warn("Rejected sign-in for disallowed email domain", {
+ email: user.email,
+ });
+ return false;
+ }
+
const existingUser = await prisma.user.findUnique({
where: { email: user.email! },
});
+ // If user exists, update image from Google and return
if (existingUser) {
+ if (user.image && existingUser.image !== user.image) {
+ await prisma.user.update({
+ where: { email: user.email! },
+ data: { image: user.image },
+ });
+ }
return true;
}
@@ -71,6 +93,7 @@ export const authOptions: NextAuthOptions = {
data: {
email: user.email!,
name: user.name || "",
+ image: user.image || null,
role: "user", // Default role
},
});
@@ -134,6 +157,13 @@ export const authOptions: NextAuthOptions = {
return session;
}
+ const activeCycle = await prisma.recruitmentCycle.findFirst({
+ where: { isActive: true },
+ orderBy: { createdAt: "desc" },
+ select: { id: true },
+ });
+ const activeCycleId = activeCycle?.id ?? "__no_active_cycle__";
+
const dbUser = await prisma.user.findUnique({
where: { email },
select: {
@@ -144,7 +174,9 @@ export const authOptions: NextAuthOptions = {
role: true,
createdAt: true,
updatedAt: true,
- memberApplication: {
+ memberApplications: {
+ where: { recruitmentCycleId: activeCycleId },
+ take: 1,
select: {
id: true,
hasAccepted: true,
@@ -152,7 +184,9 @@ export const authOptions: NextAuthOptions = {
createdAt: true,
},
},
- eaApplication: {
+ executiveAssociateApplications: {
+ where: { recruitmentCycleId: activeCycleId },
+ take: 1,
select: {
id: true,
hasAccepted: true,
@@ -160,7 +194,9 @@ export const authOptions: NextAuthOptions = {
firstOptionEb: true,
},
},
- committeeApplication: {
+ committeeApplications: {
+ where: { recruitmentCycleId: activeCycleId },
+ take: 1,
select: {
id: true,
hasAccepted: true,
@@ -194,15 +230,15 @@ export const authOptions: NextAuthOptions = {
// Add application status information
(session.user as UserSession).hasMemberApplication =
- !!dbUser.memberApplication;
- (session.user as UserSession).hasEAApplication = !!dbUser.eaApplication;
+ !!dbUser.memberApplications?.[0];
+ (session.user as UserSession).hasExecutiveAssociateApplication = !!dbUser.executiveAssociateApplications?.[0];
(session.user as UserSession).hasCommitteeApplication =
- !!dbUser.committeeApplication;
+ !!dbUser.committeeApplications?.[0];
// Add redirect information for faster navigation
- (session.user as UserSession).ebRole = dbUser.eaApplication?.firstOptionEb;
+ (session.user as UserSession).ebRole = dbUser.executiveAssociateApplications?.[0]?.firstOptionEb;
(session.user as UserSession).committeeId =
- dbUser.committeeApplication?.firstOptionCommittee;
+ dbUser.committeeApplications?.[0]?.firstOptionCommittee;
// Check if user has completed their profile
(session.user as UserSession).hasCompletedProfile =
@@ -210,28 +246,28 @@ export const authOptions: NextAuthOptions = {
// Check application status for routing
(session.user as UserSession).applicationStatus = {
- member: dbUser.memberApplication
+ member: dbUser.memberApplications?.[0]
? {
hasApplication: true,
- hasPayment: !!dbUser.memberApplication.paymentProof,
- isAccepted: dbUser.memberApplication.hasAccepted,
- appliedAt: dbUser.memberApplication.createdAt,
+ hasPayment: !!dbUser.memberApplications?.[0].paymentProof,
+ isAccepted: dbUser.memberApplications?.[0].hasAccepted,
+ appliedAt: dbUser.memberApplications?.[0].createdAt,
}
: { hasApplication: false },
- ea: dbUser.eaApplication
+ ea: dbUser.executiveAssociateApplications?.[0]
? {
hasApplication: true,
- status: dbUser.eaApplication.status ?? undefined,
- isAccepted: dbUser.eaApplication.hasAccepted,
+ status: dbUser.executiveAssociateApplications?.[0].status ?? undefined,
+ isAccepted: dbUser.executiveAssociateApplications?.[0].hasAccepted,
}
: { hasApplication: false },
- committee: dbUser.committeeApplication
+ committee: dbUser.committeeApplications?.[0]
? {
hasApplication: true,
- status: dbUser.committeeApplication.status ?? undefined,
- isAccepted: dbUser.committeeApplication.hasAccepted,
+ status: dbUser.committeeApplications?.[0].status ?? undefined,
+ isAccepted: dbUser.committeeApplications?.[0].hasAccepted,
}
: { hasApplication: false },
};
@@ -253,7 +289,7 @@ export const authOptions: NextAuthOptions = {
return url;
}
- return url;
+ return "/";
},
},
pages: {
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 6d0e59f..87eff6c 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -4,7 +4,6 @@ import {
ADMIN_EMAILS,
validateAllEmailMappings,
} from "@/data/emailMappings";
-import { truncateToLast7 } from "@/lib/truncate-utils";
const brevo = new BrevoClient({
apiKey: process.env.BREVO_API_KEY || "",
@@ -138,32 +137,170 @@ export const sendEmailWithValidation = async (
}
};
-// Email header with logo
-const emailHeader = `
-
-
-
-`;
-
-// Standard wrapper for emails
-const _wrapEmailContent = (title: string, content: string) => `
+// Reusable standard email layout wrapper with premium CSS theme
+const wrapEmail = (title: string, innerHtml: string): string => `
+
+
+
+
-
-
- ${emailHeader}
-
- ${title}
- ${content}
+
+
+
+
+ ${title ? `
${title} ` : ""}
+ ${innerHtml}
-
-
+
-
-
- Best regards,
- CSSApply Team
-
-
-
-
-`;
-
// Email templates for different application types
export const emailTemplates = {
memberApplication: (
@@ -200,42 +317,29 @@ export const emailTemplates = {
studentNumber: string,
): EmailTemplate => ({
subject: "CSSApply - Member Application Received",
- html: `
-
-
-
-
-
-
-
-
- ${emailHeader}
-
-
Hello, ${userName}!
-
-
+ html: wrapEmail(
+ `Hello, ${userName}!`,
+ `
+
Thank you for submitting your member application to CSSApply! We have successfully received your application.
-
-
Application Details
-
Student Number: ${studentNumber}
-
Application Type: Member
-
Status: Under Review
+
+
Application Details
+
Student Number: ${studentNumber}
+
Application Type: Member
+
Status: Under Review
-
+
Our team will review your application and get back to you soon. Please keep an eye on your email for updates.
-
+
If you have any questions, feel free to reach out to us.
-
-
-
-
- `,
+ `
+ ),
}),
committeeApplication: (
@@ -247,63 +351,50 @@ export const emailTemplates = {
interviewer?: string,
): EmailTemplate => ({
subject: "CSSApply - Committee Staff Application Received",
- html: `
-
-
-
-
-
-
-
-
- ${emailHeader}
-
-
Hello, ${userName}!
-
-
+ html: wrapEmail(
+ `Hello, ${userName}!`,
+ `
+
Thank you for submitting your committee staff application to CSSApply! We have successfully received your application.
-
-
Application Details
-
Student Number: ${studentNumber}
-
Application Type: Committee Staff
-
First Choice: ${getCommitteeFullName(firstOption)}
-
Second Choice: ${getCommitteeFullName(secondOption)}
-
Status: Under Review
+
+
Application Details
+
Student Number: ${studentNumber}
+
Application Type: Committee Staff
+
First Choice: ${getCommitteeFullName(firstOption)}
+
Second Choice: ${getCommitteeFullName(secondOption)}
+
Status: Under Review
${meetingLink
? `
-
-
Interview Information
-
Interviewer: ${interviewer ? capitalizeWords(interviewer) : `${getCommitteeFullName(firstOption)} Head`}
-
Meeting Link:
-
-
- Please schedule your interview time through the application dashboard, then use this link to join your interview.
-
-
- `
+
+
Interview Information
+
Interviewer: ${interviewer ? capitalizeWords(interviewer) : `${getCommitteeFullName(firstOption)} Head`}
+
Meeting Link:
+
+
+ Please schedule your interview time through the application dashboard, then use this link to join your interview.
+
+
+ `
: `
-
- Please proceed to schedule your interview through the application dashboard. The meeting link will be provided once you select your interview time.
-
- `
+
+ Please proceed to schedule your interview through the application dashboard. The meeting link will be provided once you select your interview time.
+
+ `
}
-
+
If you have any questions, feel free to reach out to us.
-
-
-
-
- `,
+ `
+ ),
}),
executiveAssistantApplication: (
@@ -315,691 +406,421 @@ export const emailTemplates = {
meetingLink?: string,
interviewer?: string,
): EmailTemplate => ({
- subject: "CSSApply - Executive Assistant Application Received",
- html: `
-
-
-
CSSApply
-
-
-
Hello, ${userName}!
-
-
- Thank you for submitting your executive assistant application to CSSApply! We have successfully received your application.
-
-
-
-
Application Details:
-
Student Number: ${studentNumber}
-
Application Type: Executive Assistant
-
EA Role: ${capitalizeWords(ebRole)}
-
First Choice: ${capitalizeWords(firstOption)}
-
Second Choice: ${capitalizeWords(secondOption)}
-
Status: Under Review
-
-
- ${meetingLink
+ subject: "CSSApply - Executive Associate Application Received",
+ html: wrapEmail(
+ `Hello, ${userName}!`,
+ `
+
+ Thank you for submitting your executive associate application to CSSApply! We have successfully received your application.
+
+
+
+
Application Details
+
Student Number: ${studentNumber}
+
Application Type: Executive Associate
+
Executive Associate Role: ${capitalizeWords(ebRole)}
+
First Choice: ${capitalizeWords(firstOption)}
+
Second Choice: ${capitalizeWords(secondOption)}
+
Status: Under Review
+
+
+ ${meetingLink
? `
-
-
📅 Interview Information:
-
Interviewer: ${interviewer ? capitalizeWords(interviewer) : `${capitalizeWords(firstOption)} Executive Board Member`}
-
Meeting Link:
-
- `,
+
+
+ If you have any questions, feel free to reach out to us.
+
+ `
+ ),
}),
// Acceptance notification templates
- memberAccepted: (userName: string, userId: string): EmailTemplate => ({
+ memberAccepted: (userName: string, _userId: string): EmailTemplate => ({
subject:
"CSSApply - Congratulations! Your Member Application Has Been Accepted",
- html: `
-
-
-
-
-
-
-
-
- ${emailHeader}
-
-
Congratulations ${userName}!
-
-
+ html: wrapEmail(
+ `Congratulations ${userName}!`,
+ `
+
We are thrilled to inform you that your member application has been ACCEPTED !
Welcome to the Computer Science Society!
-
-
Acceptance Details
-
Name: ${userName}
-
Member ID: ${truncateToLast7(userId).toUpperCase()}
-
Application Type: Member
-
Status: ACCEPTED
+
+
Acceptance Details
+
Name: ${userName}
+
Application Type: Member
+
Status: ACCEPTED
-
-
- Your Member ID (${truncateToLast7(userId).toUpperCase()} ) is now your official identifier within the organization.
- Please keep this information safe as you'll need it for future activities and events.
-
-
-
-
Payment Instructions
-
- To complete your membership, please proceed with the payment of ₱250.00 using the GCash QR code below:
+
+
+
Payment Instructions
+
+ To complete your membership, please open your application progress page, scan the latest payment QR shown there, download and fill out the acknowledgement receipt PDF, upload it to Google Drive, and submit the shareable link in the system.
-
-
-
-
-
IMPORTANT: When sending your payment, include this message:
-
Member ID: ${truncateToLast7(userId).toUpperCase()}
-
-
- Please keep a screenshot of your payment confirmation for your records.
+
+ Your Member ID will be sent through a separate email and shown in the system after your payment acknowledgement receipt link is submitted.
-
-
+
+
+
+
We look forward to seeing you at our upcoming events and activities. Welcome to the CSS family!
-
-
-
- Best regards,
- CSSApply Team
-
-
-
-
-
- `,
+ `
+ ),
}),
committeeAccepted: (
userName: string,
- userId: string,
+ _userId: string,
committee: string,
): EmailTemplate => ({
subject:
"CSSApply - Congratulations! Your Committee Staff Application Has Been Accepted",
- html: `
-
-
-
-
-
-
-
-
- ${emailHeader}
-
-
Congratulations ${userName}!
-
-
+ html: wrapEmail(
+ `Congratulations ${userName}!`,
+ `
+
We are thrilled to inform you that your committee staff application has been ACCEPTED !
Welcome to the Computer Science Society Committee Staff!
-
-
Acceptance Details
-
Name: ${userName}
-
Member ID: ${truncateToLast7(userId).toUpperCase()}
-
Application Type: Committee Staff
-
Committee: ${getCommitteeFullName(committee)}
-
Status: ACCEPTED
+
+
Acceptance Details
+
Name: ${userName}
+
Application Type: Committee Staff
+
Committee: ${getCommitteeFullName(committee)}
+
Status: ACCEPTED
-
-
- Your Member ID (${truncateToLast7(userId).toUpperCase()} ) is now your official identifier within the organization.
- Please keep this information safe as you'll need it for committee activities and events.
-
-
-
-
Payment Instructions
-
- To complete your membership, please proceed with the payment of ₱250.00 using the GCash QR code below:
+
+
+
Payment Instructions
+
+ To complete your membership, please open your application progress page, scan the latest payment QR shown there, download and fill out the acknowledgement receipt PDF, upload it to Google Drive, and submit the shareable link in the system.
-
-
-
-
-
IMPORTANT: When sending your payment, include this message:
-
Member ID: ${truncateToLast7(userId).toUpperCase()}
-
-
- Please keep a screenshot of your payment confirmation for your records.
+
+ Your Member ID will be sent through a separate email and shown in the system after your payment acknowledgement receipt link is submitted.
-
-
+
+
+
+
As a member of the ${getCommitteeFullName(committee)}, you'll be involved in exciting projects and initiatives.
We look forward to working with you!
-
-
-
- Best regards,
- CSSApply Team
-
-
-
-
-
- `,
+ `
+ ),
}),
executiveAssistantAccepted: (
userName: string,
- userId: string,
+ _userId: string,
ebRole: string,
): EmailTemplate => ({
subject:
- "CSSApply - Congratulations! Your Executive Assistant Application Has Been Accepted",
- html: `
-
-
-
-
-
-
-
-
- ${emailHeader}
-
-
Congratulations ${userName}!
-
-
- We are thrilled to inform you that your executive assistant application has been ACCEPTED !
- Welcome to the Computer Science Society Executive Assistant!
+ "CSSApply - Congratulations! Your Executive Associate Application Has Been Accepted",
+ html: wrapEmail(
+ `Congratulations ${userName}!`,
+ `
+
+ We are thrilled to inform you that your executive associate application has been ACCEPTED !
+ Welcome to the Computer Science Society Executive Associate!
-
-
Acceptance Details
-
Name: ${userName}
-
Member ID: ${truncateToLast7(userId).toUpperCase()}
-
Application Type: Executive Assistant
-
EA Role: ${capitalizeWords(ebRole)}
-
Status: ACCEPTED
+
+
Acceptance Details
+
Name: ${userName}
+
Application Type: Executive Associate
+
Executive Associate Role: ${capitalizeWords(ebRole)}
+
Status: ACCEPTED
-
-
- Your Member ID (${truncateToLast7(userId).toUpperCase()} ) is now your official identifier within the organization.
- Please keep this information safe as you'll need it for executive assistant activities and events.
-
-
-
-
Payment Instructions
-
- To complete your membership, please proceed with the payment of ₱250.00 using the GCash QR code below:
+
+
+
Payment Instructions
+
+ To complete your membership, please open your application progress page, scan the latest payment QR shown there, download and fill out the acknowledgement receipt PDF, upload it to Google Drive, and submit the shareable link in the system.
-
-
-
-
-
IMPORTANT: When sending your payment, include this message:
-
Member ID: ${truncateToLast7(userId).toUpperCase()}
-
-
- Please keep a screenshot of your payment confirmation for your records.
+
+ Your Member ID will be sent through a separate email and shown in the system after your payment acknowledgement receipt link is submitted.
-
-
- As an Executive Assistant for ${capitalizeWords(ebRole)}, you'll play a crucial role in supporting our leadership team.
- We look forward to working with you!
-
-
-
-
Join Our Community
-
- Join our exclusive private FB group for members to stay connected and receive updates:
-
-
+
+
-
-
-
- Best regards,
- CSSApply Team
+
+
+ As an Executive Associate for ${capitalizeWords(ebRole)}, you'll play a crucial role in supporting our leadership team.
+ We look forward to working with you!
-
-
-
-
- `,
+ `
+ ),
}),
// Rejection notification templates
committeeRejected: (userName: string, committee: string): EmailTemplate => ({
subject: "CSSApply - Committee Staff Application Update",
- html: `
-
-
-
-
-
-
-
-
- ${emailHeader}
-
-
Hello, ${userName},
-
-
+ html: wrapEmail(
+ `Hello, ${userName},`,
+ `
+
Thank you for your interest in joining the Computer Science Society Committee Staff.
After careful consideration, we regret to inform you that your application for the
${getCommitteeFullName(committee)} has not been successful this time.
-
-
Application Update
-
Name: ${userName}
-
Application Type: Committee Staff
-
Committee: ${getCommitteeFullName(committee)}
-
Status: NOT SELECTED
+