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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions docs/owner-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
5 changes: 3 additions & 2 deletions src/app/api/__test__/AdminPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(() => {
Expand Down
11 changes: 7 additions & 4 deletions src/app/api/__test__/JobsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand All @@ -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 () => {
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/jobs/[jobId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/jobs/recent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/jobs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,6 +73,10 @@ export const GET = withApiAuth(
filter.jobStatus = "approved";
}

if (!isAdminRequest) {
filter.approvedDate = { $gte: getThirtyDaysAgo() };
}

if (statusFilter === "approved") {
sort.approvedDate = -1;
} else {
Expand Down
18 changes: 10 additions & 8 deletions src/app/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="w-full relative">
Expand All @@ -170,7 +172,7 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da
<OrgCard
key={index}
job={job}
types={["posted", "updated"]}
types={["posted", "updated", "expires"]}
onJobStatusUpdate={handleJobUpdate}
/>
))
Expand All @@ -192,14 +194,14 @@ export default function DashboardPage({ organizationName, membershipStatus }: Da
</div>
</div>
<div className="flex flex-col mb-12">
<div className="text-2xl font-semibold mb-4">Resolved Jobs</div>
<div className="text-2xl font-semibold mb-4">Expired Jobs</div>
<div className="flex flex-col gap-4">
{resolvedJobs.length > 0 ? (
resolvedJobs.map((job, index) => (
<OrgCard key={index} job={job} types={["resolved"]} onJobStatusUpdate={handleJobUpdate} />
{expiredJobs.length > 0 ? (
expiredJobs.map((job, index) => (
<OrgCard key={index} job={job} types={["expired"]} onJobStatusUpdate={handleJobUpdate} />
))
) : (
<div className="py-4 px-5 rounded-md bg-[#f7f7f7] text-gray-500">No resolved jobs available</div>
<div className="py-4 px-5 rounded-md bg-[#f7f7f7] text-gray-500">No expired jobs available</div>
)}
</div>
</div>
Expand Down
133 changes: 77 additions & 56 deletions src/components/events/pages/AdminEventsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default function AdminEvents() {
const [isUpdatingEvent, setIsUpdatingEvent] = useState(false);
const [lastUpdateTime, setLastUpdateTime] = useState<number>(0);
const [tab, setTab] = useState(1);
const [sortNewestFirst, setSortNewestFirst] = useState(true);

const fetchData = async () => {
try {
Expand Down Expand Up @@ -124,9 +125,15 @@ export default function AdminEvents() {
setTimeout(() => setIsRefreshing(false), 1000);
};

const eventList = (events: IEvent[]) => (
const eventList = (events: IEvent[], sortByEventDate = false) => (
<div className="flex flex-col gap-4">
{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) => (
<AdminEventCard key={event._id} event={event} onUpdateEvent={updateEventStatus} />
))}
</div>
Expand Down Expand Up @@ -217,63 +224,77 @@ export default function AdminEvents() {
</div>
</div>

<Tooltip
label="Refresh event data"
hasArrow
placement="top"
bg="#2B2B2B"
color="white"
fontSize="sm"
borderRadius="md"
padding="2"
boxShadow="md"
offset={[0, 5]}
maxW="220px"
openDelay={600}
>
<div className="ml-auto flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={isRefreshing || isUpdatingEvent}
className={twMerge(
"p-2 hover:bg-gray-100 rounded-full transition-colors group",
(isRefreshing || isUpdatingEvent) && "cursor-not-allowed opacity-70",
)}
type="button"
onClick={() => setSortNewestFirst((current) => !current)}
aria-pressed={sortNewestFirst}
title={
sortNewestFirst ? "Newest first — click for oldest first" : "Oldest first — click for newest first"
}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
<span>{sortNewestFirst ? "↓" : "↑"}</span>
<span>{sortNewestFirst ? "Newest first" : "Oldest first"}</span>
</button>
<Tooltip
label="Refresh event data"
hasArrow
placement="top"
bg="#2B2B2B"
color="white"
fontSize="sm"
borderRadius="md"
padding="2"
boxShadow="md"
offset={[0, 5]}
maxW="220px"
openDelay={600}
>
<button
onClick={handleRefresh}
disabled={isRefreshing || isUpdatingEvent}
className={twMerge(
"text-gray-600 transition-transform duration-300 ease-in-out",
isRefreshing && "animate-spin-once",
"p-2 hover:bg-gray-100 rounded-full transition-colors group",
(isRefreshing || isUpdatingEvent) && "cursor-not-allowed opacity-70",
)}
>
<path
d="M23 4V10H17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M1 20V14H7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.51 9.00001C3.84797 7.58631 4.53047 6.28871 5.49997 5.20001C6.46947 4.11131 7.70047 3.26141 9.07097 2.71901C10.4415 2.17661 11.9075 1.95681 13.3745 2.07801C14.8415 2.19921 16.2645 2.65821 17.515 3.42001L23 8.00001M1 16L6.485 20.58C7.73547 21.3418 9.15847 21.8008 10.6255 21.922C12.0925 22.0432 13.5585 21.8234 14.929 21.281C16.2995 20.7386 17.5305 19.8887 18.5 18.8C19.4695 17.7113 20.152 16.4137 20.49 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</Tooltip>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={twMerge(
"text-gray-600 transition-transform duration-300 ease-in-out",
isRefreshing && "animate-spin-once",
)}
>
<path
d="M23 4V10H17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M1 20V14H7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.51 9.00001C3.84797 7.58631 4.53047 6.28871 5.49997 5.20001C6.46947 4.11131 7.70047 3.26141 9.07097 2.71901C10.4415 2.17661 11.9075 1.95681 13.3745 2.07801C14.8415 2.19921 16.2645 2.65821 17.515 3.42001L23 8.00001M1 16L6.485 20.58C7.73547 21.3418 9.15847 21.8008 10.6255 21.922C12.0925 22.0432 13.5585 21.8234 14.929 21.281C16.2995 20.7386 17.5305 19.8887 18.5 18.8C19.4695 17.7113 20.152 16.4137 20.49 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</Tooltip>
</div>
</div>

{tab === 1 ? (
Expand All @@ -282,14 +303,14 @@ export default function AdminEvents() {
) : approvedEventData.length === 0 ? (
<div className="py-4 px-5 rounded-md bg-[#f7f7f7] text-gray-500">No approved events</div>
) : (
eventList(approvedEventData)
eventList(approvedEventData, true)
)
) : !rejectedEventData ? (
<div className="py-4 px-5 rounded-md bg-[#f7f7f7] text-gray-500 animate-pulse">Loading...</div>
) : rejectedEventData.length === 0 ? (
<div className="py-4 px-5 rounded-md bg-[#f7f7f7] text-gray-500">No rejected events</div>
) : (
eventList(rejectedEventData)
eventList(rejectedEventData, true)
)}
</div>
</div>
Expand Down
Loading
Loading