scheduling features
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
192
api/src/modules/meeting-planner/meeting-planner.routes.ts
Normal file
192
api/src/modules/meeting-planner/meeting-planner.routes.ts
Normal 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 };
|
||||
77
api/src/modules/meeting-planner/meeting-planner.schemas.ts
Normal file
77
api/src/modules/meeting-planner/meeting-planner.schemas.ts
Normal 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>;
|
||||
491
api/src/modules/meeting-planner/meeting-planner.service.ts
Normal file
491
api/src/modules/meeting-planner/meeting-planner.service.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user