Add ability to remove voters from Meeting Planner polls

Add DELETE /meeting-planner/:id/voters/:voterKey endpoint and delete
button on each voter row in the voting matrix. Includes voterKey in
API response for voter identification.

Bunker Admin
This commit is contained in:
bunker-admin 2026-03-02 14:02:04 -07:00
parent e3045966a0
commit 3c4465525c
4 changed files with 66 additions and 5 deletions

View File

@ -233,6 +233,17 @@ export default function MeetingPlannerPage() {
}
};
const handleRemoveVoter = async (voterKey: string) => {
if (!selectedPoll) return;
try {
await api.delete(`/meeting-planner/${selectedPoll.id}/voters/${encodeURIComponent(voterKey)}`);
message.success('Voter removed');
fetchPollDetail(selectedPoll.id);
} catch {
message.error('Failed to remove voter');
}
};
const openEditDrawer = async (id: string) => {
try {
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${id}`);
@ -283,13 +294,23 @@ export default function MeetingPlannerPage() {
const handleUpdateOption = async (optionId: string, field: string, value: string) => {
if (!editPoll) return;
// Optimistic update: immediately reflect the change in local state
const optimisticDate = field === 'date' ? value + 'T00:00:00.000Z' : undefined;
setEditPoll((prev) => prev ? {
...prev,
options: prev.options.map((opt: any) =>
opt.id === optionId
? { ...opt, [field]: optimisticDate ?? value }
: opt
),
} : prev);
try {
await api.put(`/meeting-planner/${editPoll.id}/options/${optionId}`, { [field]: value });
// Refresh edit poll data
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${editPoll.id}`);
setEditPoll(data);
message.success('Option updated');
} catch {
// Revert on failure by refetching
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${editPoll.id}`);
setEditPoll(data);
message.error('Failed to update option');
}
};
@ -399,6 +420,7 @@ export default function MeetingPlannerPage() {
<th style={{ padding: '8px 12px', borderBottom: '2px solid #303030', textAlign: 'left', minWidth: 120 }}>
Voter
</th>
<th style={{ padding: '8px 4px', borderBottom: '2px solid #303030', width: 36 }} />
{poll.options.map((opt) => (
<th
key={opt.id}
@ -425,6 +447,14 @@ export default function MeetingPlannerPage() {
<td style={{ padding: '6px 12px', borderBottom: '1px solid #303030' }}>
{voter.name}
</td>
<td style={{ padding: '6px 4px', borderBottom: '1px solid #303030', textAlign: 'center' }}>
<Popconfirm
title={`Remove ${voter.name}'s votes?`}
onConfirm={() => handleRemoveVoter(voter.voterKey)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</td>
{poll.options.map((opt) => {
const value = voter.votes[opt.id] as PollVoteValue | undefined;
return (
@ -455,6 +485,7 @@ export default function MeetingPlannerPage() {
{/* Tally row */}
<tr style={{ fontWeight: 600 }}>
<td style={{ padding: '8px 12px', borderTop: '2px solid #303030' }}>Score</td>
<td style={{ borderTop: '2px solid #303030' }} />
{poll.options.map((opt) => (
<td
key={opt.id}

View File

@ -2849,6 +2849,7 @@ export interface PollDetailResponse extends SchedulingPoll {
comments: SchedulingPollComment[];
voters: Array<{
name: string;
voterKey: string;
votes: Record<string, PollVoteValue>;
}>;
}

View File

@ -124,6 +124,16 @@ adminRouter.post('/:id/convert-to-event', async (req: Request, res: Response, ne
} catch (err) { next(err); }
});
// Remove voter
adminRouter.delete('/:id/voters/:voterKey', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const voterKey = decodeURIComponent(req.params.voterKey as string);
const poll = await meetingPlannerService.removeVoter(id, voterKey);
res.json(poll);
} catch (err) { next(err); }
});
// Delete comment
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
try {

View File

@ -61,11 +61,11 @@ function groupVotesByVoter(votes: Array<{
optionId: string;
value: PollVoteValue;
}>) {
const voterMap = new Map<string, { name: string; votes: Record<string, PollVoteValue> }>();
const voterMap = new Map<string, { name: string; voterKey: string; votes: Record<string, PollVoteValue> }>();
for (const vote of votes) {
const key = vote.userId || vote.voterToken || vote.voterName;
if (!voterMap.has(key)) {
voterMap.set(key, { name: vote.voterName, votes: {} });
voterMap.set(key, { name: vote.voterName, voterKey: key, votes: {} });
}
voterMap.get(key)!.votes[vote.optionId] = vote.value;
}
@ -343,6 +343,25 @@ export const meetingPlannerService = {
});
},
async removeVoter(pollId: string, voterKey: string) {
const poll = await prisma.schedulingPoll.findUnique({ where: { id: pollId } });
if (!poll) throw new AppError(404, 'Poll not found');
const result = await prisma.schedulingPollVote.deleteMany({
where: {
pollId,
OR: [
{ userId: voterKey },
{ voterToken: voterKey },
{ voterName: voterKey, userId: null, voterToken: null },
],
},
});
if (result.count === 0) throw new AppError(404, 'Voter not found');
return this.findById(pollId);
},
async deleteComment(pollId: string, commentId: string) {
const comment = await prisma.schedulingPollComment.findFirst({
where: { id: commentId, pollId },