mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-01 10:11:50 +02:00
♻️ Create backend package (#643)
This commit is contained in:
parent
7fc08c6736
commit
05fe2edaea
68 changed files with 476 additions and 391 deletions
239
packages/backend/trpc/routers/auth.ts
Normal file
239
packages/backend/trpc/routers/auth.ts
Normal file
|
@ -0,0 +1,239 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { sendEmail } from "@rallly/emails";
|
||||
import { absoluteUrl } from "@rallly/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createToken, decryptToken } from "../../session";
|
||||
import { generateOtp } from "../../utils/nanoid";
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import { LoginTokenPayload, RegistrationTokenPayload } from "../types";
|
||||
|
||||
// assigns participants and comments created by guests to a user
|
||||
// we could have multiple guests because a login might be triggered from one device
|
||||
// and opened in another one.
|
||||
const mergeGuestsIntoUser = async (userId: string, guestIds: string[]) => {
|
||||
await prisma.participant.updateMany({
|
||||
where: {
|
||||
userId: {
|
||||
in: guestIds,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.comment.updateMany({
|
||||
where: {
|
||||
userId: {
|
||||
in: guestIds,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isEmailBlocked = (email: string) => {
|
||||
if (process.env.ALLOWED_EMAILS) {
|
||||
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
|
||||
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
|
||||
const isAllowed = allowedEmails.some((allowedEmail) => {
|
||||
const regex = new RegExp(allowedEmail.trim().replace("*", ".*"), "i");
|
||||
return regex.test(email);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const auth = router({
|
||||
requestRegistration: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input,
|
||||
}): Promise<
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
|
||||
> => {
|
||||
if (isEmailBlocked(input.email)) {
|
||||
return { ok: false, reason: "emailNotAllowed" };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
email: input.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
return { ok: false, reason: "userAlreadyExists" };
|
||||
}
|
||||
|
||||
const code = await generateOtp();
|
||||
|
||||
const token = await createToken<RegistrationTokenPayload>({
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
code,
|
||||
});
|
||||
|
||||
await sendEmail("RegisterEmail", {
|
||||
to: input.email,
|
||||
subject: "Please verify your email address",
|
||||
props: {
|
||||
code,
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, token };
|
||||
},
|
||||
),
|
||||
authenticateRegistration: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
token: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const payload = await decryptToken<RegistrationTokenPayload>(input.token);
|
||||
|
||||
if (!payload) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const { name, email, code } = payload;
|
||||
if (input.code !== code) {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
select: { id: true, name: true, email: true },
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (ctx.session.user?.isGuest) {
|
||||
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
|
||||
}
|
||||
|
||||
ctx.session.user = {
|
||||
isGuest: false,
|
||||
id: user.id,
|
||||
};
|
||||
|
||||
await ctx.session.save();
|
||||
|
||||
return { ok: true, user };
|
||||
}),
|
||||
requestLogin: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input,
|
||||
}): Promise<
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; reason: "emailNotAllowed" | "userNotFound" }
|
||||
> => {
|
||||
if (isEmailBlocked(input.email)) {
|
||||
return { ok: false, reason: "emailNotAllowed" };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: input.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { ok: false, reason: "userNotFound" };
|
||||
}
|
||||
|
||||
const code = await generateOtp();
|
||||
|
||||
const token = await createToken<LoginTokenPayload>({
|
||||
userId: user.id,
|
||||
code,
|
||||
});
|
||||
|
||||
await sendEmail("LoginEmail", {
|
||||
to: input.email,
|
||||
subject: "Login",
|
||||
props: {
|
||||
name: user.name,
|
||||
code,
|
||||
magicLink: absoluteUrl(`/auth/login?token=${token}`),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, token };
|
||||
},
|
||||
),
|
||||
authenticateLogin: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
token: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const payload = await decryptToken<LoginTokenPayload>(input.token);
|
||||
|
||||
if (!payload) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const { userId, code } = payload;
|
||||
|
||||
if (input.code !== code) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: { id: true, name: true, email: true },
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "The user doesn't exist anymore",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session.user?.isGuest) {
|
||||
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
|
||||
}
|
||||
|
||||
ctx.session.user = {
|
||||
isGuest: false,
|
||||
id: user.id,
|
||||
};
|
||||
|
||||
await ctx.session.save();
|
||||
|
||||
return { user };
|
||||
}),
|
||||
});
|
37
packages/backend/trpc/routers/feedback.ts
Normal file
37
packages/backend/trpc/routers/feedback.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { sendRawEmail } from "@rallly/emails";
|
||||
import { z } from "zod";
|
||||
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
|
||||
export const feedback = router({
|
||||
send: publicProcedure
|
||||
.input(z.object({ content: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
let replyTo: string | undefined;
|
||||
let name = "Guest";
|
||||
|
||||
if (!ctx.user.isGuest) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: ctx.user.id },
|
||||
select: { email: true, name: true },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
replyTo = user.email;
|
||||
name = user.name;
|
||||
}
|
||||
}
|
||||
|
||||
await sendRawEmail({
|
||||
to: process.env.NEXT_PUBLIC_FEEDBACK_EMAIL,
|
||||
from: {
|
||||
name: "Rallly Feedback Form",
|
||||
address: process.env.SUPPORT_EMAIL ?? "",
|
||||
},
|
||||
subject: "Feedback",
|
||||
replyTo,
|
||||
text: `${name} says:\n\n${input.content}`,
|
||||
});
|
||||
}),
|
||||
});
|
18
packages/backend/trpc/routers/index.ts
Normal file
18
packages/backend/trpc/routers/index.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { mergeRouters, router } from "../trpc";
|
||||
import { auth } from "./auth";
|
||||
import { feedback } from "./feedback";
|
||||
import { polls } from "./polls";
|
||||
import { user } from "./user";
|
||||
import { whoami } from "./whoami";
|
||||
|
||||
export const appRouter = mergeRouters(
|
||||
router({
|
||||
whoami,
|
||||
auth,
|
||||
polls,
|
||||
user,
|
||||
feedback,
|
||||
}),
|
||||
);
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
379
packages/backend/trpc/routers/polls.ts
Normal file
379
packages/backend/trpc/routers/polls.ts
Normal file
|
@ -0,0 +1,379 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { sendEmail } from "@rallly/emails";
|
||||
import { absoluteUrl } from "@rallly/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { nanoid } from "../../utils/nanoid";
|
||||
import { possiblyPublicProcedure, publicProcedure, router } from "../trpc";
|
||||
import { comments } from "./polls/comments";
|
||||
import { demo } from "./polls/demo";
|
||||
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",
|
||||
});
|
||||
}
|
||||
return res.id;
|
||||
};
|
||||
|
||||
export const polls = router({
|
||||
// START LEGACY ROUTES
|
||||
create: possiblyPublicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
timeZone: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
user: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
options: z
|
||||
.object({
|
||||
startDate: z.string(),
|
||||
endDate: z.string().optional(),
|
||||
})
|
||||
.array(),
|
||||
demo: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({ ctx, input }): Promise<{ id: string; urlId: string }> => {
|
||||
const adminUrlId = await nanoid();
|
||||
const participantUrlId = await nanoid();
|
||||
|
||||
let email = input.user?.email;
|
||||
let name = input.user?.name;
|
||||
|
||||
if (!ctx.user.isGuest) {
|
||||
const user = await prisma.user.findUnique({
|
||||
select: { email: true, name: true },
|
||||
where: { id: ctx.user.id },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
email = user.email;
|
||||
name = user.name;
|
||||
}
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
select: {
|
||||
adminUrlId: true,
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
data: {
|
||||
id: await nanoid(),
|
||||
title: input.title,
|
||||
timeZone: input.timeZone,
|
||||
location: input.location,
|
||||
description: input.description,
|
||||
demo: input.demo,
|
||||
adminUrlId,
|
||||
participantUrlId,
|
||||
userId: ctx.user.id,
|
||||
watchers: !ctx.user.isGuest
|
||||
? {
|
||||
create: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
options: {
|
||||
createMany: {
|
||||
data: input.options.map((option) => ({
|
||||
start: new Date(`${option.startDate}Z`),
|
||||
duration: option.endDate
|
||||
? dayjs(option.endDate).diff(
|
||||
dayjs(option.startDate),
|
||||
"minute",
|
||||
)
|
||||
: 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const adminLink = absoluteUrl(`/admin/${adminUrlId}`);
|
||||
const participantLink = absoluteUrl(`/p/${participantUrlId}`);
|
||||
|
||||
if (email && name) {
|
||||
await sendEmail("NewPollEmail", {
|
||||
to: email,
|
||||
subject: `Let's find a date for ${poll.title}`,
|
||||
props: {
|
||||
title: poll.title,
|
||||
name,
|
||||
adminLink,
|
||||
participantLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { id: poll.id, urlId: adminUrlId };
|
||||
},
|
||||
),
|
||||
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(),
|
||||
closed: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.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 {
|
||||
start: new Date(`${start}Z`),
|
||||
duration: dayjs(end).diff(dayjs(start), "minute"),
|
||||
pollId,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
start: new Date(start.substring(0, 10) + "T00:00:00Z"),
|
||||
pollId,
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.poll.update({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: pollId,
|
||||
},
|
||||
data: {
|
||||
title: input.title,
|
||||
location: input.location,
|
||||
description: input.description,
|
||||
timeZone: input.timeZone,
|
||||
closed: input.closed,
|
||||
},
|
||||
});
|
||||
}),
|
||||
delete: possiblyPublicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
urlId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input: { urlId } }) => {
|
||||
const pollId = await getPollIdFromAdminUrlId(urlId);
|
||||
await prisma.poll.delete({ where: { id: pollId } });
|
||||
}),
|
||||
touch: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input: { pollId } }) => {
|
||||
await prisma.poll.update({
|
||||
where: {
|
||||
id: pollId,
|
||||
},
|
||||
data: {
|
||||
touchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}),
|
||||
demo,
|
||||
participants,
|
||||
comments,
|
||||
// END LEGACY ROUTES
|
||||
watch: possiblyPublicProcedure
|
||||
.input(z.object({ pollId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (ctx.user.isGuest) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Guests can't watch polls",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.watcher.create({
|
||||
data: {
|
||||
pollId: input.pollId,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
unwatch: possiblyPublicProcedure
|
||||
.input(z.object({ pollId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (ctx.user.isGuest) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Guests can't unwatch polls",
|
||||
});
|
||||
}
|
||||
|
||||
const res = await prisma.watcher.findFirst({
|
||||
where: {
|
||||
pollId: input.pollId,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Not watching this poll",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.watcher.delete({
|
||||
where: {
|
||||
id: res.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
getByAdminUrlId: possiblyPublicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
urlId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const res = await prisma.poll.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
title: true,
|
||||
location: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
adminUrlId: true,
|
||||
participantUrlId: true,
|
||||
closed: true,
|
||||
legacy: true,
|
||||
demo: true,
|
||||
options: {
|
||||
orderBy: {
|
||||
start: "asc",
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
deleted: true,
|
||||
watchers: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
adminUrlId: input.urlId,
|
||||
},
|
||||
rejectOnNotFound: false,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Poll not found",
|
||||
});
|
||||
}
|
||||
|
||||
return res;
|
||||
}),
|
||||
getByParticipantUrlId: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
urlId: z.string(),
|
||||
}),
|
||||
)
|
||||
.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,
|
||||
closed: true,
|
||||
legacy: true,
|
||||
demo: true,
|
||||
options: {
|
||||
orderBy: {
|
||||
start: "asc",
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
userId: true,
|
||||
deleted: true,
|
||||
watchers: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
participantUrlId: input.urlId,
|
||||
},
|
||||
rejectOnNotFound: false,
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Poll not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.id === res.userId) {
|
||||
return res;
|
||||
} else {
|
||||
return { ...res, adminUrlId: "" };
|
||||
}
|
||||
}),
|
||||
});
|
117
packages/backend/trpc/routers/polls/comments.ts
Normal file
117
packages/backend/trpc/routers/polls/comments.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { sendEmail } from "@rallly/emails";
|
||||
import { absoluteUrl } from "@rallly/utils";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createToken } from "../../../session";
|
||||
import { publicProcedure, router } from "../../trpc";
|
||||
import { DisableNotificationsPayload } from "../../types";
|
||||
|
||||
export const comments = router({
|
||||
list: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { pollId } }) => {
|
||||
return await prisma.comment.findMany({
|
||||
where: { pollId },
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
add: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
authorName: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input: { pollId, authorName, content } }) => {
|
||||
const user = ctx.session.user;
|
||||
|
||||
const newComment = await prisma.comment.create({
|
||||
data: {
|
||||
content,
|
||||
pollId,
|
||||
authorName,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
authorName: true,
|
||||
content: true,
|
||||
poll: {
|
||||
select: {
|
||||
title: true,
|
||||
adminUrlId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const watchers = await prisma.watcher.findMany({
|
||||
where: {
|
||||
pollId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const poll = newComment.poll;
|
||||
|
||||
const emailsToSend: Promise<void>[] = [];
|
||||
|
||||
for (const watcher of watchers) {
|
||||
const email = watcher.user.email;
|
||||
const token = await createToken<DisableNotificationsPayload>(
|
||||
{ watcherId: watcher.id, pollId },
|
||||
{ ttl: 0 },
|
||||
);
|
||||
emailsToSend.push(
|
||||
sendEmail("NewCommentEmail", {
|
||||
to: email,
|
||||
subject: `New comment on ${poll.title}`,
|
||||
props: {
|
||||
name: watcher.user.name,
|
||||
authorName,
|
||||
pollUrl: absoluteUrl(`/admin/${poll.adminUrlId}`),
|
||||
disableNotificationsUrl: absoluteUrl(
|
||||
`/auth/disable-notifications?token=${token}`,
|
||||
),
|
||||
title: poll.title,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return newComment;
|
||||
}),
|
||||
delete: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
commentId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input: { commentId } }) => {
|
||||
await prisma.comment.delete({
|
||||
where: {
|
||||
id: commentId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
110
packages/backend/trpc/routers/polls/demo.ts
Normal file
110
packages/backend/trpc/routers/polls/demo.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { prisma, VoteType } from "@rallly/database";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { nanoid } from "../../../utils/nanoid";
|
||||
import { possiblyPublicProcedure, router } from "../../trpc";
|
||||
|
||||
const participantData: Array<{ name: string; votes: VoteType[] }> = [
|
||||
{
|
||||
name: "Reed",
|
||||
votes: ["yes", "no", "yes", "no"],
|
||||
},
|
||||
{
|
||||
name: "Susan",
|
||||
votes: ["yes", "yes", "yes", "no"],
|
||||
},
|
||||
{
|
||||
name: "Johnny",
|
||||
votes: ["no", "no", "yes", "yes"],
|
||||
},
|
||||
{
|
||||
name: "Ben",
|
||||
votes: ["yes", "yes", "yes", "yes"],
|
||||
},
|
||||
];
|
||||
|
||||
const optionValues = ["2022-12-14", "2022-12-15", "2022-12-16", "2022-12-17"];
|
||||
|
||||
export const demo = router({
|
||||
create: possiblyPublicProcedure.mutation(async () => {
|
||||
const adminUrlId = await nanoid();
|
||||
const demoUser = { name: "John Example", email: "noreply@rallly.co" };
|
||||
|
||||
const options: Array<{ start: Date; id: string }> = [];
|
||||
|
||||
for (let i = 0; i < optionValues.length; i++) {
|
||||
options.push({ id: await nanoid(), start: new Date(optionValues[i]) });
|
||||
}
|
||||
|
||||
const participants: Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
}> = [];
|
||||
|
||||
const votes: Array<{
|
||||
optionId: string;
|
||||
participantId: string;
|
||||
type: VoteType;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < participantData.length; i++) {
|
||||
const { name, votes: participantVotes } = participantData[i];
|
||||
const participantId = await nanoid();
|
||||
participants.push({
|
||||
id: participantId,
|
||||
name,
|
||||
userId: "user-demo",
|
||||
createdAt: dayjs()
|
||||
.add(i * -1, "minutes")
|
||||
.toDate(),
|
||||
});
|
||||
|
||||
options.forEach((option, index) => {
|
||||
votes.push({
|
||||
optionId: option.id,
|
||||
participantId,
|
||||
type: participantVotes[index],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.poll.create({
|
||||
data: {
|
||||
id: await nanoid(),
|
||||
title: "Lunch Meeting",
|
||||
location: "Starbucks, 901 New York Avenue",
|
||||
description: `Hey everyone, please choose the dates when you are available to meet for our monthly get together. Looking forward to see you all!`,
|
||||
demo: true,
|
||||
adminUrlId,
|
||||
participantUrlId: await nanoid(),
|
||||
user: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
email: demoUser.email,
|
||||
},
|
||||
create: demoUser,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
createMany: {
|
||||
data: options,
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
createMany: {
|
||||
data: participants,
|
||||
},
|
||||
},
|
||||
votes: {
|
||||
createMany: {
|
||||
data: votes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return adminUrlId;
|
||||
}),
|
||||
});
|
211
packages/backend/trpc/routers/polls/participants.ts
Normal file
211
packages/backend/trpc/routers/polls/participants.ts
Normal file
|
@ -0,0 +1,211 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { sendEmail } from "@rallly/emails";
|
||||
import { absoluteUrl } from "@rallly/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createToken } from "../../../session";
|
||||
import { publicProcedure, router } from "../../trpc";
|
||||
import { DisableNotificationsPayload } from "../../types";
|
||||
|
||||
export const participants = router({
|
||||
list: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input: { pollId } }) => {
|
||||
const participants = await prisma.participant.findMany({
|
||||
where: {
|
||||
pollId,
|
||||
},
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
{ name: "desc" },
|
||||
],
|
||||
});
|
||||
return participants;
|
||||
}),
|
||||
delete: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
participantId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input: { participantId } }) => {
|
||||
await prisma.participant.delete({
|
||||
where: {
|
||||
id: participantId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
add: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
name: z.string().min(1, "Participant name is required"),
|
||||
email: z.string().optional(),
|
||||
votes: z
|
||||
.object({
|
||||
optionId: z.string(),
|
||||
type: z.enum(["yes", "no", "ifNeedBe"]),
|
||||
})
|
||||
.array(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input: { pollId, votes, name, email } }) => {
|
||||
const user = ctx.session.user;
|
||||
|
||||
const poll = await prisma.poll.findUnique({
|
||||
where: { id: pollId },
|
||||
select: { title: true, adminUrlId: true, participantUrlId: true },
|
||||
});
|
||||
|
||||
if (!poll) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Poll not found" });
|
||||
}
|
||||
|
||||
const participant = await prisma.participant.create({
|
||||
data: {
|
||||
pollId: pollId,
|
||||
name: name,
|
||||
userId: user.id,
|
||||
votes: {
|
||||
createMany: {
|
||||
data: votes.map(({ optionId, type }) => ({
|
||||
optionId,
|
||||
type,
|
||||
pollId: pollId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emailsToSend: Promise<void>[] = [];
|
||||
if (email) {
|
||||
const token = await createToken(
|
||||
{ userId: user.id },
|
||||
{
|
||||
ttl: 0, // basically forever
|
||||
},
|
||||
);
|
||||
|
||||
emailsToSend.push(
|
||||
sendEmail("NewParticipantConfirmationEmail", {
|
||||
to: email,
|
||||
subject: `Response submitted for ${poll.title}`,
|
||||
props: {
|
||||
name,
|
||||
title: poll.title,
|
||||
editSubmissionUrl: absoluteUrl(
|
||||
`/p/${poll.participantUrlId}?token=${token}`,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const watchers = await prisma.watcher.findMany({
|
||||
where: {
|
||||
pollId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const watcher of watchers) {
|
||||
const email = watcher.user.email;
|
||||
const token = await createToken<DisableNotificationsPayload>(
|
||||
{ watcherId: watcher.id, pollId },
|
||||
{ ttl: 0 },
|
||||
);
|
||||
emailsToSend.push(
|
||||
sendEmail("NewParticipantEmail", {
|
||||
to: email,
|
||||
subject: `New response for ${poll.title}`,
|
||||
props: {
|
||||
name: watcher.user.name,
|
||||
participantName: participant.name,
|
||||
pollUrl: absoluteUrl(`/admin/${poll.adminUrlId}`),
|
||||
disableNotificationsUrl: absoluteUrl(
|
||||
`/auth/disable-notifications?token=${token}`,
|
||||
),
|
||||
title: poll.title,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(emailsToSend);
|
||||
|
||||
return participant;
|
||||
}),
|
||||
rename: publicProcedure
|
||||
.input(z.object({ participantId: z.string(), newName: z.string() }))
|
||||
.mutation(async ({ input: { participantId, newName } }) => {
|
||||
await prisma.participant.update({
|
||||
where: {
|
||||
id: participantId,
|
||||
},
|
||||
data: {
|
||||
name: newName,
|
||||
},
|
||||
select: null,
|
||||
});
|
||||
}),
|
||||
update: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pollId: z.string(),
|
||||
participantId: z.string(),
|
||||
votes: z
|
||||
.object({
|
||||
optionId: z.string(),
|
||||
type: z.enum(["yes", "no", "ifNeedBe"]),
|
||||
})
|
||||
.array(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input: { pollId, participantId, votes } }) => {
|
||||
const participant = await prisma.participant.update({
|
||||
where: {
|
||||
id: participantId,
|
||||
},
|
||||
data: {
|
||||
votes: {
|
||||
deleteMany: {
|
||||
pollId: pollId,
|
||||
},
|
||||
createMany: {
|
||||
data: votes.map(({ optionId, type }) => ({
|
||||
optionId,
|
||||
type,
|
||||
pollId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
votes: true,
|
||||
},
|
||||
});
|
||||
|
||||
return participant;
|
||||
}),
|
||||
});
|
48
packages/backend/trpc/routers/user.ts
Normal file
48
packages/backend/trpc/routers/user.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { z } from "zod";
|
||||
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
|
||||
export const user = router({
|
||||
getPolls: publicProcedure.query(async ({ ctx }) => {
|
||||
const userPolls = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
polls: {
|
||||
where: {
|
||||
deleted: false,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
closed: true,
|
||||
createdAt: true,
|
||||
adminUrlId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return userPolls;
|
||||
}),
|
||||
changeName: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
name: z.string().min(1).max(100),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: input.userId,
|
||||
},
|
||||
data: {
|
||||
name: input.name,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
27
packages/backend/trpc/routers/whoami.ts
Normal file
27
packages/backend/trpc/routers/whoami.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import { UserSession } from "../types";
|
||||
|
||||
export const whoami = router({
|
||||
get: publicProcedure.query(async ({ ctx }): Promise<UserSession> => {
|
||||
if (ctx.user.isGuest) {
|
||||
return { isGuest: true, id: ctx.user.id };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: { id: true, name: true, email: true },
|
||||
where: { id: ctx.user.id },
|
||||
});
|
||||
|
||||
if (user === null) {
|
||||
ctx.session.destroy();
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return { isGuest: false, ...user };
|
||||
}),
|
||||
destroy: publicProcedure.mutation(async ({ ctx }) => {
|
||||
ctx.session.destroy();
|
||||
}),
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue