Skip to content
62 changes: 62 additions & 0 deletions backend/src/controllers/volunteerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,68 @@ export const createVolunteer: RequestHandler = async (req, res, next) => {
}
};

type UpdateVolunteerBody = {
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
tags?: string[];
status?: "returning" | "new";
volunteerTypeTags?: string[];
events?: string[];
additionalNotes?: string;
};

export const updateVolunteer: RequestHandler = async (req, res, next) => {
const errors = validationResult(req);
const volunteerId = req.params.id;
const {
firstName,
lastName,
email,
phoneNumber,
tags,
status,
volunteerTypeTags,
events,
additionalNotes,
} = req.body as UpdateVolunteerBody;

try {
validationErrorParser(errors);

const updatePayload: Record<string, unknown> = {
firstName,
lastName,
email,
phoneNumber,
volunteerTypeTags,
events,
additionalNotes,
};

if (Array.isArray(tags)) {
updatePayload.tags = tags;
}
if (status === "new" || status === "returning") {
updatePayload.status = status;
}

const volunteer = await VolunteerModel.findByIdAndUpdate(volunteerId, updatePayload, {
new: true,
runValidators: true,
});

if (!volunteer) {
return res.status(404).json({ error: "Could not find volunteer" });
}

res.status(200).json(volunteer);
} catch (err) {
next(err);
}
};

type UpdateVolunteerContactBody = {
email: string;
phoneNumber: string;
Expand Down
1 change: 1 addition & 0 deletions backend/src/routes/volunteerRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ router.get("/", volunteer.getVolunteers);
router.delete("/:id", volunteer.deleteVolunteer);

router.post("/", VolunteerValidator.createVolunteerValidator, volunteer.createVolunteer);
router.put("/:id", VolunteerValidator.updateVolunteerValidator, volunteer.updateVolunteer);
router.put(
"/contact/:id",
VolunteerValidator.updateVolunteerContactValidator,
Expand Down
32 changes: 29 additions & 3 deletions backend/src/validators/volunteerValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,14 @@ const makePhoneValidator = (path = "phoneNumber") =>
body(path)
.exists()
.withMessage("phone is required")
.bail() // What kind of phone number do we want to enforce?
.isMobilePhone("any")
.withMessage("phoneNumber must be a valid mobile phone number")
.bail()
.custom((value: string) => {
const digitsOnly = value.replace(/\D/g, "");
if (digitsOnly.length !== 10) {
throw new Error("phoneNumber must be a valid phone number");
}
return true;
})
.customSanitizer((value: string) => value.replace(/\D/g, ""));
/*
* makeStatusValidator is not currently used, but we may want to add a route in the future
Expand All @@ -61,6 +66,10 @@ const makeStatusValidator = (path = "status") =>
*/

const tagsValidator = () => body("tags").optional().isArray();
const statusValidator = () => body("status").optional().isIn(["new", "returning"]);
const volunteerTypeTagsValidator = () => body("volunteerTypeTags").optional().isArray();
const eventsValidator = () => body("events").optional().isArray();
const additionalNotesValidator = () => body("additionalNotes").optional().isString();

const batchUploadVolunteersValidator = () =>
body("volunteers")
Expand All @@ -85,6 +94,23 @@ export const createVolunteerValidator = [
makeEmailValidator(),
makePhoneValidator(),
tagsValidator(),
statusValidator(),
volunteerTypeTagsValidator(),
eventsValidator(),
additionalNotesValidator(),
];

export const updateVolunteerValidator = [
makeParamIDValidator(),
makeFirstNameValidator(),
makeLastNameValidator(),
makeEmailValidator(),
makePhoneValidator(),
tagsValidator(),
statusValidator(),
volunteerTypeTagsValidator(),
eventsValidator(),
additionalNotesValidator(),
];

export const updateVolunteerContactValidator = [
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/app/api/volunteer/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";

import type { NextRequest } from "next/server";

const API_URL = process.env.NEXT_PUBLIC_API_URL;

export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
if (!API_URL) {
return NextResponse.json({ error: "NEXT_PUBLIC_API_URL is not configured" }, { status: 500 });
}

const token = request.cookies.get("firebaseAuthToken")?.value;

if (!token) {
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
}

const { id } = await context.params;

try {
const requestBody: unknown = await request.json();
const response = await fetch(`${API_URL}/api/volunteer/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
cache: "no-store",
});

const contentType = response.headers.get("content-type") ?? "";
const payload: unknown = contentType.includes("application/json")
? await response.json()
: { error: await response.text() };

return NextResponse.json(payload, { status: response.status });
} catch {
return NextResponse.json(
{ error: "Unable to reach backend volunteer service" },
{ status: 502 },
);
}
}
24 changes: 24 additions & 0 deletions frontend/src/app/volunteers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import PageBar from "@/components/PageBar";
import SearchBar from "@/components/SearchBar";
import Sidebar from "@/components/Sidebar";
import TitleBar from "@/components/TitleBar";
import VolunteerProfileModal from "@/components/VolunteerProfileModal";
import VolunteerTable from "@/components/VolunteerTable";

type PopulatedAssignment = {
Expand Down Expand Up @@ -41,6 +42,8 @@ export default function Page() {
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [showImportSuccess, setShowImportSuccess] = useState(false);
const [selectedVolunteer, setSelectedVolunteer] = useState<Volunteer | null>(null);
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [selectedCount, setSelectedCount] = useState(0);
const [sortOption, setSortOption] = useState<
"Newest" | "Oldest" | "First Name A-Z" | "First Name Z-A" | "Last Name A-Z" | "Last Name Z-A"
Expand Down Expand Up @@ -250,6 +253,10 @@ export default function Page() {
setShowImportSuccess(true);
};

const handleSheetClose = () => {
setIsSheetOpen(false);
};

return (
<Sidebar>
<div className={styles.page}>
Expand Down Expand Up @@ -297,6 +304,10 @@ export default function Page() {
volunteers={displayedVolunteers}
selectableVolunteers={filteredVolunteers}
onSelectedCountChange={setSelectedCount}
onVolunteerSelect={(v) => {
setSelectedVolunteer(v);
setIsSheetOpen(true);
}}
/>
<div className={styles.tableSummaryRow}>
<span className={styles.tableSummaryLeft}>
Expand All @@ -314,6 +325,19 @@ export default function Page() {
onPageChange={setCurrentPage}
/>
</main>
<VolunteerProfileModal
volunteer={selectedVolunteer}
isOpen={isSheetOpen}
onClose={handleSheetClose}
onVolunteerUpdated={(updatedVolunteer) => {
setSelectedVolunteer(updatedVolunteer);
setVolunteers((prev) =>
prev.map((volunteer) =>
volunteer._id === updatedVolunteer._id ? updatedVolunteer : volunteer,
),
);
}}
/>
</div>
</Sidebar>
);
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/assets/plus.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/src/assets/redx.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading