scheduling features

This commit is contained in:
2026-03-01 15:22:27 -07:00
parent aaba7df97d
commit e95bc8883e
40 changed files with 4267 additions and 220 deletions

View File

@@ -9,7 +9,7 @@ import { logger } from '../../utils/logger';
export interface UnifiedCalendarItem {
id: string;
type: 'shift' | 'event';
type: 'shift' | 'event' | 'poll';
title: string;
date: string; // YYYY-MM-DD
startTime: string; // HH:MM
@@ -23,6 +23,11 @@ export interface UnifiedCalendarItem {
// Event-specific
gancioEventId?: number;
gancioUrl?: string;
// Poll-specific
pollId?: string;
pollSlug?: string;
pollStatus?: string;
pollVoteCount?: number;
}
export interface UnifiedCalendarResponse {
@@ -50,10 +55,11 @@ export const unifiedCalendarService = {
// Set end to end of day
end.setHours(23, 59, 59, 999);
// Fetch shifts and Gancio events in parallel
const [shifts, gancioEvents] = await Promise.all([
// Fetch shifts, Gancio events, and polls in parallel
const [shifts, gancioEvents, pollItems] = await Promise.all([
this.fetchShifts(start, end),
this.fetchGancioEvents(start, end),
this.fetchPolls(start, end),
]);
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
@@ -99,7 +105,7 @@ export const unifiedCalendarService = {
});
// Merge and group by date
const allItems = [...shiftItems, ...eventItems];
const allItems = [...shiftItems, ...eventItems, ...pollItems];
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
@@ -163,6 +169,53 @@ export const unifiedCalendarService = {
});
},
async fetchPolls(start: Date, end: Date): Promise<UnifiedCalendarItem[]> {
try {
const polls = await prisma.schedulingPoll.findMany({
where: {
status: { in: ['OPEN', 'FINALIZED'] },
options: {
some: { date: { gte: start, lte: end } },
},
},
include: {
options: { orderBy: { sortOrder: 'asc' } },
_count: { select: { votes: true } },
},
});
const items: UnifiedCalendarItem[] = [];
for (const poll of polls) {
// For finalized polls, only show the selected option
const optionsToShow = poll.finalizedOptionId
? poll.options.filter(o => o.id === poll.finalizedOptionId)
: poll.options;
for (const opt of optionsToShow) {
const optDate = opt.date.toISOString().split('T')[0];
items.push({
id: `poll-${poll.id}-${opt.id}`,
type: 'poll',
title: poll.title,
date: optDate,
startTime: opt.startTime,
endTime: opt.endTime,
location: poll.location,
tags: ['scheduling', 'poll'],
pollId: poll.id,
pollSlug: poll.slug,
pollStatus: poll.status,
pollVoteCount: poll._count.votes,
});
}
}
return items;
} catch (err) {
logger.debug('Failed to fetch polls for calendar:', err);
return [];
}
},
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
try {
const events = await gancioClient.fetchPublicEvents();

View File

@@ -0,0 +1,37 @@
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
export const pollVoteRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 30,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:poll-vote:',
}),
message: {
error: {
message: 'Too many vote submissions, please try again later',
code: 'POLL_VOTE_RATE_LIMIT_EXCEEDED',
},
},
});
export const pollCommentRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:poll-comment:',
}),
message: {
error: {
message: 'Too many comments, please try again later',
code: 'POLL_COMMENT_RATE_LIMIT_EXCEEDED',
},
},
});

View File

