mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-21 18:27:53 +02:00
⬆️ v3.0.0 (#704)
This commit is contained in:
parent
735056f25f
commit
c22b3abc4d
385 changed files with 19912 additions and 5250 deletions
|
@ -69,21 +69,3 @@ export const withAuth: GetServerSideProps = async (ctx) => {
|
|||
|
||||
return { props: {} };
|
||||
};
|
||||
|
||||
/**
|
||||
* Require user to be logged in if AUTH_REQUIRED is true
|
||||
* @returns
|
||||
*/
|
||||
export const withAuthIfRequired: GetServerSideProps = async (ctx) => {
|
||||
if (process.env.AUTH_REQUIRED === "true") {
|
||||
if (!ctx.req.session.user || ctx.req.session.user.isGuest) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return { props: {} };
|
||||
};
|
||||
|
|
|
@ -20,7 +20,9 @@ export const trpc = createTRPCNext<AppRouter>({
|
|||
queryClientConfig: {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
networkMode: "always",
|
||||
cacheTime: Infinity,
|
||||
staleTime: 1000 * 60,
|
||||
},
|
||||
},
|
||||
mutationCache: new MutationCache({
|
||||
|
|
|
@ -8,13 +8,15 @@ export function composeGetServerSideProps(
|
|||
for (const getServerSideProps of fns) {
|
||||
const fnRes = await getServerSideProps(ctx);
|
||||
|
||||
if ("redirect" in fnRes) {
|
||||
return fnRes;
|
||||
}
|
||||
|
||||
if ("props" in fnRes) {
|
||||
res.props = {
|
||||
...res.props,
|
||||
...fnRes.props,
|
||||
};
|
||||
} else {
|
||||
return fnRes;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,8 @@
|
|||
"@trpc/react-query": "^10.13.0",
|
||||
"@trpc/server": "^10.13.0",
|
||||
"iron-session": "^6.3.1",
|
||||
"superjson": "^1.12.2"
|
||||
"spacetime": "^7.4.4",
|
||||
"superjson": "^1.12.2",
|
||||
"timezone-soft": "^1.4.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
import { TimeFormat } from "@rallly/database";
|
||||
import { sealData, unsealData } from "iron-session";
|
||||
|
||||
import { sessionConfig } from "./session-config";
|
||||
|
||||
type UserSessionData = { id: string; isGuest: boolean };
|
||||
type UserSessionData = {
|
||||
id: string;
|
||||
isGuest: boolean;
|
||||
preferences?: {
|
||||
timeZone?: string;
|
||||
weekStart?: number;
|
||||
timeFormat?: TimeFormat;
|
||||
};
|
||||
};
|
||||
|
||||
declare module "iron-session" {
|
||||
export interface IronSessionData {
|
||||
|
@ -16,6 +25,7 @@ export const decryptToken = async <P extends Record<string, unknown>>(
|
|||
const payload = await unsealData(token, {
|
||||
password: sessionConfig.password,
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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}`,
|
||||
),
|
||||
|
|
45
packages/backend/trpc/routers/polls/options.ts
Normal file
45
packages/backend/trpc/routers/polls/options.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
|
@ -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}`,
|
||||
),
|
||||
|
|
74
packages/backend/trpc/routers/user-preferences.ts
Normal file
74
packages/backend/trpc/routers/user-preferences.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
33
packages/backend/utils/date.ts
Normal file
33
packages/backend/utils/date.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import spacetime from "spacetime";
|
||||
import soft from "timezone-soft";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export const getTimeZoneAbbreviation = (date: Date, timeZone: string) => {
|
||||
const timeZoneDisplayFormat = soft(timeZone)[0];
|
||||
const spaceTimeDate = spacetime(date, timeZone);
|
||||
const standardAbbrev = timeZoneDisplayFormat.standard.abbr;
|
||||
const dstAbbrev = timeZoneDisplayFormat.daylight?.abbr;
|
||||
const abbrev = spaceTimeDate.isDST() ? dstAbbrev : standardAbbrev;
|
||||
return abbrev;
|
||||
};
|
||||
|
||||
export const printDate = (date: Date, duration: number, timeZone?: string) => {
|
||||
if (duration === 0) {
|
||||
return dayjs(date).format("LL");
|
||||
} else if (timeZone) {
|
||||
return `${dayjs(date).tz(timeZone).format("LLL")} - ${dayjs(date)
|
||||
.add(duration, "minutes")
|
||||
.tz(timeZone)
|
||||
.format("LT")} ${getTimeZoneAbbreviation(date, timeZone)}`;
|
||||
} else {
|
||||
return `${dayjs(date).utc().format("LLL")} - ${dayjs(date)
|
||||
.utc()
|
||||
.add(duration, "minutes")
|
||||
.format("LT")}`;
|
||||
}
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue