diff --git a/docs/documentation.md b/docs/documentation.md index cff9114..8ed81ef 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -184,7 +184,7 @@ Event region and city options live in `src/lib/eventOptions.ts`. - Supports filters for `employmentType`, `compensationType`, `organizationIndustry`, and `jobStatus`. - Non-admin requests are paginated with `page` and `limit`. - Admin requests use `admin=true` and return all matching records. -- Approved non-admin results include all approved jobs, regardless of approval date. +- Approved non-admin results include only jobs approved within the last 30 days. - Sorting prioritizes `memberJob` first, then approved date or post date. `POST /api/jobs` @@ -362,18 +362,18 @@ Shows the authenticated user's jobs. Organization users can: - Edit jobs - View rejection feedback -- Reopen resolved jobs +- Renew expired jobs - Resolve approved jobs ### Job Admin Dashboard File: `src/components/jobs/pages/JobsAdminPage.tsx` -Shows pending, approved, rejected, and resolved jobs. Admins can: +Shows pending, approved, rejected, and expired jobs. Admins can: - Approve jobs - Reject jobs with a reason -- Reopen or resolve jobs through status actions +- Renew expired jobs or resolve approved jobs through status actions - Refresh dashboard data - Navigate to user management diff --git a/docs/owner-user-guide.md b/docs/owner-user-guide.md index afedc47..5b65d91 100644 --- a/docs/owner-user-guide.md +++ b/docs/owner-user-guide.md @@ -62,17 +62,17 @@ Approved jobs appear on the public job board. Rejected jobs stay available to th - Pending: waiting for Spokes admin review. - Approved: visible on the public job board. - Rejected: not public; the organization can view feedback. -- Resolved: no longer active or visible on the public job board. +- Expired: no longer active or visible on the public job board. -Jobs stay active until the posting organization resolves or deletes them. +Approved jobs expire automatically after 30 days. The admin dashboard checks for expired jobs when it loads and once per hour while open. -### Edit, Reopen, or Resolve Jobs +### Edit, Renew, or Resolve Jobs Organizations can use `/jobs/manage` to manage their own jobs. - Edit: updates the job and sends it back to pending review. -- Reopen: sends a resolved job back for review. -- Resolve Job: marks an approved job as resolved so it is no longer active. +- Renew: sends an expired job back for review and starts a new 30-day period after approval. +- Resolve Job: marks an approved job as expired so it is no longer active. - View Feedback: shows the admin's rejection reason. ## Managing Events @@ -236,7 +236,7 @@ Owners can usually handle: - Updating user organizations - Deleting users - Deleting organizations -- Reopening or resolving jobs from the organization dashboard +- Renewing or resolving jobs from the organization dashboard Owners should ask a technical maintainer for: diff --git a/src/app/api/__test__/AdminPage.test.tsx b/src/app/api/__test__/AdminPage.test.tsx index 17303cf..ec9fdb9 100644 --- a/src/app/api/__test__/AdminPage.test.tsx +++ b/src/app/api/__test__/AdminPage.test.tsx @@ -89,6 +89,7 @@ describe("Admin Jobs Page", () => { detailURL: "http://example.com/live", userId: "user2", modifiedDate: new Date().toISOString(), + approvedDate: new Date().toISOString(), }, { _id: "3", @@ -190,8 +191,8 @@ describe("Admin Jobs Page", () => { }); expect(screen.getByText("Org2")).toBeInTheDocument(); - // Click on Resolved Jobs tab - fireEvent.click(screen.getByText(/Resolved Jobs/i)); + // Click on Expired Jobs tab + fireEvent.click(screen.getByText(/Expired Jobs/i)); // Check for expired jobs await waitFor(() => { diff --git a/src/app/api/__test__/JobsApi.test.ts b/src/app/api/__test__/JobsApi.test.ts index 0722f7e..2fcfd01 100644 --- a/src/app/api/__test__/JobsApi.test.ts +++ b/src/app/api/__test__/JobsApi.test.ts @@ -186,7 +186,7 @@ describe("Jobs API", () => { expect(Job.find).not.toHaveBeenCalled(); }); - test("GET defaults public job listings to all approved jobs", async () => { + test("GET defaults public job listings to approved jobs from the last 30 days", async () => { mockAuth.userId = null as any; mockAuth.role = "job_seeker"; const limit = jest.fn().mockResolvedValue([]); @@ -202,7 +202,10 @@ describe("Jobs API", () => { ); expect(response.status).toBe(200); - expect(Job.find).toHaveBeenCalledWith({ jobStatus: "approved" }); + expect(Job.find).toHaveBeenCalledWith({ + jobStatus: "approved", + approvedDate: { $gte: expect.any(Date) }, + }); }); test("GET job detail hides private jobs from anonymous users", async () => { @@ -221,7 +224,7 @@ describe("Jobs API", () => { expect(result.message).toBe("Job not found"); }); - test("GET job detail keeps an approved legacy job public without an approval date", async () => { + test("GET job detail hides an approved job without an approval date", async () => { mockAuth.userId = null as any; mockAuth.role = "job_seeker"; (Job.findById as jest.Mock).mockResolvedValue({ @@ -232,7 +235,7 @@ describe("Jobs API", () => { const response = await GET_JOB({ nextUrl: { pathname: "/api/jobs/job-1" } } as any, {}); - expect(response.status).toBe(200); + expect(response.status).toBe(404); }); test("allows an organization to resolve its job", async () => { diff --git a/src/app/api/jobs/[jobId]/route.ts b/src/app/api/jobs/[jobId]/route.ts index 6cf64d0..8603ee9 100644 --- a/src/app/api/jobs/[jobId]/route.ts +++ b/src/app/api/jobs/[jobId]/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import Job from "@/database/jobSchema"; import { withApiAuth } from "@/lib/auth"; import { JobStatus } from "@/database/jobSchema"; +import { isExpired } from "@/lib/utils"; const mutableJobFields = [ "organizationIndustry", @@ -171,7 +172,7 @@ export const GET = withApiAuth( return NextResponse.json({ message: "Job not found" }, { status: 404 }); } - const isPubliclyVisible = job.jobStatus === JobStatus.approved; + const isPubliclyVisible = job.jobStatus === JobStatus.approved && !isExpired(job.jobStatus, job.approvedDate); const canViewPrivateJob = auth.role === "spokes_admin" || (auth.userId && job.userId === auth.userId); if (!isPubliclyVisible && !canViewPrivateJob) { diff --git a/src/app/api/jobs/recent/route.ts b/src/app/api/jobs/recent/route.ts index 137afc3..eef11ee 100644 --- a/src/app/api/jobs/recent/route.ts +++ b/src/app/api/jobs/recent/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server"; import Job from "@/database/jobSchema"; import { ObjectId } from "mongodb"; import { withApiAuth } from "@/lib/auth"; +import { getThirtyDaysAgo } from "@/lib/utils"; export const POST = withApiAuth( async (req: NextRequest) => { @@ -21,6 +22,7 @@ export const POST = withApiAuth( const recentJobs = await Job.find({ _id: { $in: jobIdArray }, jobStatus: "approved", + approvedDate: { $gte: getThirtyDaysAgo() }, }); return NextResponse.json(recentJobs, { status: 200 }); } catch (error) { diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index 7a6cde1..877d6e5 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -4,6 +4,7 @@ import Job from "@/database/jobSchema"; import User from "@/database/userSchema"; import { withApiAuth } from "@/lib/auth"; import { resolveOrganizationName } from "@/lib/organizations"; +import { getThirtyDaysAgo } from "@/lib/utils"; // no filters: GET /api/jobs?page=1&limit=10 // filter by employment type: GET /api/jobs?employmentType=full-time&employment=part-time @@ -72,6 +73,10 @@ export const GET = withApiAuth( filter.jobStatus = "approved"; } + if (!isAdminRequest) { + filter.approvedDate = { $gte: getThirtyDaysAgo() }; + } + if (statusFilter === "approved") { sort.approvedDate = -1; } else { diff --git a/src/app/dashboard/DashboardPage.tsx b/src/app/dashboard/DashboardPage.tsx index 4b851cc..3ae759a 100644 --- a/src/app/dashboard/DashboardPage.tsx +++ b/src/app/dashboard/DashboardPage.tsx @@ -139,13 +139,15 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da ); } - const liveJobs = userJobs.filter((job) => job.jobStatus.toLowerCase() === "approved" && !isExpired(job.jobStatus)); + const liveJobs = userJobs.filter( + (job) => job.jobStatus.toLowerCase() === "approved" && !isExpired(job.jobStatus, job.approvedDate), + ); const pendingJobs = userJobs.filter( (job) => job.jobStatus.toLowerCase() === "pending" || job.jobStatus.toLowerCase() === "rejected", ); - const resolvedJobs = userJobs.filter((job) => isExpired(job.jobStatus)); + const expiredJobs = userJobs.filter((job) => isExpired(job.jobStatus, job.approvedDate)); return (
@@ -170,7 +172,7 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da )) @@ -192,14 +194,14 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da
-
Resolved Jobs
+
Expired Jobs
- {resolvedJobs.length > 0 ? ( - resolvedJobs.map((job, index) => ( - + {expiredJobs.length > 0 ? ( + expiredJobs.map((job, index) => ( + )) ) : ( -
No resolved jobs available
+
No expired jobs available
)}
diff --git a/src/components/events/pages/AdminEventsPage.tsx b/src/components/events/pages/AdminEventsPage.tsx index 195e76c..7b18aad 100644 --- a/src/components/events/pages/AdminEventsPage.tsx +++ b/src/components/events/pages/AdminEventsPage.tsx @@ -15,6 +15,7 @@ export default function AdminEvents() { const [isUpdatingEvent, setIsUpdatingEvent] = useState(false); const [lastUpdateTime, setLastUpdateTime] = useState(0); const [tab, setTab] = useState(1); + const [sortNewestFirst, setSortNewestFirst] = useState(true); const fetchData = async () => { try { @@ -124,9 +125,15 @@ export default function AdminEvents() { setTimeout(() => setIsRefreshing(false), 1000); }; - const eventList = (events: IEvent[]) => ( + const eventList = (events: IEvent[], sortByEventDate = false) => (
- {events.map((event) => ( + {(sortByEventDate + ? [...events].sort((a, b) => { + const dateDifference = new Date(a.date).getTime() - new Date(b.date).getTime(); + return sortNewestFirst ? -dateDifference : dateDifference; + }) + : events + ).map((event) => ( ))}
@@ -217,63 +224,77 @@ export default function AdminEvents() { - +
+ + - + + + + + + + +
{tab === 1 ? ( @@ -282,14 +303,14 @@ export default function AdminEvents() { ) : approvedEventData.length === 0 ? (
No approved events
) : ( - eventList(approvedEventData) + eventList(approvedEventData, true) ) ) : !rejectedEventData ? (
Loading...
) : rejectedEventData.length === 0 ? (
No rejected events
) : ( - eventList(rejectedEventData) + eventList(rejectedEventData, true) )} diff --git a/src/components/events/pages/EventsBoardPage.test.tsx b/src/components/events/pages/EventsBoardPage.test.tsx new file mode 100644 index 0000000..560e9b4 --- /dev/null +++ b/src/components/events/pages/EventsBoardPage.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import EventsPage from "./EventsBoardPage"; +import { getAllEvents } from "@/services/events"; + +jest.mock("@/services/events", () => ({ + getAllEvents: jest.fn(), +})); + +jest.mock("@/components/events/EventCard", () => ({ + __esModule: true, + default: ({ event }: { event: { eventName: string } }) =>
{event.eventName}
, +})); + +describe("EventsPage", () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + test("shows far-future events in All months without extending the month options", async () => { + const farFutureEvent = { + _id: "far-future-event", + eventName: "Far Future Gala", + date: new Date(Date.UTC(new Date().getUTCFullYear() + 5, 0, 1)).toISOString(), + eventLocationGeneral: "San Luis Obispo Area", + eventLocationCity: "San Luis Obispo", + }; + (getAllEvents as jest.Mock).mockResolvedValue([farFutureEvent]); + + render(); + + await screen.findByText("Far Future Gala"); + + const monthSelect = screen.getAllByRole("combobox")[0] as HTMLSelectElement; + const monthValues = Array.from(monthSelect.options).map((option) => option.value); + const now = new Date(); + const expectedMonths = Array.from({ length: 13 }, (_, offset) => { + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 1)); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; + }); + + expect(monthValues).toEqual(["", ...expectedMonths]); + + fireEvent.change(monthSelect, { target: { value: expectedMonths[0] } }); + + await waitFor(() => { + expect(screen.queryByText("Far Future Gala")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/jobs/JobCard/AdminCard.tsx b/src/components/jobs/JobCard/AdminCard.tsx index 07bb400..4302c07 100644 --- a/src/components/jobs/JobCard/AdminCard.tsx +++ b/src/components/jobs/JobCard/AdminCard.tsx @@ -26,8 +26,6 @@ export default function AdminCard({ job, onUpdateJob, innerRef }: JobCardProps) const [isNewIndicatorDismissed, setIsNewIndicatorDismissed] = useState(false); const [isLoading, setIsLoading] = useState<"approve" | "reject" | null>(null); const router = useRouter(); - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const toast = useToast(); // Check localStorage for dismissed state on component mount @@ -135,7 +133,7 @@ export default function AdminCard({ job, onUpdateJob, innerRef }: JobCardProps) } } - const isActuallyExpired = isExpired(job.jobStatus); + const isActuallyExpired = isExpired(job.jobStatus, job.approvedDate); function handleEditApplicationButton(e: React.ChangeEvent) { e.preventDefault(); diff --git a/src/components/jobs/JobCard/JobDateInfo.tsx b/src/components/jobs/JobCard/JobDateInfo.tsx index 31c3524..53f1c4f 100644 --- a/src/components/jobs/JobCard/JobDateInfo.tsx +++ b/src/components/jobs/JobCard/JobDateInfo.tsx @@ -12,7 +12,7 @@ function formatDate(date: Date | undefined) { return new Date(date).toLocaleDateString(undefined, options); } -export type JobDateKind = "submitted" | "posted" | "updated" | "resolved"; +export type JobDateKind = "submitted" | "posted" | "updated" | "expires" | "expired"; export default function JobDateInfo({ date, @@ -35,8 +35,11 @@ export default function JobDateInfo({ case "updated": label = "Updated: "; break; - case "resolved": - label = "Resolved: "; + case "expires": + label = "Expires: "; + break; + case "expired": + label = "Expired: "; break; } diff --git a/src/components/jobs/JobCard/JobStatusBadge.tsx b/src/components/jobs/JobCard/JobStatusBadge.tsx index a9e4400..004076a 100644 --- a/src/components/jobs/JobCard/JobStatusBadge.tsx +++ b/src/components/jobs/JobCard/JobStatusBadge.tsx @@ -28,7 +28,7 @@ export default function JobStatusBadge({ jobStatus, className }: JobStatusBadgeP case "expired": badgeColor = "#FFE5E5"; // red textColor = "#D45959"; - tooltipText = "This job listing has been resolved and is no longer visible to potential applicants."; + tooltipText = "This job posting is no longer active. Renew to make it visible to potential applicants again."; break; case "rejected": badgeColor = "#FFE5E5"; // red @@ -42,7 +42,7 @@ export default function JobStatusBadge({ jobStatus, className }: JobStatusBadgeP break; } - const badgeName = jobStatus === "expired" ? "Resolved" : formatBadgeName(jobStatus.toString()); + const badgeName = formatBadgeName(jobStatus.toString()); return ( ( const { isOpen, onOpen, onClose } = useDisclosure(); const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); const [actionText, setActionText] = useState(""); - const isActuallyExpired = isExpired(job.jobStatus); + const isActuallyExpired = isExpired(job.jobStatus, job.approvedDate); const router = useRouter(); const toast = useToast(); @@ -83,8 +87,8 @@ export const OrgCard = forwardRef( // Show success toast toast({ - title: "Job Reopened", - description: `Successfully reopened "${job.title}"`, + title: "Job Renewed", + description: `Successfully renewed "${job.title}"`, status: "success", duration: 5000, isClosable: true, @@ -102,7 +106,7 @@ export const OrgCard = forwardRef( console.error("Error renewing job:", error); toast({ title: "Error", - description: "Failed to reopen job. Please try again.", + description: "Failed to renew job. Please try again.", status: "error", duration: 5000, isClosable: true, @@ -112,7 +116,7 @@ export const OrgCard = forwardRef( } function handleConfirmationModal(action: "resolve" | "renew") { - setActionText(action === "resolve" ? "Resolve" : "Reopen"); + setActionText(action === "resolve" ? "Resolve" : "Renew"); setIsConfirmationModalOpen(true); } @@ -260,7 +264,7 @@ export const OrgCard = forwardRef( {isActuallyExpired && ( )}