⬆️ v3.0.0 (#704)

This commit is contained in:
Luke Vella 2023-06-19 17:17:00 +01:00 committed by GitHub
parent 735056f25f
commit c22b3abc4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
385 changed files with 19912 additions and 5250 deletions

View file

@ -19,7 +19,7 @@ export async function createContext(
opts.req.session.user = user;
await opts.req.session.save();
}
return { user, session: opts.req.session };
return { user, session: opts.req.session, req: opts.req, res: opts.res };
}
export type Context = trpc.inferAsyncReturnType<typeof createContext>;

View file

@ -13,6 +13,17 @@ import { LoginTokenPayload, RegistrationTokenPayload } from "../types";
// 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.poll.updateMany({
where: {
userId: {
in: guestIds,
},
},
data: {
userId: userId,
},
});
await prisma.participant.updateMany({
where: {
userId: {

View file

@ -3,6 +3,7 @@ import { auth } from "./auth";
import { feedback } from "./feedback";
import { polls } from "./polls";
import { user } from "./user";
import { userPreferences } from "./user-preferences";
import { whoami } from "./whoami";
export const appRouter = mergeRouters(
@ -12,6 +13,7 @@ export const appRouter = mergeRouters(
polls,
user,
feedback,
userPreferences,
}),
);

View file

@ -3,14 +3,24 @@ import { sendEmail } from "@rallly/emails";
import { absoluteUrl } from "@rallly/utils";
import { TRPCError } from "@trpc/server";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import * as ics from "ics";
import { z } from "zod";
import { printDate } from "../../utils/date";
import { nanoid } from "../../utils/nanoid";
import { possiblyPublicProcedure, publicProcedure, router } from "../trpc";
import { comments } from "./polls/comments";
import { demo } from "./polls/demo";
import { options } from "./polls/options";
import { participants } from "./polls/participants";
dayjs.extend(toArray);
dayjs.extend(timezone);
dayjs.extend(utc);
const getPollIdFromAdminUrlId = async (urlId: string) => {
const res = await prisma.poll.findUnique({
select: {
@ -22,12 +32,17 @@ const getPollIdFromAdminUrlId = async (urlId: string) => {
if (!res) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Poll not found",
});
}
return res.id;
};
export const polls = router({
demo,
participants,
comments,
options,
// START LEGACY ROUTES
create: possiblyPublicProcedure
.input(
@ -51,89 +66,97 @@ export const polls = router({
demo: z.boolean().optional(),
}),
)
.mutation(
async ({ ctx, input }): Promise<{ id: string; urlId: string }> => {
const adminUrlId = nanoid();
const participantUrlId = 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: 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,
})),
},
},
},
.mutation(async ({ ctx, input }) => {
const adminToken = nanoid();
const participantUrlId = nanoid();
const pollId = nanoid();
let email: string;
let name: string;
if (input.user && ctx.user.isGuest) {
email = input.user.email;
name = input.user.name;
} else {
const user = await prisma.user.findUnique({
select: { email: true, name: true },
where: { id: ctx.user.id },
});
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,
},
if (!user) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User not found",
});
}
return { id: poll.id, urlId: adminUrlId };
},
),
email = user.email;
name = user.name;
}
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,
demo: input.demo,
adminUrlId: adminToken,
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 pollLink = ctx.user.isGuest
? absoluteUrl(`/admin/${adminToken}`)
: absoluteUrl(`/poll/${pollId}`);
const participantLink = absoluteUrl(`/invite/${pollId}`);
if (email && name) {
await sendEmail("NewPollEmail", {
to: email,
subject: `Let's find a date for ${poll.title}`,
props: {
title: poll.title,
name,
adminLink: pollLink,
participantLink,
},
});
}
return { id: poll.id };
}),
update: possiblyPublicProcedure
.input(
z.object({
@ -221,10 +244,23 @@ export const polls = router({
},
});
}),
demo,
participants,
comments,
// END LEGACY ROUTES
getWatchers: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.query(async ({ input: { pollId } }) => {
return await prisma.watcher.findMany({
where: {
pollId,
},
select: {
userId: true,
},
});
}),
watch: possiblyPublicProcedure
.input(z.object({ pollId: z.string() }))
.mutation(async ({ input, ctx }) => {
@ -323,10 +359,11 @@ export const polls = router({
return res;
}),
getByParticipantUrlId: publicProcedure
get: publicProcedure
.input(
z.object({
urlId: z.string(),
adminToken: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
@ -351,6 +388,13 @@ export const polls = router({
user: true,
userId: true,
deleted: true,
event: {
select: {
start: true,
duration: true,
optionId: true,
},
},
watchers: {
select: {
userId: true,
@ -358,7 +402,7 @@ export const polls = router({
},
},
where: {
participantUrlId: input.urlId,
id: input.urlId,
},
rejectOnNotFound: false,
});
@ -370,10 +414,377 @@ export const polls = router({
});
}
if (ctx.user.id === res.userId) {
if (ctx.user.id === res.userId || res.adminUrlId === input.adminToken) {
return res;
} else {
return { ...res, adminUrlId: "" };
}
}),
transfer: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
userId: ctx.user.id,
},
});
}),
list: possiblyPublicProcedure.query(async ({ ctx }) => {
const polls = await prisma.poll.findMany({
where: {
userId: ctx.user.id,
deleted: false,
},
select: {
id: true,
title: true,
location: true,
createdAt: true,
timeZone: true,
adminUrlId: true,
participantUrlId: true,
event: {
select: {
start: true,
duration: true,
},
},
options: {
select: {
id: true,
start: true,
duration: true,
},
},
closed: true,
participants: {
select: {
id: true,
name: true,
},
orderBy: [
{
createdAt: "desc",
},
{ name: "desc" },
],
},
},
orderBy: [
{
createdAt: "desc",
},
{ title: "asc" },
],
});
return polls;
}),
book: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
optionId: z.string(),
notify: z.enum(["none", "all", "attendees"]),
}),
)
.mutation(async ({ input, ctx }) => {
if (process.env.NEXT_PUBLIC_ENABLE_FINALIZATION !== "true") {
throw new TRPCError({
code: "FORBIDDEN",
message: "This feature is not enabled",
});
}
const poll = await prisma.poll.findUnique({
where: {
id: input.pollId,
},
select: {
id: true,
timeZone: true,
title: true,
location: true,
user: {
select: {
name: true,
email: true,
},
},
participants: {
select: {
name: true,
email: 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: {
start: true,
duration: true,
},
});
if (!option) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Option not found",
});
}
const eventStart = poll.timeZone
? dayjs(option.start).utc().tz(poll.timeZone, true).toDate()
: option.start;
await prisma.event.create({
data: {
pollId: poll.id,
optionId: input.optionId,
start: eventStart,
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"),
);
let event: ics.ReturnObject;
if (option.duration > 0) {
// we need to remember to call .utc() on the dayjs() object
// to make sure we get the correct time because dayjs() will
// use the local timezone
const start = poll.timeZone
? dayjs(option.start).utc().tz(poll.timeZone, true).utc()
: dayjs(option.start).utc();
event = ics.createEvent({
title: poll.title,
start: [
start.year(),
start.month() + 1,
start.date(),
start.hour(),
start.minute(),
],
organizer: {
name: poll.user.name,
email: poll.user.email,
},
startInputType: poll.timeZone ? "utc" : "local",
duration: { minutes: option.duration },
attendees: attendees
.filter((a) => !!a.email) // remove participants without email
.map((a) => ({
name: a.name,
email: a.email ?? undefined,
})),
});
} else {
const start = dayjs(option.start);
const end = start.add(1, "day");
event = ics.createEvent({
title: poll.title,
start: [start.year(), start.month() + 1, start.date()],
end: [end.year(), end.month() + 1, end.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 formattedDate = printDate(
eventStart,
option.duration,
poll.timeZone ?? undefined,
);
// const formatDate = (
// date: Date,
// duration: number,
// timeZone?: string | null,
// ) => {
// if (duration > 0) {
// if (timeZone) {
// return `${dayjs(date)
// .utc()
// .format(
// "dddd, MMMM D, YYYY, HH:mm",
// )} (${getTimeZoneAbbreviation(timeZone)})`;
// } else {
// return dayjs(date).utc().format("dddd, MMMM D, YYYY, HH:mm");
// }
// } else {
// return dayjs(date).format("dddd, MMMM D, YYYY");
// }
// };
const participantsToEmail: Array<{ name: string; email: string }> = [];
if (input.notify === "all") {
poll.participants.forEach((p) => {
if (p.email) {
participantsToEmail.push({
name: p.name,
email: p.email,
});
}
});
}
if (input.notify === "attendees") {
attendees.forEach((p) => {
if (p.email) {
participantsToEmail.push({
name: p.name,
email: p.email,
});
}
});
}
const emailToHost = sendEmail("FinalizeHostEmail", {
subject: `Date booked for ${poll.title}`,
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: formattedDate,
},
attachments: [{ filename: "event.ics", content: event.value }],
});
const emailsToParticipants = participantsToEmail.map((p) => {
return sendEmail("FinalizeHostEmail", {
subject: `Date booked for ${poll.title}`,
to: p.email,
props: {
name: p.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: formattedDate,
},
attachments: [{ filename: "event.ics", content: event.value }],
});
});
await Promise.all([emailToHost, ...emailsToParticipants]);
}
}),
reopen: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input }) => {
await prisma.$transaction([
prisma.event.delete({
where: {
pollId: input.pollId,
},
}),
prisma.poll.update({
where: {
id: input.pollId,
},
data: {
eventId: null,
closed: false,
},
}),
]);
}),
pause: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input }) => {
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
closed: true,
},
});
}),
resume: possiblyPublicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.mutation(async ({ input }) => {
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
closed: false,
},
});
}),
});