@@ -0,0 +1,192 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { meetingPlannerService } from './meeting-planner.service';
import {
createPollSchema,
updatePollSchema,
addOptionsSchema,
submitVotesSchema,
submitCommentSchema,
finalizePollSchema,
convertToShiftSchema,
listPollsSchema,
} from './meeting-planner.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...ADMIN_ROLES));
// List polls
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await meetingPlannerService.findAll(req.query as any);
res.json(result);
} catch (err) { next(err); }
});
// Get poll detail
adminRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await meetingPlannerService.findById(id);
res.json(poll);
} catch (err) { next(err); }
});
// Create poll
adminRouter.post('/', validate(createPollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await meetingPlannerService.create(req.body, req.user!.id);
res.status(201).json(poll);
} catch (err) { next(err); }
});
// Update poll
adminRouter.put('/:id', validate(updatePollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await meetingPlannerService.update(id, req.body);
res.json(poll);
} catch (err) { next(err); }
});
// Delete poll
adminRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await meetingPlannerService.delete(id);
res.json({ message: 'Poll deleted' });
} catch (err) { next(err); }
});
// Add options
adminRouter.post('/:id/options', validate(addOptionsSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await meetingPlannerService.addOptions(id, req.body);
res.json(poll);
} catch (err) { next(err); }
});
// Remove option
adminRouter.delete('/:id/options/:optionId', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const optionId = req.params.optionId as string;
const poll = await meetingPlannerService.removeOption(id, optionId);
res.json(poll);
} catch (err) { next(err); }
});
// Finalize poll
adminRouter.post('/:id/finalize', validate(finalizePollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await meetingPlannerService.finalize(id, req.body);
res.json(poll);
} catch (err) { next(err); }
});
// Convert to shift
adminRouter.post('/:id/convert-to-shift', validate(convertToShiftSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const shift = await meetingPlannerService.convertToShift(id, req.body);
res.json(shift);
} catch (err) { next(err); }
});
// Convert to Gancio event
adminRouter.post('/:id/convert-to-event', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await meetingPlannerService.convertToEvent(id);
res.json(result);
} catch (err) { next(err); }
});
// Delete comment
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const commentId = req.params.commentId as string;
await meetingPlannerService.deleteComment(id, commentId);
res.json({ message: 'Comment deleted' });
} catch (err) { next(err); }
});
// --- Public Router ---
const publicRouter = Router();
// Public listing of open polls
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await meetingPlannerService.findAll({
status: 'OPEN',
limit: 50,
page: 1,
});
res.json(result);
} catch (err) { next(err); }
});
// View poll by slug
publicRouter.get('/public/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const poll = await meetingPlannerService.findBySlug(slug);
res.json(poll);
} catch (err) { next(err); }
});
// Submit votes
publicRouter.post('/public/:slug/vote', pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
// Try to get userId from optional auth header
let userId: string | undefined;
try {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const jwt = await import('jsonwebtoken');
const { env } = await import('../../config/env');
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
userId = decoded.id;
}
} catch { /* not authenticated, that's fine */ }
const result = await meetingPlannerService.submitVotes(slug, req.body, userId);
res.json(result);
} catch (err) { next(err); }
});
// Add comment
publicRouter.post('/public/:slug/comment', pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
let userId: string | undefined;
try {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const jwt = await import('jsonwebtoken');
const { env } = await import('../../config/env');
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
userId = decoded.id;
}
} catch { /* not authenticated */ }
const comment = await meetingPlannerService.addComment(slug, req.body, userId);
res.status(201).json(comment);
} catch (err) { next(err); }
});
export { adminRouter as meetingPlannerAdminRouter, publicRouter as meetingPlannerPublicRouter };

View File

