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 }>(); 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: `

${escapeHtml(voterName)} voted on your scheduling poll "${escapeHtml(pollTitle)}".

`, 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(); 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: `

The date for "${escapeHtml(poll.title)}" has been confirmed:

${dateStr}
${timeStr}

${poll.location ? `

Location: ${escapeHtml(poll.location)}

` : ''}`, 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, '&') .replace(//g, '>') .replace(/"/g, '"'); }