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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
/.pnp
.pnp.js

# debugging images
/images

# testing
/coverage

Expand Down
14 changes: 7 additions & 7 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 exclude old expired approved jobs by approved date.
- Approved non-admin results include all approved jobs, regardless of approval date.
- Sorting prioritizes `memberJob` first, then approved date or post date.

`POST /api/jobs`
Expand All @@ -205,8 +205,8 @@ Event region and city options live in `src/lib/eventOptions.ts`.
- Requires `nonprofit` or `spokes_admin`.
- Nonprofits can update only their own jobs.
- Nonprofit edits send the job back to `pending`.
- Supports renewal and unpublish actions.
- Admin status changes can approve, reject, or expire jobs.
- Supports renewal and resolve actions.
- Admin status changes can approve, reject, or resolve jobs.

`DELETE /api/jobs/[jobId]`

Expand Down Expand Up @@ -362,18 +362,18 @@ Shows the authenticated user's jobs. Organization users can:

- Edit jobs
- View rejection feedback
- Renew expired jobs
- Unpublish approved jobs
- Reopen resolved jobs
- Resolve approved jobs

### Job Admin Dashboard

File: `src/components/jobs/pages/JobsAdminPage.tsx`

Shows pending, approved, rejected, and expired jobs. Admins can:
Shows pending, approved, rejected, and resolved jobs. Admins can:

- Approve jobs
- Reject jobs with a reason
- Renew or expire jobs through status actions
- Reopen or resolve jobs through status actions
- Refresh dashboard data
- Navigate to user management

Expand Down
14 changes: 7 additions & 7 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.
- Expired: no longer active.
- Resolved: no longer active or visible on the public job board.

Approved jobs expire after their active period. The admin dashboard checks for expired jobs when it loads and periodically while open.
Jobs stay active until the posting organization resolves or deletes them.

### Edit, Renew, or Unpublish Jobs
### Edit, Reopen, or Resolve Jobs

Organizations can use `/jobs/manage` to manage their own jobs.

- Edit: updates the job and sends it back to pending review.
- Renew: restarts an expired job.
- Unpublish: marks an approved job as expired so it is no longer active.
- Reopen: sends a resolved job back for review.
- Resolve Job: marks an approved job as resolved so it is no longer active.
- View Feedback: shows the admin's rejection reason.

## Managing Events
Expand Down Expand Up @@ -193,7 +193,7 @@ Check its status:

- Pending items are waiting for approval.
- Rejected items are not public.
- Expired jobs are not active.
- Resolved jobs are not active.
- Approved items should appear publicly.

Also try refreshing the admin page with the refresh button.
Expand Down Expand Up @@ -236,7 +236,7 @@ Owners can usually handle:
- Updating user organizations
- Deleting users
- Deleting organizations
- Renewing or unpublishing jobs from the organization dashboard
- Reopening or resolving jobs from the organization dashboard

Owners should ask a technical maintainer for:

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"framer-motion": "^11.15.0",
"lucide-react": "^0.475.0",
"mongoose": "^8.14.1",
"next": ">=14.2.25",
"next": "16.2.6",
"next-themes": "^0.4.4",
"react": "^18",
"react-dom": "^18",
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/__test__/AdminPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,8 @@ describe("Admin Jobs Page", () => {
});
expect(screen.getByText("Org2")).toBeInTheDocument();

// Click on Expired Jobs tab
fireEvent.click(screen.getByText(/Expired Jobs/i));
// Click on Resolved Jobs tab
fireEvent.click(screen.getByText(/Resolved Jobs/i));

// Check for expired jobs
await waitFor(() => {
Expand Down
51 changes: 50 additions & 1 deletion src/app/api/__test__/EventsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Event from "@/database/eventSchema";
import User from "@/database/userSchema";
import { resolveOrganizationName } from "@/lib/organizations";
import { GET, POST } from "@/app/api/events/route";
import { GET as GET_EVENT, PUT } from "@/app/api/events/[eventId]/route";
import { DELETE, GET as GET_EVENT, PUT } from "@/app/api/events/[eventId]/route";

const mockAuth = {
userId: "user-1",
Expand All @@ -26,6 +26,7 @@ jest.mock("@/database/eventSchema", () => ({
find: jest.fn(),
findById: jest.fn(),
findByIdAndUpdate: jest.fn(),
findByIdAndDelete: jest.fn(),
findOneAndUpdate: jest.fn(),
},
}));
Expand Down Expand Up @@ -241,11 +242,59 @@ describe("Events API", () => {
locationLink: "https://maps.example.com/updated-event",
eventLocationGeneral: "North Coast",
eventLocationCity: "Morro Bay",
eventStatus: "pending",
rejectionMessage: "",
},
{ new: true, strict: false },
);
});