@@ -0,0 +1,77 @@
import { z } from 'zod';
import { SchedulingPollStatus, PollVoteValue } from '@prisma/client';
export const createPollSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
location: z.string().max(500).optional(),
timezone: z.string().default('America/Edmonton'),
allowAnonymous: z.boolean().optional().default(true),
notifyOnVote: z.boolean().optional().default(true),
votingDeadline: z.string().datetime().optional(),
options: z.array(z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
})).min(2, 'At least 2 options required').max(20, 'Maximum 20 options'),
});
export const updatePollSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
location: z.string().max(500).nullable().optional(),
timezone: z.string().optional(),
allowAnonymous: z.boolean().optional(),
notifyOnVote: z.boolean().optional(),
votingDeadline: z.string().datetime().nullable().optional(),
status: z.nativeEnum(SchedulingPollStatus).optional(),
});
export const addOptionsSchema = z.object({
options: z.array(z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
})).min(1).max(20),
});
export const submitVotesSchema = z.object({
voterName: z.string().min(1, 'Name is required').max(100),
voterEmail: z.string().email().max(200).optional(),
voterToken: z.string().optional(),
votes: z.array(z.object({
optionId: z.string().min(1),
value: z.nativeEnum(PollVoteValue),
})).min(1, 'At least one vote required'),
});
export const submitCommentSchema = z.object({
authorName: z.string().min(1, 'Name is required').max(100),
content: z.string().min(1, 'Comment is required').max(2000),
});
export const finalizePollSchema = z.object({
optionId: z.string().min(1, 'Option ID is required'),
});
export const convertToShiftSchema = z.object({
maxVolunteers: z.number().int().min(1).default(10),
isPublic: z.boolean().optional().default(true),
cutId: z.string().optional(),
});
export const listPollsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.nativeEnum(SchedulingPollStatus).optional(),
});
export type CreatePollInput = z.infer<typeof createPollSchema>;
export type UpdatePollInput = z.infer<typeof updatePollSchema>;
export type AddOptionsInput = z.infer<typeof addOptionsSchema>;
export type SubmitVotesInput = z.infer<typeof submitVotesSchema>;
export type SubmitCommentInput = z.infer<typeof submitCommentSchema>;
export type FinalizePollInput = z.infer<typeof finalizePollSchema>;
export type ConvertToShiftInput = z.infer<typeof convertToShiftSchema>;
export type ListPollsInput = z.infer<typeof listPollsSchema>;

View File

@@ -0,0 +1,491 @@
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,
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;
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;
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; 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.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 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,
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.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 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.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');
return prisma.schedulingPollComment.create({
data: {
pollId: poll.id,
userId,
authorName: data.authorName,
content: data.content,
},
});
},
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 {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 24; i++) {
token += chars[Math.floor(Math.random() * chars.length)];
}
return token;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -56,6 +56,7 @@ export const updateSiteSettingsSchema = z.object({
enablePeople: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enableMeet: z.boolean().optional(),
enableMeetingPlanner: z.boolean().optional(),
autoSyncPeopleToMap: z.boolean().optional(),
// SMS connection config

View File

@@ -3,7 +3,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsContactsService } from './sms-contacts.service';
import { createContactListSchema, updateContactListSchema, createContactEntrySchema } from './sms-contacts.schemas';
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
const router = Router();
@@ -144,6 +144,14 @@ router.post('/:id/entries', validate(createContactEntrySchema), async (req, res,
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/entries/bulk — add multiple entries at once
router.post('/:id/entries/bulk', validate(bulkAddEntriesSchema), async (req, res, next) => {
try {
const result = await smsContactsService.addEntriesBulk(req.params.id as string, req.body.entries);
res.json(result);
} catch (err) { next(err); }
});
// DELETE /api/sms/contacts/:id/entries/:entryId — remove an entry
router.delete('/:id/entries/:entryId', async (req, res, next) => {
try {

View File

@@ -15,6 +15,15 @@ export const createContactEntrySchema = z.object({
customFields: z.record(z.string()).optional(),
});
export const bulkAddEntriesSchema = z.object({
entries: z.array(z.object({
phone: z.string().min(7).max(20),
name: z.string().max(200).optional(),
email: z.string().email().max(200).optional(),
})).min(1).max(1000),
});
export type CreateContactListInput = z.infer<typeof createContactListSchema>;
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;
export type BulkAddEntriesInput = z.infer<typeof bulkAddEntriesSchema>;

View File

@@ -197,6 +197,32 @@ export const smsContactsService = {
return entry;
},
async addEntriesBulk(listId: string, entries: { phone: string; name?: string; email?: string }[]) {
let imported = 0;
let skipped = 0;
for (const entry of entries) {
const phone = normalizePhone(entry.phone);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: entry.name, email: entry.email },
update: { name: entry.name, email: entry.email },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
async deleteEntry(id: string) {
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
// Update total count

View File

@@ -31,6 +31,7 @@ import { mapSettingsRouter } from './modules/map/settings/settings.routes';
import { qrRouter } from './modules/qr/qr.routes';
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes';
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
import { blocksRouter } from './modules/pages/blocks.routes';
@@ -211,6 +212,8 @@ app.use('/api/map/shifts', shiftsAdminRouter); // Admin shift CRUD (au
app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
app.use('/api/qr', qrRouter); // QR code generation (public)
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)