View file

@ -48,7 +48,7 @@ export const comments = router({
poll: {
select: {
title: true,
adminUrlId: true,
id: true,
},
},
},
@ -87,7 +87,7 @@ export const comments = router({
props: {
name: watcher.user.name,
authorName,
pollUrl: absoluteUrl(`/admin/${poll.adminUrlId}`),
pollUrl: absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: absoluteUrl(
`/auth/disable-notifications?token=${token}`,
),

View file

@ -0,0 +1,45 @@
import { prisma } from "@rallly/database";
import { z } from "zod";
import { publicProcedure, router } from "../../trpc";
export const options = router({
list: publicProcedure
.input(
z.object({
pollId: z.string(),
}),
)
.query(async ({ input: { pollId } }) => {
const options = await prisma.option.findMany({
where: {
pollId,
},
select: {
id: true,
start: true,
duration: true,
},
orderBy: [
{
start: "asc",
},
],
});
return options;
}),
delete: publicProcedure
.input(
z.object({
optionId: z.string(),
}),
)
.mutation(async ({ input: { optionId } }) => {
await prisma.option.delete({
where: {
id: optionId,
},
});
}),
});

View file

@ -35,7 +35,6 @@ export const participants = router({
delete: publicProcedure
.input(
z.object({
pollId: z.string(),
participantId: z.string(),
}),
)
@ -65,7 +64,10 @@ export const participants = router({
const poll = await prisma.poll.findUnique({
where: { id: pollId },
select: { title: true, adminUrlId: true, participantUrlId: true },
select: {
id: true,
title: true,
},
});
if (!poll) {
@ -106,7 +108,7 @@ export const participants = router({
name,
title: poll.title,
editSubmissionUrl: absoluteUrl(
`/p/${poll.participantUrlId}?token=${token}`,
`/invite/${poll.id}?token=${token}`,
),
},
}),
@ -142,7 +144,7 @@ export const participants = router({
props: {
name: watcher.user.name,
participantName: participant.name,
pollUrl: absoluteUrl(`/admin/${poll.adminUrlId}`),
pollUrl: absoluteUrl(`/poll/${poll.id}`),
disableNotificationsUrl: absoluteUrl(
`/auth/disable-notifications?token=${token}`,
),

View file

@ -0,0 +1,74 @@
import { prisma } from "@rallly/database";
import z from "zod";
import { publicProcedure, router } from "../trpc";
export const userPreferences = router({
get: publicProcedure.query(async ({ ctx }) => {
if (ctx.user.isGuest) {
return ctx.user.preferences
? {
timeZone: ctx.user.preferences.timeZone ?? null,
timeFormat: ctx.user.preferences.timeFormat ?? null,
weekStart: ctx.user.preferences.weekStart ?? null,
}
: null;
} else {
return await prisma.userPreferences.findUnique({
where: {
userId: ctx.user.id,
},
select: {
timeZone: true,
weekStart: true,
timeFormat: true,
},
});
}
}),
update: publicProcedure
.input(
z.object({
timeZone: z.string().optional(),
weekStart: z.number().min(0).max(6).optional(),
timeFormat: z.enum(["hours12", "hours24"]).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (ctx.user.isGuest === false) {
await prisma.userPreferences.upsert({
where: {
userId: ctx.user.id,
},
create: {
userId: ctx.user.id,
...input,
},
update: {
...input,
},
});
} else {
ctx.session.user = {
...ctx.user,
preferences: { ...ctx.user.preferences, ...input },
};
await ctx.session.save();
}
}),
delete: publicProcedure.mutation(async ({ ctx }) => {
if (ctx.user.isGuest) {
ctx.session.user = {
...ctx.user,
preferences: undefined,
};
await ctx.session.save();
} else {
await prisma.userPreferences.delete({
where: {
userId: ctx.user.id,
},
});
}
}),
});

View file

@ -31,14 +31,13 @@ export const user = router({
changeName: publicProcedure
.input(
z.object({
userId: z.string(),
name: z.string().min(1).max(100),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
await prisma.user.update({
where: {
id: input.userId,
id: ctx.user.id,
},
data: {
name: input.name,

View file

@ -4,16 +4,20 @@ import z from "zod";
import { decryptToken } from "../../session";
import { publicProcedure, router } from "../trpc";
import { LoginTokenPayload, UserSession } from "../types";
import { LoginTokenPayload } from "../types";
export const whoami = router({
get: publicProcedure.query(async ({ ctx }): Promise<UserSession> => {
get: publicProcedure.query(async ({ ctx }) => {
if (ctx.user.isGuest) {
return { isGuest: true, id: ctx.user.id };
return { isGuest: true as const, id: ctx.user.id };
}
const user = await prisma.user.findUnique({
select: { id: true, name: true, email: true },
select: {
id: true,
name: true,
email: true,
},
where: { id: ctx.user.id },
});
@ -22,7 +26,7 @@ export const whoami = router({
throw new Error("User not found");
}
return { isGuest: false, ...user };
return { isGuest: false as const, ...user };
}),
destroy: publicProcedure.mutation(async ({ ctx }) => {
ctx.session.destroy();

View file

@ -16,13 +16,28 @@ export const publicProcedure = t.procedure;
export const middleware = t.middleware;
const checkAuthIfRequired = middleware(async ({ ctx, next }) => {
if (process.env.AUTH_REQUIRED === "true" && ctx.user.isGuest) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Login is required" });
}
return next();
});
export const possiblyPublicProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
if (process.env.AUTH_REQUIRED === "true" && ctx.user.isGuest) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required",
});
}
return next();
}),
);
export const possiblyPublicProcedure = t.procedure.use(checkAuthIfRequired);
export const privateProcedure = t.procedure.use(
middleware(async ({ ctx, next }) => {
if (ctx.user.isGuest) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Login is required",
});
}
return next();
}),
);
export const mergeRouters = t.mergeRouters;