rallly/apps/web/src/trpc/routers/polls.ts
2025-04-02 15:02:52 +01:00

867 lines
22 KiB
TypeScript

import type { PollStatus } from "@rallly/database";
import { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server";
import { absoluteUrl, shortUrl } from "@rallly/utils/absolute-url";
import { nanoid } from "@rallly/utils/nanoid";
import { TRPCError } from "@trpc/server";
import dayjs from "dayjs";
import * as ics from "ics";
import { z } from "zod";
import { moderateContent } from "@/features/moderation";
import { getEmailClient } from "@/utils/emails";
import { getTimeZoneAbbreviation } from "../../utils/date";
import {
createRateLimitMiddleware,
possiblyPublicProcedure,
privateProcedure,
proProcedure,
publicProcedure,
requireUserMiddleware,
router,
} from "../trpc";
import { comments } from "./polls/comments";
import { participants } from "./polls/participants";
const getPollIdFromAdminUrlId = async (urlId: string) => {
const res = await prisma.poll.findUnique({
select: {
id: true,
},
where: { adminUrlId: urlId },
});
if (!res) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Poll not found",
});
}
return res.id;
};
export const polls = router({
participants,
comments,
getCountByStatus: privateProcedure.query(async ({ ctx }) => {
const res = await prisma.poll.groupBy({
by: ["status"],
where: {
userId: ctx.user.id,
deletedAt: null,
},
_count: {
status: true,
},
});
return res.reduce(
(acc, { status, _count }) => {
acc[status] = _count.status;
return acc;
},
{} as Record<PollStatus, number>,
);
}),
infiniteList: privateProcedure
.input(
z.object({
status: z.enum(["all", "live", "paused", "finalized"]),
cursor: z.string().optional(),
limit: z.number(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor, limit, status } = input;
const polls = await prisma.poll.findMany({
where: {
...(ctx.user.isGuest
? { guestId: ctx.user.id }
: { userId: ctx.user.id }),
deletedAt: null,
status: status === "all" ? undefined : status,
},
orderBy: [
{
createdAt: "desc",
},
{
title: "asc",
},
],
cursor: cursor ? { id: cursor } : undefined,
take: limit + 1,
select: {
id: true,
title: true,
location: true,
timeZone: true,
createdAt: true,
status: true,
guestId: true,
user: {
select: {
id: true,
name: true,
},
},
participants: {
where: {
deletedAt: null,
},
select: {
id: true,
name: true,
},
},
},
});
let nextCursor: typeof cursor | undefined = undefined;
if (polls.length > input.limit) {
const nextItem = polls.pop();
nextCursor = nextItem!.id;
}
return {
polls,
nextCursor,
};
}),
// START LEGACY ROUTES
create: possiblyPublicProcedure
.input(
z.object({
title: z.string().trim().min(1),
timeZone: z.string().optional(),
location: z.string().optional(),
description: z.string().optional(),
hideParticipants: z.boolean().optional(),
hideScores: z.boolean().optional(),
disableComments: z.boolean().optional(),
requireParticipantEmail: z.boolean().optional(),
options: z
.object({
startDate: z.string(),
endDate: z.string().optional(),
})
.array(),
}),
)
.use(requireUserMiddleware)
.use(createRateLimitMiddleware("create_poll", 20, "1 h"))
.use(async ({ ctx, input, next }) => {
const isFlaggedContent = await moderateContent([
input.title,
input.description,
input.location,
]);
if (isFlaggedContent) {
posthog?.capture({
distinctId: ctx.user.id,
event: "flagged_content",
properties: {
action: "create_poll",
},
});
throw new TRPCError({
code: "BAD_REQUEST",
message: "Inappropriate content",
});
}
return next();
})
.mutation(async ({ ctx, input }) => {
const adminToken = nanoid();
const participantUrlId = nanoid();
const pollId = nanoid();
const poll = await prisma.poll.create({
select: {
adminUrlId: true,
id: true,
title: true,
options: {
select: {
id: true,
},
},
},
data: {
id: pollId,
title: input.title,
timeZone: input.timeZone,
location: input.location,
description: input.description,
adminUrlId: adminToken,
participantUrlId,
...(ctx.user.isGuest
? { guestId: ctx.user.id }
: { userId: ctx.user.id }),
watchers: !ctx.user.isGuest
? {
create: {
userId: ctx.user.id,
},
}
: undefined,
options: {
createMany: {
data: input.options.map((option) => ({
startTime: input.timeZone
? dayjs(option.startDate).tz(input.timeZone, true).toDate()
: dayjs(option.startDate).utc(true).toDate(),
duration: option.endDate
? dayjs(option.endDate).diff(
dayjs(option.startDate),
"minute",
)
: 0,
})),
},
},
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
hideScores: input.hideScores,
requireParticipantEmail: input.requireParticipantEmail,
},
});
const pollLink = absoluteUrl(`/poll/${pollId}`);
const participantLink = shortUrl(`/invite/${pollId}`);
if (ctx.user.isGuest === false) {
const user = await prisma.user.findUnique({
select: { email: true, name: true },
where: { id: ctx.user.id },
});
if (user) {
ctx.user.getEmailClient().queueTemplate("NewPollEmail", {
to: user.email,
props: {
title: poll.title,
name: user.name,
adminLink: pollLink,
participantLink,
},
});
}
}
return { id: poll.id };
}),
update: possiblyPublicProcedure
.input(
z.object({
urlId: z.string(),
title: z.string().optional(),
timeZone: z.string().optional(),
location: z.string().optional(),
description: z.string().optional(),
optionsToDelete: z.string().array().optional(),
optionsToAdd: z.string().array().optional(),
hideParticipants: z.boolean().optional(),
disableComments: z.boolean().optional(),
hideScores: z.boolean().optional(),
requireParticipantEmail: z.boolean().optional(),
}),
)
.use(requireUserMiddleware)
.use(createRateLimitMiddleware("update_poll", 5, "1 m"))
.use(async ({ ctx, input, next }) => {
const isFlaggedContent = await moderateContent([
input.title,
input.description,
input.location,
]);
if (isFlaggedContent) {
posthog?.capture({
distinctId: ctx.user.id,
event: "flagged_content",
properties: {
action: "update_poll",
},
});
throw new TRPCError({
code: "BAD_REQUEST",
message: "Inappropriate content",
});
}
return next();
})
.mutation(async ({ input }) => {
const pollId = await getPollIdFromAdminUrlId(input.urlId);
if (input.optionsToDelete && input.optionsToDelete.length > 0) {
await prisma.option.deleteMany({
where: {
pollId,
id: {
in: input.optionsToDelete,
},
},
});
}
if (input.optionsToAdd && input.optionsToAdd.length > 0) {
await prisma.option.createMany({
data: input.optionsToAdd.map((optionValue) => {
const [start, end] = optionValue.split("/");
if (end) {
return {
startTime: input.timeZone
? dayjs(start).tz(input.timeZone, true).toDate()
: dayjs(start).utc(true).toDate(),
duration: dayjs(end).diff(dayjs(start), "minute"),
pollId,
};
} else {
return {
startTime: dayjs(start).utc(true).toDate(),
pollId,
};
}
}),
});
}
await prisma.poll.update({
select: { id: true },
where: {
id: pollId,
},
data: {
title: input.title,
location: input.location,
description: input.description,
timeZone: input.timeZone,
hideScores: input.hideScores,
hideParticipants: input.hideParticipants,
disableComments: input.disableComments,
requireParticipantEmail: input.requireParticipantEmail,
},
});
}),
delete: possiblyPublicProcedure
.input(
z.object({
urlId: z.string(),
}),
)
.mutation(async ({ input: { urlId } }) => {
const pollId = await getPollIdFromAdminUrlId(urlId);
await prisma.poll.update({
where: { id: pollId },
data: { deleted: true, deletedAt: new Date() },
});
}),
// END LEGACY ROUTES
getWatchers: publicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.query(async ({ input: { pollId } }) => {
return await prisma.watcher.findMany({
where: {
pollId,
},
select: {
userId: true,
},
});
}),
watch: privateProcedure
.input(z.object({ pollId: z.string() }))
.mutation(async ({ input, ctx }) => {
await prisma.watcher.create({
data: {
pollId: input.pollId,
userId: ctx.user.id,
},
});
}),
unwatch: privateProcedure
.input(z.object({ pollId: z.string() }))
.mutation(async ({ input, ctx }) => {
const watcher = await prisma.watcher.findFirst({
where: {
pollId: input.pollId,
userId: ctx.user.id,
},
select: {
id: true,
},
});
if (watcher) {
await prisma.watcher.delete({
where: {
id: watcher.id,
},
});
}
}),
get: publicProcedure
.input(
z.object({
urlId: z.string(),
adminToken: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
const res = await prisma.poll.findUnique({
select: {
id: true,
timeZone: true,
title: true,
location: true,
description: true,
createdAt: true,
adminUrlId: true,
participantUrlId: true,
status: true,
hideParticipants: true,
disableComments: true,
hideScores: true,
requireParticipantEmail: true,
options: {
select: {
id: true,
startTime: true,
duration: true,
},
orderBy: {
startTime: "asc",
},
},
user: {
select: {
id: true,
name: true,
email: true,
image: true,
banned: true,
},
},
userId: true,
guestId: true,
deleted: true,
event: {
select: {
start: true,
duration: true,
optionId: true,
},
},
watchers: {
select: {
userId: true,
},
},
},
where: {
id: input.urlId,
},
});
if (!res) {
return null;
}
const inviteLink = shortUrl(`/invite/${res.id}`);
const userId = ctx.user?.id;
const isOwner = ctx.user?.isGuest
? userId === res.guestId
: userId === res.userId;
if (isOwner || res.adminUrlId === input.adminToken) {
return { ...res, inviteLink };
} else {
return { ...res, adminUrlId: "", inviteLink };
}
}),
book: proProcedure
.input(
z.object({
pollId: z.string(),
optionId: z.string(),
notify: z.enum(["none", "all", "attendees"]),
}),
)
.mutation(async ({ input, ctx }) => {
const poll = await prisma.poll.findUnique({
where: {
id: input.pollId,
},
select: {
id: true,
createdAt: true,
timeZone: true,
title: true,
location: true,
description: true,
user: {
select: {
name: true,
email: true,
},
},
participants: {
select: {
name: true,
email: true,
locale: true,
votes: {
select: {
optionId: true,
type: true,
},
},
},
},
},
});
if (!poll) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Poll not found",
});
}
if (!poll.user) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Poll has no user",
});
}
// create event in database
const option = await prisma.option.findUnique({
where: {
id: input.optionId,
},
select: {
startTime: true,
duration: true,
},
});
if (!option) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Option not found",
});
}
let eventStart = dayjs(option.startTime);
if (poll.timeZone) {
eventStart = eventStart.tz(poll.timeZone);
} else {
eventStart = eventStart.utc();
}
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
status: "finalized",
event: {
create: {
optionId: input.optionId,
start: eventStart.toDate(),
duration: option.duration,
title: poll.title,
userId: ctx.user.id,
},
},
},
});
const attendees = poll.participants.filter((p) =>
p.votes.some((v) => v.optionId === input.optionId && v.type !== "no"),
);
const icsAttendees = attendees
.filter((a) => !!a.email) // remove participants without email
.map((a) => ({
name: a.name,
email: a.email ?? undefined,
}));
const utcStart = eventStart.utc();
const eventEnd =
option.duration > 0
? eventStart.add(option.duration, "minutes")
: eventStart.add(1, "day");
const event = ics.createEvent({
title: poll.title,
location: poll.location ?? undefined,
description: poll.description ?? undefined,
organizer: {
name: poll.user.name,
email: poll.user.email,
},
attendees: icsAttendees,
...(option.duration > 0
? {
start: [
utcStart.year(),
utcStart.month() + 1,
utcStart.date(),
utcStart.hour(),
utcStart.minute(),
],
startInputType: poll.timeZone ? "utc" : "local",
duration: { minutes: option.duration },
}
: {
start: [
eventStart.year(),
eventStart.month() + 1,
eventStart.date(),
],
end: [eventEnd.year(), eventEnd.month() + 1, eventEnd.date()],
}),
});
if (event.error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: event.error.message,
});
}
if (!event.value) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate ics",
});
} else {
const timeZoneAbbrev = poll.timeZone
? getTimeZoneAbbreviation(eventStart.toDate(), poll.timeZone)
: "";
const date = eventStart.format("dddd, MMMM D, YYYY");
const day = eventStart.format("D");
const dow = eventStart.format("ddd");
const startTime = eventStart.format("hh:mm A");
const endTime = eventEnd.format("hh:mm A");
const time =
option.duration > 0
? `${startTime} - ${endTime} ${timeZoneAbbrev}`
: "All-day";
const participantsToEmail: Array<{
name: string;
email: string;
locale: string | undefined;
}> = [];
if (input.notify === "all") {
poll.participants.forEach((p) => {
if (p.email) {
participantsToEmail.push({
name: p.name,
email: p.email,
locale: p.locale ?? undefined,
});
}
});
}
if (input.notify === "attendees") {
attendees.forEach((p) => {
if (p.email) {
participantsToEmail.push({
name: p.name,
email: p.email,
locale: p.locale ?? undefined,
});
}
});
}
ctx.user.getEmailClient().queueTemplate("FinalizeHostEmail", {
to: poll.user.email,
props: {
name: poll.user.name,
pollUrl: absoluteUrl(`/poll/${poll.id}`),
location: poll.location,
title: poll.title,
attendees: poll.participants
.filter((p) =>
p.votes.some(
(v) => v.optionId === input.optionId && v.type !== "no",
),
)
.map((p) => p.name),
date,
day,
dow,
time,
},
attachments: [{ filename: "event.ics", content: event.value }],
});
for (const p of participantsToEmail) {
getEmailClient(p.locale ?? undefined).queueTemplate(
"FinalizeParticipantEmail",
{
to: p.email,
props: {
pollUrl: absoluteUrl(`/invite/${poll.id}`),
title: poll.title,
hostName: poll.user?.name ?? "",
date,
day,
dow,
time,
},
attachments: [{ filename: "event.ics", content: event.value }],
},
);
}
posthog?.capture({
distinctId: ctx.user.id,
event: "finalize poll",
properties: {
poll_id: poll.id,
poll_time_zone: poll.timeZone,
number_of_participants: poll.participants.length,
number_of_attendees: attendees.length,
days_since_created: dayjs().diff(poll.createdAt, "day"),
},
});
}
}),
reopen: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input }) => {
await prisma.$transaction([
prisma.poll.update({
where: {
id: input.pollId,
},
data: {
event: {
delete: true,
},
status: "live",
},
}),
]);
}),
pause: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input }) => {
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
status: "paused",
},
});
}),
duplicate: proProcedure
.input(
z.object({
pollId: z.string(),
newTitle: z.string().min(1),
}),
)
.mutation(async ({ input, ctx }) => {
const poll = await prisma.poll.findUnique({
where: {
id: input.pollId,
},
select: {
location: true,
description: true,
timeZone: true,
hideParticipants: true,
hideScores: true,
disableComments: true,
options: {
select: {
startTime: true,
duration: true,
},
},
},
});
if (!poll) {
throw new TRPCError({ code: "NOT_FOUND", message: "Poll not found" });
}
const newPoll = await prisma.poll.create({
select: {
id: true,
},
data: {
id: nanoid(),
title: input.newTitle,
userId: ctx.user.id,
timeZone: poll.timeZone,
location: poll.location,
description: poll.description,
hideParticipants: poll.hideParticipants,
hideScores: poll.hideScores,
disableComments: poll.disableComments,
adminUrlId: nanoid(),
participantUrlId: nanoid(),
watchers: {
create: {
userId: ctx.user.id,
},
},
options: {
create: poll.options,
},
},
});
return newPoll;
}),
resume: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input }) => {
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
status: "live",
},
});
}),
});