♻️ Create backend package (#643)

This commit is contained in:
Luke Vella 2023-04-03 10:41:19 +01:00 committed by GitHub
parent 7fc08c6736
commit 05fe2edaea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 476 additions and 391 deletions

View 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 };
}),
});

View 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}`,
});
}),
});

View 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;

View 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: "" };
}
}),
});

View 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,
},
});
}),
});

View 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;
}),
});

View 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;
}),
});

View 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,
},
});
}),
});

View 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();
}),
});