test("resubmits an owned approved event for review after editing", async () => {
(Event.findById as jest.Mock).mockResolvedValue({
_id: "event-1",
createdByUserId: "user-1",
eventStatus: "approved",
});
(Event.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "event-1", eventStatus: "pending" });

const response = await PUT(jsonRequest("/api/events/event-1", { eventName: "Updated Event" }), {});

expect(response.status).toBe(200);
expect(Event.findByIdAndUpdate).toHaveBeenCalledWith(
"event-1",
{ eventName: "Updated Event", eventStatus: "pending", rejectionMessage: "" },
{ new: true, strict: false },
);
});

test("allows an admin to reject an event with feedback", async () => {
mockAuth.role = "spokes_admin";
(Event.findById as jest.Mock).mockResolvedValue({ _id: "event-1", createdByUserId: "user-1" });
(Event.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "event-1", eventStatus: "rejected" });

const response = await PUT(
jsonRequest("/api/events/event-1", { eventStatus: "rejected", rejectionMessage: "Missing event details" }),
{},
);

expect(response.status).toBe(200);
expect(Event.findByIdAndUpdate).toHaveBeenCalledWith(
"event-1",
{ eventStatus: "rejected", rejectionMessage: "Missing event details" },
{ new: true },
);
});

test("allows an owner to delete an event", async () => {
(Event.findById as jest.Mock).mockResolvedValue({ _id: "event-1", createdByUserId: "user-1" });
(Event.findByIdAndDelete as jest.Mock).mockResolvedValue({ _id: "event-1" });

const response = await DELETE({ nextUrl: { pathname: "/api/events/event-1" } } as any, {});

expect(response.status).toBe(200);
expect(Event.findByIdAndDelete).toHaveBeenCalledWith("event-1");
});

test("GET blocks non-admin requests for private event statuses", async () => {
mockAuth.userId = null as any;
mockAuth.role = "job_seeker";
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/__test__/EventsValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,16 @@ describe("event validation service", () => {

expect(validateEventPayload({ majorFundraisingEvent: false }, { partial: true })).toBeNull();
});

test("allows a new event without a time or venue", () => {
const payload = { ...validPayload, time: "", location: "" };

expect(validateEventPayload(payload)).toBeNull();
expect(sanitizeEventPayload(payload)).toEqual(
expect.objectContaining({
time: "",
location: "",
}),
);
});
});
35 changes: 30 additions & 5 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 approved, unexpired jobs", async () => {
test("GET defaults public job listings to all approved jobs", async () => {
mockAuth.userId = null as any;
mockAuth.role = "job_seeker";
const limit = jest.fn().mockResolvedValue([]);
Expand All @@ -202,10 +202,7 @@ describe("Jobs API", () => {
);

expect(response.status).toBe(200);
expect(Job.find).toHaveBeenCalledWith({
jobStatus: "approved",
approvedDate: { $gte: expect.any(Date) },
});
expect(Job.find).toHaveBeenCalledWith({ jobStatus: "approved" });
});

test("GET job detail hides private jobs from anonymous users", async () => {
Expand All @@ -224,6 +221,34 @@ 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 () => {
mockAuth.userId = null as any;
mockAuth.role = "job_seeker";
(Job.findById as jest.Mock).mockResolvedValue({
_id: "job-1",
userId: "user-1",
jobStatus: "approved",
});

const response = await GET_JOB({ nextUrl: { pathname: "/api/jobs/job-1" } } as any, {});

expect(response.status).toBe(200);
});

test("allows an organization to resolve its job", async () => {
(Job.findById as jest.Mock).mockResolvedValue({ _id: "job-1", userId: "user-1", jobStatus: "approved" });
(Job.findByIdAndUpdate as jest.Mock).mockResolvedValue({ _id: "job-1", jobStatus: "expired" });

const response = await PUT(jsonRequest("/api/jobs/job-1", { isResolve: true }), {});

expect(response.status).toBe(200);
expect(Job.findByIdAndUpdate).toHaveBeenCalledWith(
"job-1",
{ jobStatus: "expired", modifiedDate: expect.any(Date) },
{ new: true },
);
});

test("PUT only writes whitelisted mutable job fields", async () => {
(Job.findById as jest.Mock).mockResolvedValue({
_id: "job-1",
Expand Down
13 changes: 7 additions & 6 deletions src/app/api/events/[eventId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,6 @@ export const PUT = withApiAuth(
return NextResponse.json({ message: "Event status updated successfully", event: updatedEvent });
}

// Handle regular updates (nonprofit can update their own pending events)
if (auth.role === "nonprofit" && existingEvent.eventStatus !== "pending") {
return NextResponse.json({ message: "Can only edit pending events" }, { status: 403 });
}

const validationError = validateEventPayload(eventData, { partial: true });
if (validationError) {
return NextResponse.json({ message: validationError }, { status: 400 });
Expand All @@ -95,7 +90,13 @@ export const PUT = withApiAuth(
return NextResponse.json({ message: "No valid event fields provided" }, { status: 400 });
}

const updatedEvent = await Event.findByIdAndUpdate(eventId, sanitizedEventData, { new: true, strict: false });
// Organization changes must be reviewed again. Admin edits remain live because
// they are already made by the reviewing role.
const updateData =
auth.role === "nonprofit"
? { ...sanitizedEventData, eventStatus: EventStatus.pending, rejectionMessage: "" }
: sanitizedEventData;
const updatedEvent = await Event.findByIdAndUpdate(eventId, updateData, { new: true, strict: false });
return NextResponse.json({ message: "Event updated successfully", event: updatedEvent });
} catch (error) {
return NextResponse.json({ message: "Error updating event", error }, { status: 500 });
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export const POST = withApiAuth(
organization,
date: new Date(sanitizedEventData.date as string),
eventName: sanitizedEventData.eventName as string,
time: sanitizedEventData.time as string,
location: sanitizedEventData.location as string,
time: (sanitizedEventData.time as string) || "",
location: (sanitizedEventData.location as string) || "",
};
const duplicateUpdateFields: Record<string, string | boolean> = {};
const insertEventData = { ...sanitizedEventData };
Expand Down
12 changes: 6 additions & 6 deletions src/app/api/jobs/[jobId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 @@ -75,7 +74,7 @@ export const PUT = withApiAuth(
const jobId = req.nextUrl.pathname.split("/").pop();

const requestData = await req.json();
const { previousStatus, newStatus, isRenewal, isUnpublish, ...jobData } = requestData;
const { previousStatus, newStatus, isRenewal, isResolve, isUnpublish, ...jobData } = requestData;

// Handle both cases - with and without status transition data
const hasStatusTransition = previousStatus !== undefined && newStatus !== undefined;
Expand Down Expand Up @@ -110,8 +109,9 @@ export const PUT = withApiAuth(
return NextResponse.json({ message: "Job renewed successfully", job: updatedJob });
}

// Handle job unpublishing
if (isUnpublish) {
// Resolve removes a listing from the public board while retaining it for
// the organization and admins. Keep isUnpublish for older clients.
if (isResolve || isUnpublish) {
const updatedJob = await Job.findByIdAndUpdate(
jobId,
{
Expand All @@ -120,7 +120,7 @@ export const PUT = withApiAuth(
},
{ new: true },
);
return NextResponse.json({ message: "Job unpublished successfully", job: updatedJob });
return NextResponse.json({ message: "Job resolved successfully", job: updatedJob });
}

const nextJobStatus = hasStatusTransition
Expand Down Expand Up @@ -171,7 +171,7 @@ export const GET = withApiAuth(
return NextResponse.json({ message: "Job not found" }, { status: 404 });
}

const isPubliclyVisible = job.jobStatus === JobStatus.approved && !isExpired(job.jobStatus, job.approvedDate);
const isPubliclyVisible = job.jobStatus === JobStatus.approved;
const canViewPrivateJob = auth.role === "spokes_admin" || (auth.userId && job.userId === auth.userId);

if (!isPubliclyVisible && !canViewPrivateJob) {
Expand Down
2 changes: 0 additions & 2 deletions src/app/api/jobs/recent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 @@ -22,7 +21,6 @@ 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
11 changes: 0 additions & 11 deletions src/app/api/jobs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ 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 @@ -69,18 +68,8 @@ export const GET = withApiAuth(
filter.jobStatus = {
$in: statusFilter,
};

// Filter out expired jobs that are already approved
if (statusFilter == "approved" && !isAdminRequest) {
filter.approvedDate = {
$gte: getThirtyDaysAgo(),
};
}
} else if (!isAdminRequest) {
filter.jobStatus = "approved";
filter.approvedDate = {
$gte: getThirtyDaysAgo(),
};
}

if (statusFilter === "approved") {
Expand Down
Loading
Loading