Switch to tRPC (#173)

This commit is contained in:
Luke Vella 2022-05-18 10:22:40 +01:00 committed by GitHub
parent 3d7e7e8a95
commit 2c4157ea24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
245 changed files with 1585 additions and 1755 deletions

View file

@ -74,16 +74,17 @@ yarn start
## ⚙️ Configuration
| Parameter | Default | Description |
| --------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| DATABASE_URL | postgres://postgres:postgres@rallly_db:5432/db | A postgres database URL. Leave out if using the docker-compose file since it will spin up and connect to its own database instance. |
| SECRET_PASSWORD | - | A long string (minimum 32 characters) that is used to encrypt session data. |
| SUPPORT_EMAIL | - | An email address that will appear as the FROM email for all emails being sent out. |
| SMTP_HOST | - | Host name of your SMTP server |
| SMTP_PORT | - | Port of your SMTP server |
| SMTP_SECURE | false | Set to "true" if SSL is enabled for your SMTP connection |
| SMTP_USER | - | Username to use for your SMTP connection |
| SMTP_PWD | - | Password to use for your SMTP connection |
| Parameter | Default | Description |
| -------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| NEXT_PUBLIC_BASE_URL | http://localhost:3000 | The hosting url of the server, used for creating links and making api calls from the client. |
| DATABASE_URL | postgres://postgres:postgres@rallly_db:5432/db | A postgres database URL. Leave out if using the docker-compose file since it will spin up and connect to its own database instance. |
| SECRET_PASSWORD | - | A long string (minimum 32 characters) that is used to encrypt session data. |
| SUPPORT_EMAIL | - | An email address that will appear as the FROM email for all emails being sent out. |
| SMTP_HOST | - | Host name of your SMTP server |
| SMTP_PORT | - | Port of your SMTP server |
| SMTP_SECURE | false | Set to "true" if SSL is enabled for your SMTP connection |
| SMTP_USER | - | Username to use for your SMTP connection |
| SMTP_PWD | - | Password to use for your SMTP connection |
## 👨‍💻 Contributors

View file

@ -1,23 +0,0 @@
import { Participant, Vote, VoteType } from "@prisma/client";
import axios from "axios";
export interface AddParticipantPayload {
pollId: string;
name: string;
votes: Array<{ optionId: string; type: VoteType }>;
}
export type AddParticipantResponse = Participant & {
votes: Vote[];
};
export const addParticipant = async (
payload: AddParticipantPayload,
): Promise<AddParticipantResponse> => {
const res = await axios.post<AddParticipantResponse>(
`/api/poll/${payload.pollId}/participant`,
payload,
);
return res.data;
};

View file

@ -1,18 +0,0 @@
import { Comment } from "@prisma/client";
import axios from "axios";
export interface CreateCommentPayload {
pollId: string;
content: string;
authorName: string;
}
export const createComment = async (
payload: CreateCommentPayload,
): Promise<Comment> => {
const { data } = await axios.post<Comment>(
`/api/poll/${payload.pollId}/comments`,
payload,
);
return data;
};

View file

@ -1,7 +0,0 @@
import { Poll } from "@prisma/client";
import axios from "axios";
export const createDemo = async (): Promise<Poll> => {
const { data } = await axios.post<Poll>("/api/poll/demo");
return data;
};

View file

@ -1,21 +0,0 @@
import { Poll } from "@prisma/client";
import axios from "axios";
export interface CreatePollPayload {
title: string;
type: "date";
timeZone?: string;
location?: string;
description?: string;
user: {
name: string;
email: string;
};
options: string[];
demo?: boolean;
}
export const createPoll = async (payload: CreatePollPayload): Promise<Poll> => {
const { data } = await axios.post<Poll>("/api/poll", payload);
return data;
};

View file

@ -1,17 +0,0 @@
import axios from "axios";
export interface DeleteParticipantPayload {
pollId: string;
participantId: string;
}
export const deleteParticipant = async (
payload: DeleteParticipantPayload,
): Promise<void> => {
try {
const { pollId, participantId } = payload;
await axios.delete(`/api/poll/${pollId}/participant/${participantId}`);
} catch (err) {
throw err;
}
};

View file

@ -1,14 +0,0 @@
import { Link, Option, Participant, Poll, User, Vote } from "@prisma/client";
export interface GetPollApiResponse extends Omit<Poll, "verificationCode"> {
options: Option[];
participants: Array<Participant & { votes: Vote[] }>;
user: User;
role: "admin" | "participant";
links: Array<Link>;
pollId: string;
}
export interface GetPollResponse extends Omit<GetPollApiResponse, "createdAt"> {
createdAt: string;
}

View file

@ -1,20 +0,0 @@
import { Participant, Vote, VoteType } from "@prisma/client";
import axios from "axios";
export interface UpdateParticipantPayload {
pollId: string;
participantId: string;
name: string;
votes: Array<{ optionId: string; type: VoteType }>;
}
export const updateParticipant = async (
payload: UpdateParticipantPayload,
): Promise<Participant & { votes: Vote[] }> => {
const { pollId, participantId, ...body } = payload;
const res = await axios.patch<Participant & { votes: Vote[] }>(
`/api/poll/${pollId}/participant/${participantId}`,
body,
);
return res.data;
};

View file

@ -1,25 +0,0 @@
import { Poll } from "@prisma/client";
import axios from "axios";
export interface UpdatePollPayload {
title?: string;
timeZone?: string;
location?: string;
description?: string;
optionsToDelete?: string[];
optionsToAdd?: string[];
notifications?: boolean;
closed?: boolean;
}
export const updatePoll = async (
urlId: string,
payload: UpdatePollPayload,
): Promise<Poll> => {
try {
const { data } = await axios.patch<Poll>(`/api/poll/${urlId}`, payload);
return data;
} catch (err) {
throw err;
}
};

View file

@ -1,145 +0,0 @@
import { updatePoll, UpdatePollPayload } from "api-client/update-poll";
import { usePlausible } from "next-plausible";
import { useMutation, useQueryClient } from "react-query";
import { addParticipant } from "../../api-client/add-participant";
import {
deleteParticipant,
DeleteParticipantPayload,
} from "../../api-client/delete-participant";
import { GetPollResponse } from "../../api-client/get-poll";
import { updateParticipant } from "../../api-client/update-participant";
import { usePoll } from "../poll-context";
import { useSession } from "../session";
import { ParticipantForm } from "./types";
export const useAddParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const session = useSession();
const plausible = usePlausible();
const { options } = usePoll();
return useMutation(
(payload: ParticipantForm) =>
addParticipant({
pollId,
name: payload.name.trim(),
votes: options.map(
(option, i) =>
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
),
}),
{
onSuccess: (participant) => {
plausible("Add participant");
queryClient.setQueryData<GetPollResponse>(
["getPoll", pollId],
(poll) => {
if (!poll) {
throw new Error(
"Tried to update poll but no result found in query cache",
);
}
poll.participants = [participant, ...poll.participants];
return poll;
},
);
session.refresh();
},
},
);
};
export const useUpdateParticipantMutation = (pollId: string) => {
const queryClient = useQueryClient();
const plausible = usePlausible();
const { options } = usePoll();
return useMutation(
(payload: ParticipantForm & { participantId: string }) =>
updateParticipant({
pollId,
participantId: payload.participantId,
name: payload.name.trim(),
votes: options.map(
(option, i) =>
payload.votes[i] ?? { optionId: option.optionId, type: "no" },
),
}),
{
onSuccess: (participant) => {
plausible("Update participant");
queryClient.setQueryData<GetPollResponse>(
["getPoll", pollId],
(poll) => {
if (!poll) {
throw new Error(
"Tried to update poll but no result found in query cache",
);
}
poll.participants = poll.participants.map((p) =>
p.id === participant.id ? participant : p,
);
return poll;
},
);
},
onSettled: () => {
queryClient.invalidateQueries(["getPoll", pollId]);
},
},
);
};
export const useDeleteParticipantMutation = () => {
const queryClient = useQueryClient();
const plausible = usePlausible();
const { poll } = usePoll();
return useMutation(
(payload: DeleteParticipantPayload) => deleteParticipant(payload),
{
onMutate: ({ participantId }) => {
queryClient.setQueryData<GetPollResponse>(
["getPoll", poll.urlId],
(poll) => {
if (!poll) {
throw new Error(
"Tried to update poll but no result found in query cache",
);
}
poll.participants = poll.participants.filter(
({ id }) => id !== participantId,
);
return poll;
},
);
},
onSuccess: () => {
plausible("Remove participant");
},
onSettled: (_data, _error, { pollId }) => {
queryClient.invalidateQueries(["getPoll", pollId]);
},
},
);
};
export const useUpdatePollMutation = () => {
const { poll } = usePoll();
const plausible = usePlausible();
const queryClient = useQueryClient();
return useMutation(
(payload: UpdatePollPayload) => updatePoll(poll.urlId, payload),
{
onSuccess: (data) => {
queryClient.setQueryData(["getPoll", poll.urlId], data);
plausible("Updated poll");
},
},
);
};

View file

@ -1,63 +0,0 @@
import axios from "axios";
import clsx from "clsx";
import * as React from "react";
import { useForm } from "react-hook-form";
import { validEmail } from "utils/form-validation";
import Button from "@/components/button";
const GuestSession: React.VoidFunctionComponent = () => {
const { handleSubmit, register, formState, getValues } = useForm<{
email: string;
}>({
defaultValues: {
email: "",
},
});
return (
<div className="card border-amber-500 ring-2 ring-amber-500/20">
<h2>Guest session</h2>
<p>
Guest sessions allow us to remember your device so that you can edit
your votes and comments later. However, these sessions are temporary and
when they end, cannot be resumed.{" "}
<a href="">Read more about guest sessions.</a>
</p>
<p>Login with your email to make sure you don&apos;t lose access:</p>
{formState.submitCount > 0 ? (
<div>
An email has been sent to <strong>{getValues("email")}</strong>.
Please check your inbox.
</div>
) : (
<form
onSubmit={handleSubmit(({ email }) => {
axios.post("/api/login", { email });
})}
>
<input
{...register("email", {
validate: validEmail,
})}
className={clsx("input w-full", {
"input-error": formState.errors.email,
})}
placeholder="Email address"
/>
{formState.errors.email ? (
<div className="mt-1 text-sm text-red-500">
Please enter a valid email address
</div>
) : null}
<div className="mt-4 flex space-x-3">
<Button htmlType="submit" type="primary">
Login
</Button>
</div>
</form>
)}
</div>
);
};
export default GuestSession;

View file

@ -4,6 +4,7 @@ declare global {
DATABASE_URL: string;
NODE_ENV: "development" | "production";
SECRET_PASSWORD: string;
NEXT_PUBLIC_LEGACY_POLLS?: string;
NEXT_PUBLIC_MAINTENANCE_MODE?: string;
PLAUSIBLE_DOMAIN?: string;
NEXT_PUBLIC_CRISP_WEBSITE_ID?: string;

View file

@ -1,8 +1,8 @@
import "react-i18next";
import app from "../public/locales/en/app.json";
import homepage from "../public/locales/en/homepage.json";
import support from "../public/locales/en/support.json";
import app from "~/public/locales/en/app.json";
import homepage from "~/public/locales/en/homepage.json";
import support from "~/public/locales/en/support.json";
declare module "next-i18next" {
interface Resources {

View file

@ -16,11 +16,15 @@
"@floating-ui/react-dom-interactions": "^0.4.0",
"@headlessui/react": "^1.5.0",
"@next/bundle-analyzer": "^12.1.0",
"@prisma/client": "^3.13.0",
"@prisma/client": "^3.14.0",
"@sentry/nextjs": "^6.19.3",
"@svgr/webpack": "^6.2.1",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.2",
"@trpc/client": "^9.23.2",
"@trpc/next": "^9.23.2",
"@trpc/react": "^9.23.2",
"@trpc/server": "^9.23.2",
"axios": "^0.24.0",
"clsx": "^1.1.1",
"date-fns": "^2.28.0",
@ -37,7 +41,7 @@
"next-i18next": "^10.5.0",
"next-plausible": "^3.1.9",
"nodemailer": "^6.7.2",
"prisma": "^3.13.0",
"prisma": "^3.14.0",
"react": "17.0.2",
"react-big-calendar": "^0.38.9",
"react-dom": "17.0.2",
@ -50,8 +54,10 @@
"react-use": "^17.3.2",
"smoothscroll-polyfill": "^0.4.4",
"spacetime": "^7.1.2",
"superjson": "^1.9.1",
"timezone-soft": "^1.3.1",
"typescript": "^4.5.2"
"typescript": "^4.5.2",
"zod": "^3.16.0"
},
"devDependencies": {
"@playwright/test": "^1.20.1",

View file

@ -1,66 +0,0 @@
import "react-big-calendar/lib/css/react-big-calendar.css";
import "tailwindcss/tailwind.css";
import "../style.css";
import axios from "axios";
import { NextPage } from "next";
import { AppProps } from "next/app";
import dynamic from "next/dynamic";
import Head from "next/head";
import { appWithTranslation } from "next-i18next";
import PlausibleProvider from "next-plausible";
import toast, { Toaster } from "react-hot-toast";
import { MutationCache, QueryClient, QueryClientProvider } from "react-query";
import Maintenance from "@/components/maintenance";
import ModalProvider from "@/components/modal/modal-provider";
import PreferencesProvider from "@/components/preferences/preferences-provider";
const CrispChat = dynamic(() => import("@/components/crisp-chat"), {
ssr: false,
});
const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 500) {
toast.error(
"Uh oh! Something went wrong. The issue has been logged and we'll fix it as soon as possible. Please try again later.",
);
}
},
}),
});
const MyApp: NextPage<AppProps> = ({ Component, pageProps }) => {
if (process.env.NEXT_PUBLIC_MAINTENANCE_MODE === "1") {
return <Maintenance />;
}
return (
<PlausibleProvider
domain="rallly.co"
customDomain={process.env.PLAUSIBLE_DOMAIN}
trackOutboundLinks={true}
selfHosted={true}
enabled={!!process.env.PLAUSIBLE_DOMAIN}
>
<PreferencesProvider>
<QueryClientProvider client={queryClient}>
<Head>
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
</Head>
<CrispChat />
<Toaster />
<ModalProvider>
<Component {...pageProps} />
</ModalProvider>
</QueryClientProvider>
</PreferencesProvider>
</PlausibleProvider>
);
};
export default appWithTranslation(MyApp);

View file

@ -1,35 +0,0 @@
import absoluteUrl from "utils/absolute-url";
import { sendEmailTemplate } from "utils/api-utils";
import { createToken, withSessionRoute } from "utils/auth";
export default withSessionRoute(async (req, res) => {
switch (req.method) {
case "POST": {
const email = req.body.email;
const homePageUrl = absoluteUrl(req).origin;
const token = await createToken({
email,
guestId: req.session.user?.isGuest ? req.session.user.id : undefined,
path: req.body.path,
});
const loginUrl = `${homePageUrl}/login?code=${token}`;
await sendEmailTemplate({
templateName: "login",
to: email,
subject: "Rallly - Login",
templateVars: {
loginUrl,
homePageUrl,
},
});
res.end();
return;
}
default:
res.status(405);
return;
}
});

View file

@ -1,6 +0,0 @@
import { withSessionRoute } from "utils/auth";
export default withSessionRoute((req, res) => {
req.session.destroy();
res.send({ ok: true });
});

View file

@ -1,108 +0,0 @@
import { sendEmailTemplate } from "utils/api-utils";
import { createToken, withSessionRoute } from "utils/auth";
import { nanoid } from "utils/nanoid";
import { CreatePollPayload } from "../../api-client/create-poll";
import { prisma } from "../../db";
import absoluteUrl from "../../utils/absolute-url";
export default withSessionRoute(async (req, res) => {
switch (req.method) {
case "POST": {
const adminUrlId = await nanoid();
const payload: CreatePollPayload = req.body;
const poll = await prisma.poll.create({
data: {
urlId: await nanoid(),
title: payload.title,
type: payload.type,
timeZone: payload.timeZone,
location: payload.location,
description: payload.description,
authorName: payload.user.name,
demo: payload.demo,
verified:
req.session.user?.isGuest === false &&
req.session.user.email === payload.user.email,
user: {
connectOrCreate: {
where: {
email: payload.user.email,
},
create: {
id: await nanoid(),
...payload.user,
},
},
},
options: {
createMany: {
data: payload.options.map((value) => ({
value,
})),
},
},
links: {
createMany: {
data: [
{
urlId: adminUrlId,
role: "admin",
},
{
urlId: await nanoid(),
role: "participant",
},
],
},
},
},
});
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${adminUrlId}`;
try {
if (poll.verified) {
await sendEmailTemplate({
templateName: "new-poll-verified",
to: payload.user.email,
subject: `Rallly: ${poll.title}`,
templateVars: {
title: poll.title,
name: payload.user.name,
pollUrl,
homePageUrl,
supportEmail: process.env.SUPPORT_EMAIL,
},
});
} else {
const verificationCode = await createToken({
pollId: poll.urlId,
});
const verifyEmailUrl = `${pollUrl}?code=${verificationCode}`;
await sendEmailTemplate({
templateName: "new-poll",
to: payload.user.email,
subject: `Rallly: ${poll.title} - Verify your email address`,
templateVars: {
title: poll.title,
name: payload.user.name,
pollUrl,
verifyEmailUrl,
homePageUrl,
supportEmail: process.env.SUPPORT_EMAIL,
},
});
}
} catch (e) {
console.error(e);
}
return res.json({ urlId: adminUrlId, authorName: poll.authorName });
}
default:
}
});

View file

@ -1,148 +0,0 @@
import { GetPollApiResponse } from "api-client/get-poll";
import { resetDates } from "utils/legacy-utils";
import { UpdatePollPayload } from "../../../api-client/update-poll";
import { prisma } from "../../../db";
import { withLink } from "../../../utils/api-utils";
export default withLink<
GetPollApiResponse | { status: number; message: string }
>(async ({ req, res, link }) => {
const pollId = link.pollId;
switch (req.method) {
case "GET": {
const poll = await prisma.poll.findUnique({
where: {
urlId: pollId,
},
include: {
options: {
orderBy: {
value: "asc",
},
},
participants: {
include: {
votes: true,
},
orderBy: [
{
createdAt: "desc",
},
{ name: "desc" },
],
},
user: true,
links: link.role === "admin",
},
});
if (!poll) {
return res.status(404).json({ status: 404, message: "Poll not found" });
}
if (
poll.legacy &&
// has converted options without timezone
poll.options.every(({ value }) => value.indexOf("T") === -1)
) {
// We need to reset the dates for polls that lost their timezone data because some users
// of the old version will end up seeing the wrong dates
const fixedPoll = await resetDates(poll.urlId);
if (fixedPoll) {
return res.json({
...fixedPoll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
}
}
return res.json({
...poll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
}
case "PATCH": {
if (link.role !== "admin") {
return res
.status(401)
.json({ status: 401, message: "Permission denied" });
}
const payload: Partial<UpdatePollPayload> = req.body;
if (payload.optionsToDelete && payload.optionsToDelete.length > 0) {
await prisma.option.deleteMany({
where: {
pollId,
id: {
in: payload.optionsToDelete,
},
},
});
}
if (payload.optionsToAdd && payload.optionsToAdd.length > 0) {
await prisma.option.createMany({
data: payload.optionsToAdd.map((optionValue) => ({
value: optionValue,
pollId,
})),
});
}
const poll = await prisma.poll.update({
where: {
urlId: pollId,
},
data: {
title: payload.title,
location: payload.location,
description: payload.description,
timeZone: payload.timeZone,
notifications: payload.notifications,
closed: payload.closed,
},
include: {
options: {
orderBy: {
value: "asc",
},
},
participants: {
include: {
votes: true,
},
orderBy: [
{
createdAt: "desc",
},
{ name: "desc" },
],
},
user: true,
links: true,
},
});
if (!poll) {
return res.status(404).json({ status: 404, message: "Poll not found" });
}
return res.json({
...poll,
role: link.role,
urlId: link.urlId,
pollId: poll.urlId,
});
}
default:
return res
.status(405)
.json({ status: 405, message: "Method not allowed" });
}
});

View file

@ -1,56 +0,0 @@
import { createGuestUser, withSessionRoute } from "utils/auth";
import { prisma } from "../../../../db";
import { sendNotification, withLink } from "../../../../utils/api-utils";
export default withSessionRoute(
withLink(async ({ req, res, link }) => {
switch (req.method) {
case "GET": {
const comments = await prisma.comment.findMany({
where: {
pollId: link.pollId,
},
orderBy: [
{
createdAt: "asc",
},
],
});
return res.json({ comments });
}
case "POST": {
if (!req.session.user) {
await createGuestUser(req);
}
const newComment = await prisma.comment.create({
data: {
content: req.body.content,
pollId: link.pollId,
authorName: req.body.authorName,
userId: req.session.user?.isGuest
? undefined
: req.session.user?.id,
guestId: req.session.user?.isGuest
? req.session.user.id
: undefined,
},
});
await sendNotification(req, link.pollId, {
type: "newComment",
authorName: newComment.authorName,
});
return res.json(newComment);
}
default:
return res
.status(405)
.json({ status: 405, message: "Method not allowed" });
}
}),
);

View file

@ -1,36 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "../../../../../db";
import { getQueryParam } from "../../../../../utils/api-utils";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
const urlId = getQueryParam(req, "urlId");
const commentId = getQueryParam(req, "commentId");
const link = await prisma.link.findUnique({ where: { urlId } });
if (!link) {
return res.status(404).end();
}
switch (req.method) {
case "DELETE":
await prisma.comment.delete({
where: {
id_pollId: {
id: commentId,
pollId: link.pollId,
},
},
});
return res.end();
default:
return res
.status(405)
.json({ status: 405, message: "Method not allowed" });
}
}

View file

@ -1,55 +0,0 @@
import { createGuestUser, withSessionRoute } from "utils/auth";
import { AddParticipantPayload } from "../../../../api-client/add-participant";
import { prisma } from "../../../../db";
import { sendNotification, withLink } from "../../../../utils/api-utils";
export default withSessionRoute(
withLink(async ({ req, res, link }) => {
switch (req.method) {
case "POST": {
const payload: AddParticipantPayload = req.body;
if (!req.session.user) {
await createGuestUser(req);
}
const participant = await prisma.participant.create({
data: {
pollId: link.pollId,
name: payload.name,
userId:
req.session.user?.isGuest === false
? req.session.user.id
: undefined,
guestId:
req.session.user?.isGuest === true
? req.session.user.id
: undefined,
votes: {
createMany: {
data: payload.votes.map(({ optionId, type }) => ({
optionId,
type,
pollId: link.pollId,
})),
},
},
},
include: {
votes: true,
},
});
await sendNotification(req, link.pollId, {
type: "newParticipant",
participantName: participant.name,
});
return res.json(participant);
}
default:
return res.status(405).json({ ok: 1 });
}
}),
);

View file

@ -1,57 +0,0 @@
import { UpdateParticipantPayload } from "api-client/update-participant";
import { prisma } from "../../../../../db";
import { getQueryParam, withLink } from "../../../../../utils/api-utils";
export default withLink(async ({ req, res, link }) => {
const participantId = getQueryParam(req, "participantId");
const pollId = link.pollId;
switch (req.method) {
case "PATCH":
const payload: UpdateParticipantPayload = req.body;
const participant = await prisma.participant.update({
where: {
id_pollId: {
id: participantId,
pollId,
},
},
data: {
votes: {
deleteMany: {
pollId,
},
createMany: {
data: payload.votes.map(({ optionId, type }) => ({
optionId,
type,
pollId,
})),
},
},
name: req.body.name,
},
include: {
votes: true,
},
});
return res.json(participant);
case "DELETE":
await prisma.participant.delete({
where: {
id_pollId: {
id: participantId,
pollId,
},
},
});
return res.end();
default:
return res.status(405);
}
});

View file

@ -1,109 +0,0 @@
import absoluteUrl from "utils/absolute-url";
import {
createToken,
decryptToken,
mergeGuestsIntoUser as mergeUsersWithGuests,
withSessionRoute,
} from "utils/auth";
import { prisma } from "../../../../db";
import { sendEmailTemplate, withLink } from "../../../../utils/api-utils";
export default withSessionRoute(
withLink(async ({ req, res, link }) => {
if (req.method === "POST") {
if (link.role !== "admin") {
return res
.status(401)
.json({ status: 401, message: "Only admins can verify polls" });
}
const verificationCode = req.body ? req.body.verificationCode : undefined;
if (!verificationCode) {
const poll = await prisma.poll.findUnique({
where: {
urlId: link.pollId,
},
include: {
user: true,
},
});
if (!poll) {
return res
.status(404)
.json({ status: 404, message: "Poll not found" });
}
const homePageUrl = absoluteUrl(req).origin;
const pollUrl = `${homePageUrl}/admin/${link.urlId}`;
const token = await createToken({
pollId: link.pollId,
});
const verifyEmailUrl = `${pollUrl}?code=${token}`;
await sendEmailTemplate({
templateName: "new-poll",
to: poll.user.email,
subject: `Rallly: ${poll.title} - Verify your email address`,
templateVars: {
title: poll.title,
name: poll.user.name,
pollUrl,
verifyEmailUrl,
homePageUrl,
supportEmail: process.env.SUPPORT_EMAIL,
},
});
return res.send("ok");
}
try {
const { pollId } = await decryptToken<{
pollId: string;
}>(verificationCode);
if (pollId !== link.pollId) {
res.status(401).json({
status: 401,
message: "Invalid token",
});
return;
}
const poll = await prisma.poll.update({
where: {
urlId: pollId,
},
data: {
verified: true,
},
include: { user: true },
});
// If logged in as guest, we update all participants
// and comments by this guest to the user that we just authenticated
if (req.session.user?.isGuest) {
await mergeUsersWithGuests(poll.user.id, [req.session.user.id]);
}
req.session.user = {
id: poll.user.id,
isGuest: false,
name: poll.user.name,
email: poll.user.email,
};
await req.session.save();
return res.send("ok");
} catch (e) {
console.error(e);
return res
.status(500)
.json({ status: 500, message: "Could not verify poll" });
}
}
return res
.status(405)
.json({ status: 405, message: "Invalid http method" });
}),
);

View file

@ -1,135 +0,0 @@
import { VoteType } from "@prisma/client";
import { addMinutes } from "date-fns";
import { NextApiRequest, NextApiResponse } from "next";
import absoluteUrl from "utils/absolute-url";
import { nanoid } from "utils/nanoid";
import { prisma } from "../../../db";
const participantData: Array<{ name: string; votes: VoteType[] }> = [
{
name: "Reed",
votes: ["yes", "no", "ifNeedBe", "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 default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
switch (req.method) {
case "POST": {
const adminUrlId = await nanoid();
const demoUser = { name: "John Example", email: "noreply@rallly.co" };
const today = new Date();
const options: Array<{ value: string; id: string }> = [];
for (let i = 0; i < optionValues.length; i++) {
options.push({ id: await nanoid(), value: optionValues[i] });
}
const participants: Array<{
name: string;
id: string;
guestId: 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,
guestId: "user-demo",
createdAt: addMinutes(today, i * -1),
});
options.forEach((option, index) => {
votes.push({
optionId: option.id,
participantId,
type: participantVotes[index],
});
});
}
const homePageUrl = absoluteUrl(req).origin;
await prisma.poll.create({
data: {
urlId: await nanoid(),
title: "Lunch Meeting Demo",
type: "date",
location: "Starbucks, 901 New York Avenue",
description: `This poll has been automatically generated just for you! Feel free to try out all the different features and when you're ready, you can go to ${homePageUrl}/new to make a new poll.`,
authorName: "Johnny",
verified: true,
demo: true,
user: {
connectOrCreate: {
where: {
email: demoUser.email,
},
create: demoUser,
},
},
options: {
createMany: {
data: options,
},
},
links: {
createMany: {
data: [
{
role: "admin",
urlId: adminUrlId,
},
{
role: "participant",
urlId: await nanoid(),
},
],
},
},
participants: {
createMany: {
data: participants,
},
},
votes: {
createMany: {
data: votes,
},
},
},
});
return res.json({ urlId: adminUrlId });
}
default:
return res.status(405).json({ ok: 1 });
}
}

View file

@ -1,20 +0,0 @@
import { withSessionRoute } from "utils/auth";
import { prisma } from "../../db";
export default withSessionRoute(async (req, res) => {
if (req.session.user?.isGuest === false) {
const user = await prisma.user.findUnique({
where: { id: req.session.user.id },
});
res.json({
user: user
? { id: user.id, name: user.name, email: user.email, isGuest: false }
: null,
});
return;
}
res.json({ user: req.session.user ?? null });
});

View file

@ -1,107 +0,0 @@
import axios from "axios";
import { GetServerSideProps, NextPage } from "next";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { usePlausible } from "next-plausible";
import React from "react";
import { useQuery } from "react-query";
import { withSessionSsr } from "utils/auth";
import ErrorPage from "@/components/error-page";
import FullPageLoader from "@/components/full-page-loader";
import { PollContextProvider } from "@/components/poll-context";
import { SessionProps, withSession } from "@/components/session";
import { GetPollResponse } from "../api-client/get-poll";
import Custom404 from "./404";
const PollPage = dynamic(() => import("@/components/poll"), { ssr: false });
const PollPageLoader: NextPage<SessionProps> = () => {
const { query } = useRouter();
const { t } = useTranslation("app");
const urlId = query.urlId as string;
const [didError, setDidError] = React.useState(false);
const [didFailToConvertLegacyPoll, setDidFailToConvertLegacyPoll] =
React.useState(false);
const plausible = usePlausible();
const { data: poll } = useQuery<GetPollResponse | null>(
["getPoll", urlId],
async () => {
try {
const { data } = await axios.get<GetPollResponse>(`/api/poll/${urlId}`);
return data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
try {
// check if poll exists from the legacy endpoint and convert it if it does
const { data } = await axios.get<GetPollResponse>(
`/api/legacy/${urlId}`,
);
plausible("Converted legacy event");
return data;
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 500) {
// failed to convert legacy poll
setDidFailToConvertLegacyPoll(true);
}
throw err;
}
} else {
throw error;
}
}
},
{
onError: () => {
setDidError(true);
},
retry: false,
},
);
if (didFailToConvertLegacyPoll) {
return (
<ErrorPage
title="Server error"
description="There was a problem retrieving your poll. Please contact support."
/>
);
}
if (poll) {
return (
<PollContextProvider value={poll}>
<PollPage />
</PollContextProvider>
);
}
if (didError) {
return <Custom404 />;
}
return <FullPageLoader>{t("loading")}</FullPageLoader>;
};
export const getServerSideProps: GetServerSideProps = withSessionSsr(
async ({ locale = "en", req }) => {
try {
return {
props: {
...(await serverSideTranslations(locale, ["app"])),
user: req.session.user ?? null,
},
};
} catch {
return { notFound: true };
}
},
);
export default withSession(PollPageLoader);

View file

View file

@ -36,6 +36,7 @@
"creatingDemo": "Creating demo poll…",
"ok": "Ok",
"loading": "Loading…",
"loadingParticipants": "Loading participants…",
"admin": "Admin",
"adminDescription": "Full access to edit this poll.",
"participant": "Participant",

View file

@ -1,3 +1,4 @@
NEXT_PUBLIC_BASE_URL=http://localhost:3000
DATABASE_URL=postgres://your-database/db
SECRET_PASSWORD=minimum-32-characters
SUPPORT_EMAIL=foo@yourdomain.com

View file

@ -1,4 +0,0 @@
defaults.url=https://sentry.io/
defaults.org=stack-snap
defaults.project=rallly
cli.executable=../../.npm/_npx/a8388072043b4cbc/node_modules/@sentry/cli/bin/sentry-cli

View file

@ -3,7 +3,8 @@ import Cookies from "js-cookie";
import Link from "next/link";
import * as React from "react";
import ReactDOM from "react-dom";
import { getPortal } from "utils/selectors";
import { getPortal } from "@/utils/selectors";
import CookiesIllustration from "./cookie-consent/cookies.svg";

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -4,11 +4,11 @@ import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { usePlausible } from "next-plausible";
import React from "react";
import { useMutation } from "react-query";
import { useSessionStorage } from "react-use";
import { createPoll } from "../api-client/create-poll";
import Button from "../components/button";
import { encodeDateOption } from "../utils/date-time-utils";
import { trpc } from "../utils/trpc";
import Button from "./button";
import {
NewEventData,
PollDetailsData,
@ -17,11 +17,10 @@ import {
PollOptionsForm,
UserDetailsData,
UserDetailsForm,
} from "../components/forms";
import StandardLayout from "../components/standard-layout";
import Steps from "../components/steps";
import { encodeDateOption } from "../utils/date-time-utils";
} from "./forms";
import { SessionProps, useSession, withSession } from "./session";
import StandardLayout from "./standard-layout";
import Steps from "./steps";
type StepName = "eventDetails" | "options" | "userDetails";
@ -95,41 +94,23 @@ const Page: NextPage<CreatePollPageProps> = ({
const plausible = usePlausible();
const { mutate: createEventMutation, isLoading: isCreatingPoll } =
useMutation(
() => {
const title = required(formData?.eventDetails?.title);
return createPoll({
title: title,
type: "date",
location: formData?.eventDetails?.location,
description: formData?.eventDetails?.description,
user: {
name: required(formData?.userDetails?.name),
email: required(formData?.userDetails?.contact),
},
timeZone: formData?.options?.timeZone,
options: required(formData?.options?.options).map(encodeDateOption),
});
},
{
onSuccess: (poll) => {
setIsRedirecting(true);
plausible("Created poll", {
props: {
numberOfOptions: formData.options?.options?.length,
optionsView: formData?.options?.view,
},
});
setPersistedFormData(initialNewEventData);
router.replace(`/admin/${poll.urlId}`);
const createPoll = trpc.useMutation(["polls.create"], {
onSuccess: (poll) => {
setIsRedirecting(true);
plausible("Created poll", {
props: {
numberOfOptions: formData.options?.options?.length,
optionsView: formData?.options?.view,
},
},
);
});
setPersistedFormData(initialNewEventData);
router.replace(`/admin/${poll.urlId}`);
},
});
const isBusy = isRedirecting || isCreatingPoll;
const isBusy = isRedirecting || createPoll.isLoading;
const handleSubmit = (
const handleSubmit = async (
data: PollDetailsData | PollOptionsData | UserDetailsData,
) => {
if (currentStepIndex < steps.length - 1) {
@ -140,7 +121,20 @@ const Page: NextPage<CreatePollPageProps> = ({
});
} else {
// last step
createEventMutation();
const title = required(formData?.eventDetails?.title);
await createPoll.mutateAsync({
title: title,
type: "date",
location: formData?.eventDetails?.location,
description: formData?.eventDetails?.description,
user: {
name: required(formData?.userDetails?.name),
email: required(formData?.userDetails?.contact),
},
timeZone: formData?.options?.timeZone,
options: required(formData?.options?.options).map(encodeDateOption),
});
}
};

View file

@ -1,18 +1,12 @@
import { Comment } from "@prisma/client";
import axios from "axios";
import clsx from "clsx";
import { formatRelative } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { usePlausible } from "next-plausible";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
createComment,
CreateCommentPayload,
} from "../../api-client/create-comment";
import { requiredString } from "../../utils/form-validation";
import { trpc } from "../../utils/trpc";
import Button from "../button";
import CompactButton from "../compact-button";
import Dropdown, { DropdownItem } from "../dropdown";
@ -25,29 +19,20 @@ import { usePoll } from "../poll-context";
import { usePreferences } from "../preferences/use-preferences";
import { isUnclaimed, useSession } from "../session";
export interface DiscussionProps {
pollId: string;
}
interface CommentForm {
authorName: string;
content: string;
}
const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
pollId,
}) => {
const Discussion: React.VoidFunctionComponent = () => {
const { locale } = usePreferences();
const getCommentsQueryKey = ["poll", pollId, "comments"];
const queryClient = useQueryClient();
const { data: comments } = useQuery(
getCommentsQueryKey,
async () => {
const res = await axios.get<{
comments: Array<Omit<Comment, "createdAt"> & { createdAt: string }>;
}>(`/api/poll/${pollId}/comments`);
return res.data.comments;
},
const queryClient = trpc.useContext();
const {
poll: { pollId },
} = usePoll();
const { data: comments } = trpc.useQuery(
["polls.comments.list", { pollId }],
{
refetchInterval: 10000, // refetch every 10 seconds
},
@ -55,44 +40,38 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
const plausible = usePlausible();
const { mutate: createCommentMutation } = useMutation(
(payload: CreateCommentPayload) => {
// post comment
return createComment(payload);
const addComment = trpc.useMutation("polls.comments.add", {
onSuccess: (newComment) => {
session.refresh();
queryClient.setQueryData(
["polls.comments.list", { pollId }],
(existingComments = []) => {
return [...existingComments, newComment];
},
);
plausible("Created comment");
},
{
onSuccess: (newComment) => {
session.refresh();
queryClient.setQueryData(getCommentsQueryKey, (comments) => {
if (Array.isArray(comments)) {
return [...comments, newComment];
}
return [newComment];
});
plausible("Created comment");
},
},
);
});
const { poll } = usePoll();
const { mutate: deleteCommentMutation } = useMutation(
async (payload: { pollId: string; commentId: string }) => {
await axios.delete(`/api/poll/${pollId}/comments/${payload.commentId}`);
const deleteComment = trpc.useMutation("polls.comments.delete", {
onMutate: ({ commentId }) => {
queryClient.setQueryData(
["polls.comments.list", { pollId }],
(existingComments = []) => {
return [...existingComments].filter(({ id }) => id !== commentId);
},
);
},
{
onMutate: () => {
plausible("Deleted comment");
},
onSuccess: () => {
queryClient.invalidateQueries(getCommentsQueryKey);
},
onSuccess: () => {
plausible("Deleted comment");
},
);
});
const session = useSession();
const { register, setValue, control, handleSubmit, formState } =
const { register, reset, control, handleSubmit, formState } =
useForm<CommentForm>({
defaultValues: {
authorName: "",
@ -100,13 +79,6 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
},
});
const handleDelete = React.useCallback(
(commentId: string) => {
deleteCommentMutation({ pollId, commentId });
},
[deleteCommentMutation, pollId],
);
if (!comments) {
return null;
}
@ -130,6 +102,7 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
return (
<motion.div
layoutId={comment.id}
transition={{ duration: 0.2 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -171,7 +144,10 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
icon={Trash}
label="Delete comment"
onClick={() => {
handleDelete(comment.id);
deleteComment.mutate({
commentId: comment.id,
pollId,
});
}}
/>
</Dropdown>
@ -188,22 +164,9 @@ const Discussion: React.VoidFunctionComponent<DiscussionProps> = ({
</div>
<form
className="bg-white p-4"
onSubmit={handleSubmit((data) => {
return new Promise((resolve, reject) => {
createCommentMutation(
{
...data,
pollId,
},
{
onSuccess: () => {
setValue("content", "");
resolve(data);
},
onError: reject,
},
);
});
onSubmit={handleSubmit(async ({ authorName, content }) => {
await addComment.mutateAsync({ authorName, content, pollId });
reset({ authorName, content: "" });
})}
>
<textarea

View file

@ -10,8 +10,9 @@ import { Menu } from "@headlessui/react";
import clsx from "clsx";
import { motion } from "framer-motion";
import * as React from "react";
import { transformOriginByPlacement } from "utils/constants";
import { stopPropagation } from "utils/stop-propagation";
import { transformOriginByPlacement } from "@/utils/constants";
import { stopPropagation } from "@/utils/stop-propagation";
const MotionMenuItems = motion(Menu.Items);

View file

@ -9,9 +9,9 @@ import { Listbox } from "@headlessui/react";
import clsx from "clsx";
import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns";
import * as React from "react";
import { stopPropagation } from "utils/stop-propagation";
import { usePreferences } from "@/components/preferences/use-preferences";
import { stopPropagation } from "@/utils/stop-propagation";
import ChevronDown from "../../../icons/chevron-down.svg";
import { styleMenuItem } from "../../../menu-styles";

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 744 B

View file

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 332 B

View file

Before

Width:  |  Height:  |  Size: 313 B

After

Width:  |  Height:  |  Size: 313 B

View file

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

View file

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 295 B

View file

Before

Width:  |  Height:  |  Size: 291 B

After

Width:  |  Height:  |  Size: 291 B

View file

Before

Width:  |  Height:  |  Size: 468 B

After

Width:  |  Height:  |  Size: 468 B

View file

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 674 B

View file

Before

Width:  |  Height:  |  Size: 283 B

After

Width:  |  Height:  |  Size: 283 B

View file

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 343 B

View file

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 354 B

View file

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

View file

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 273 B

View file

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 273 B

View file

Before

Width:  |  Height:  |  Size: 274 B

After

Width:  |  Height:  |  Size: 274 B

View file

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 273 B

View file

Before

Width:  |  Height:  |  Size: 240 B

After

Width:  |  Height:  |  Size: 240 B

Some files were not shown because too many files have changed in this diff Show more