changemaker.lite/api/src/modules/meeting-planner/meeting-planner.service.ts
2026-03-08 18:11:26 -06:00

630 lines
21 KiB
TypeScript

import crypto from 'crypto';
import { Prisma, PollVoteValue } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import { emailService } from '../../services/email.service';
import { generateSlug } from '../../utils/slug';
import { logger } from '../../utils/logger';
import type {
CreatePollInput,
UpdatePollInput,
AddOptionsInput,
UpdateOptionInput,
SubmitVotesInput,
SubmitCommentInput,
FinalizePollInput,
ConvertToShiftInput,
ListPollsInput,
} from './meeting-planner.schemas';
const pollInclude = {
options: { orderBy: { sortOrder: 'asc' as const } },
createdBy: { select: { id: true, name: true, email: true } },
_count: { select: { options: true, votes: true, comments: true } },
} as const;
// Admin detail include — returns all vote fields (for admin endpoints)
const pollDetailInclude = {
options: {
orderBy: { sortOrder: 'asc' as const },
include: {
votes: { orderBy: { createdAt: 'asc' as const } },
},
},
comments: { orderBy: { createdAt: 'asc' as const } },
createdBy: { select: { id: true, name: true, email: true } },
_count: { select: { options: true, votes: true, comments: true } },
} as const;
// Public detail include — strips voterEmail and voterToken from votes
const pollDetailPublicInclude = {
options: {
orderBy: { sortOrder: 'asc' as const },
include: {
votes: {
orderBy: { createdAt: 'asc' as const },
select: {
id: true,
pollId: true,
optionId: true,
voterName: true,
userId: true,
value: true,
createdAt: true,
// voterEmail and voterToken intentionally excluded
},
},
},
},
comments: { orderBy: { createdAt: 'asc' as const } },
createdBy: { select: { id: true, name: true } }, // exclude email from public
_count: { select: { options: true, votes: true, comments: true } },
} as const;
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
return options.map((opt) => {
let yesCount = 0;
let ifNeedBeCount = 0;
let noCount = 0;
for (const v of opt.votes) {
if (v.value === 'YES') yesCount++;
else if (v.value === 'IF_NEED_BE') ifNeedBeCount++;
else noCount++;
}
return {
...opt,
yesCount,
ifNeedBeCount,
noCount,
score: yesCount * 2 + ifNeedBeCount,
};
});
}
function groupVotesByVoter(votes: Array<{
voterName: string;
voterToken?: string | null;
userId: string | null;
optionId: string;
value: 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, voterKey: key, votes: {} });
}
voterMap.get(key)!.votes[vote.optionId] = vote.value;
}
return Array.from(voterMap.values());
}
export const meetingPlannerService = {
async findAll(filters: ListPollsInput) {
const { page, limit, search, status } = filters;
const where: Prisma.SchedulingPollWhereInput = {};
if (status) where.status = status;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [polls, total] = await Promise.all([
prisma.schedulingPoll.findMany({
where,
include: pollInclude,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.schedulingPoll.count({ where }),
]);
return {
polls,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: pollDetailInclude,
});
if (!poll) throw new AppError(404, 'Poll not found');
const optionsWithCounts = aggregateVotes(poll.options);
const allVotes = poll.options.flatMap((opt) =>
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
);
const voters = groupVotesByVoter(allVotes);
return { ...poll, options: optionsWithCounts, voters };
},
async findBySlug(slug: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { slug },
include: pollDetailInclude,
});
if (!poll) throw new AppError(404, 'Poll not found');
const optionsWithCounts = aggregateVotes(poll.options);
const allVotes = poll.options.flatMap((opt) =>
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
);
const voters = groupVotesByVoter(allVotes);
return { ...poll, options: optionsWithCounts, voters };
},
async findBySlugPublic(slug: string, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { slug },
include: pollDetailPublicInclude,
});
if (!poll) throw new AppError(404, 'Poll not found');
// If private and not authenticated, return limited data
if (poll.isPrivate && !userId) {
return {
id: poll.id,
slug: poll.slug,
title: poll.title,
description: poll.description,
location: poll.location,
status: poll.status,
timezone: poll.timezone,
allowAnonymous: poll.allowAnonymous,
isPrivate: poll.isPrivate,
notifyOnVote: poll.notifyOnVote,
createdBy: poll.createdBy,
createdByUserId: poll.createdByUserId,
createdAt: poll.createdAt,
updatedAt: poll.updatedAt,
votingDeadline: poll.votingDeadline,
finalizedOptionId: null,
finalizedOption: null,
convertedShiftId: null,
convertedGancioEventId: null,
requiresAuth: true,
options: [],
voters: [],
comments: [],
_count: { options: 0, votes: 0, comments: 0 },
};
}
const optionsWithCounts = aggregateVotes(poll.options);
const allVotes = poll.options.flatMap((opt) =>
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
);
const voters = groupVotesByVoter(allVotes);
return { ...poll, options: optionsWithCounts, voters, requiresAuth: false };
},
async findAllPublic(filters: ListPollsInput) {
const result = await this.findAll({ ...filters, status: 'OPEN' });
return {
...result,
// Filter out private polls entirely from the public listing
polls: result.polls
.filter((poll) => !poll.isPrivate)
.map((poll) => ({
...poll,
requiresAuth: false,
// Strip organizer email from public listing
createdBy: poll.createdBy ? { id: poll.createdBy.id, name: poll.createdBy.name } : null,
})),
};
},
async create(data: CreatePollInput, userId: string) {
const slug = generateSlug(data.title);
const poll = await prisma.schedulingPoll.create({
data: {
slug,
title: data.title,
description: data.description,
location: data.location,
timezone: data.timezone,
allowAnonymous: data.allowAnonymous,
isPrivate: data.isPrivate,
notifyOnVote: data.notifyOnVote,
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
createdByUserId: userId,
options: {
create: data.options.map((opt, i) => ({
date: new Date(opt.date),
startTime: opt.startTime,
endTime: opt.endTime,
sortOrder: i,
})),
},
},
include: pollInclude,
});
return poll;
},
async update(id: string, data: UpdatePollInput) {
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Poll not found');
const updateData: Prisma.SchedulingPollUncheckedUpdateInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.location !== undefined) updateData.location = data.location;
if (data.timezone !== undefined) updateData.timezone = data.timezone;
if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
if (data.isPrivate !== undefined) updateData.isPrivate = data.isPrivate;
if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
if (data.votingDeadline !== undefined) {
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
}
if (data.status !== undefined) updateData.status = data.status;
return prisma.schedulingPoll.update({
where: { id },
data: updateData,
include: pollInclude,
});
},
async delete(id: string) {
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Poll not found');
await prisma.schedulingPoll.delete({ where: { id } });
},
async addOptions(pollId: string, data: AddOptionsInput) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id: pollId },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'OPEN') throw new AppError(400, 'Cannot add options to a non-open poll');
const maxSort = poll.options.reduce((max, o) => Math.max(max, o.sortOrder), -1);
await prisma.schedulingPollOption.createMany({
data: data.options.map((opt, i) => ({
pollId,
date: new Date(opt.date),
startTime: opt.startTime,
endTime: opt.endTime,
sortOrder: maxSort + 1 + i,
})),
});
return this.findById(pollId);
},
async updateOption(pollId: string, optionId: string, data: UpdateOptionInput) {
const poll = await prisma.schedulingPoll.findUnique({ where: { id: pollId } });
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'OPEN') throw new AppError(400, 'Cannot update options on a non-open poll');
const option = await prisma.schedulingPollOption.findFirst({
where: { id: optionId, pollId },
});
if (!option) throw new AppError(404, 'Option not found');
const updateData: Prisma.SchedulingPollOptionUncheckedUpdateInput = {};
if (data.date !== undefined) updateData.date = new Date(data.date);
if (data.startTime !== undefined) updateData.startTime = data.startTime;
if (data.endTime !== undefined) updateData.endTime = data.endTime;
await prisma.schedulingPollOption.update({
where: { id: optionId },
data: updateData,
});
return this.findById(pollId);
},
async removeOption(pollId: string, optionId: string) {
const option = await prisma.schedulingPollOption.findFirst({
where: { id: optionId, pollId },
});
if (!option) throw new AppError(404, 'Option not found');
await prisma.schedulingPollOption.delete({ where: { id: optionId } });
return this.findById(pollId);
},
async submitVotes(slug: string, data: SubmitVotesInput, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { slug },
include: { options: true, createdBy: { select: { email: true, name: true } } },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'OPEN') throw new AppError(400, 'This poll is no longer accepting votes');
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
throw new AppError(400, 'The voting deadline has passed');
}
if (poll.isPrivate && !userId) {
throw new AppError(401, 'This poll requires authentication to vote');
}
if (!poll.allowAnonymous && !userId) {
throw new AppError(401, 'This poll requires authentication to vote');
}
// Validate all optionIds belong to this poll
const optionIds = new Set(poll.options.map((o) => o.id));
for (const vote of data.votes) {
if (!optionIds.has(vote.optionId)) {
throw new AppError(400, `Invalid option ID: ${vote.optionId}`);
}
}
// Generate token for anonymous voters (or reuse existing)
const voterToken = userId ? null : (data.voterToken || generateVoterToken());
// Upsert votes in a transaction
await prisma.$transaction(
data.votes.map((vote) => {
if (userId) {
return prisma.schedulingPollVote.upsert({
where: { optionId_userId: { optionId: vote.optionId, userId } },
create: {
pollId: poll.id,
optionId: vote.optionId,
userId,
voterName: data.voterName,
voterEmail: data.voterEmail,
value: vote.value,
},
update: {
voterName: data.voterName,
voterEmail: data.voterEmail,
value: vote.value,
},
});
} else {
return prisma.schedulingPollVote.upsert({
where: { optionId_voterToken: { optionId: vote.optionId, voterToken: voterToken! } },
create: {
pollId: poll.id,
optionId: vote.optionId,
voterName: data.voterName,
voterEmail: data.voterEmail,
voterToken,
value: vote.value,
},
update: {
voterName: data.voterName,
voterEmail: data.voterEmail,
value: vote.value,
},
});
}
})
);
// Notify organizer
if (poll.notifyOnVote) {
this.notifyOrganizer(poll.createdBy.email, poll.title, data.voterName).catch((err) =>
logger.error('Failed to send vote notification', { error: err })
);
}
return { voterToken };
},
async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.isPrivate && !userId) {
throw new AppError(401, 'This poll requires authentication to comment');
}
if (!poll.allowAnonymous && !userId) {
throw new AppError(401, 'This poll requires authentication to comment');
}
return prisma.schedulingPollComment.create({
data: {
pollId: poll.id,
userId,
authorName: data.authorName,
content: data.content,
},
});
},
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 },
});
if (!comment) throw new AppError(404, 'Comment not found');
await prisma.schedulingPollComment.delete({ where: { id: commentId } });
},
async finalize(id: string, data: FinalizePollInput) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status === 'FINALIZED') throw new AppError(400, 'Poll is already finalized');
const option = poll.options.find((o) => o.id === data.optionId);
if (!option) throw new AppError(400, 'Option not found in this poll');
const updated = await prisma.schedulingPoll.update({
where: { id },
data: {
status: 'FINALIZED',
finalizedOptionId: data.optionId,
},
include: pollDetailInclude,
});
// Notify all voters with emails
this.notifyVotersFinalized(updated).catch((err) =>
logger.error('Failed to send finalization notifications', { error: err })
);
return updated;
},
async convertToShift(id: string, data: ConvertToShiftInput) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
if (poll.convertedShiftId) throw new AppError(400, 'Poll has already been converted to a shift');
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
if (!option) throw new AppError(400, 'Finalized option not found');
const [shift] = await prisma.$transaction([
prisma.shift.create({
data: {
title: poll.title,
description: poll.description,
date: option.date,
startTime: option.startTime,
endTime: option.endTime,
location: poll.location,
maxVolunteers: data.maxVolunteers,
isPublic: data.isPublic,
cutId: data.cutId,
},
}),
]);
await prisma.schedulingPoll.update({
where: { id },
data: { convertedShiftId: shift.id },
});
return shift;
},
async convertToEvent(id: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
if (poll.convertedGancioEventId) throw new AppError(400, 'Poll has already been converted to an event');
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
if (!option) throw new AppError(400, 'Finalized option not found');
// Dynamically import gancio client to avoid hard dependency
const { gancioClient } = await import('../../services/gancio.client');
const eventId = await gancioClient.createEvent({
title: poll.title,
description: poll.description,
location: poll.location,
date: option.date,
startTime: option.startTime,
endTime: option.endTime,
});
if (!eventId) throw new AppError(500, 'Failed to create Gancio event');
await prisma.schedulingPoll.update({
where: { id },
data: { convertedGancioEventId: eventId },
});
return { gancioEventId: eventId };
},
async notifyOrganizer(email: string, pollTitle: string, voterName: string) {
try {
await emailService.sendEmail({
to: email,
subject: `New vote on "${pollTitle}"`,
html: `<p><strong>${escapeHtml(voterName)}</strong> voted on your scheduling poll "<strong>${escapeHtml(pollTitle)}</strong>".</p>`,
text: `${voterName} voted on your scheduling poll "${pollTitle}".`,
});
} catch (err) {
logger.error('Failed to send vote notification email', { error: err });
}
},
async notifyVotersFinalized(poll: any) {
const finalOption = poll.options.find((o: any) => o.id === poll.finalizedOptionId);
if (!finalOption) return;
const dateStr = new Date(finalOption.date).toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
const timeStr = `${finalOption.startTime} - ${finalOption.endTime}`;
// Collect unique voter emails
const voterEmails = new Set<string>();
for (const opt of poll.options) {
for (const vote of opt.votes) {
if (vote.voterEmail) voterEmails.add(vote.voterEmail);
}
}
for (const email of voterEmails) {
try {
await emailService.sendEmail({
to: email,
subject: `Date confirmed for "${poll.title}"`,
html: `<p>The date for "<strong>${escapeHtml(poll.title)}</strong>" has been confirmed:</p>
<p><strong>${dateStr}</strong><br/>${timeStr}</p>
${poll.location ? `<p>Location: ${escapeHtml(poll.location)}</p>` : ''}`,
text: `The date for "${poll.title}" has been confirmed:\n${dateStr}\n${timeStr}${poll.location ? `\nLocation: ${poll.location}` : ''}`,
});
} catch (err) {
logger.error('Failed to send finalization email', { error: err, email });
}
}
},
};
function generateVoterToken(): string {
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}