630 lines
21 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|