Store participant timezone in response (#1811)

This commit is contained in:
Luke Vella 2025-07-11 15:25:15 +01:00 committed by GitHub
parent 968e513dba
commit 965e969fd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 101 additions and 87 deletions

View file

@ -13,6 +13,7 @@ import z from "zod";
import { usePoll } from "@/contexts/poll"; import { usePoll } from "@/contexts/poll";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { useTimezone } from "@/features/timezone";
import { useAddParticipantMutation } from "./poll/mutations"; import { useAddParticipantMutation } from "./poll/mutations";
import VoteIcon from "./poll/vote-icon"; import VoteIcon from "./poll/vote-icon";
import { useUser } from "./user-provider"; import { useUser } from "./user-provider";
@ -89,7 +90,7 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
const poll = usePoll(); const poll = usePoll();
const isEmailRequired = poll.requireParticipantEmail; const isEmailRequired = poll.requireParticipantEmail;
const { timezone } = useTimezone();
const { user, createGuestIfNeeded } = useUser(); const { user, createGuestIfNeeded } = useUser();
const isLoggedIn = !user.isGuest; const isLoggedIn = !user.isGuest;
const { register, setError, formState, handleSubmit } = const { register, setError, formState, handleSubmit } =
@ -117,6 +118,7 @@ export const NewParticipantForm = (props: NewParticipantModalProps) => {
votes: props.votes, votes: props.votes,
email: data.email, email: data.email,
pollId: poll.id, pollId: poll.id,
timeZone: timezone,
}); });
props.onSubmit?.(newParticipant); props.onSubmit?.(newParticipant);
} catch (error) { } catch (error) {

View file

@ -574,6 +574,7 @@ export const polls = router({
name: true, name: true,
email: true, email: true,
locale: true, locale: true,
timeZone: true,
user: { user: {
select: { select: {
email: true, email: true,
@ -661,7 +662,8 @@ export const polls = router({
inviteeName: p.name, inviteeName: p.name,
inviteeEmail: inviteeEmail:
p.user?.email ?? p.email ?? `${p.id}@rallly.co`, p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone inviteeTimeZone:
p.user?.timeZone ?? p.timeZone ?? poll.timeZone,
status: ( status: (
{ {
yes: "accepted", yes: "accepted",
@ -758,6 +760,7 @@ export const polls = router({
name: string; name: string;
email: string; email: string;
locale: string | undefined; locale: string | undefined;
timeZone: string | null;
}> = []; }> = [];
if (input.notify === "all") { if (input.notify === "all") {
@ -768,6 +771,7 @@ export const polls = router({
name: p.name, name: p.name,
email: p.email, email: p.email,
locale: p.locale ?? undefined, locale: p.locale ?? undefined,
timeZone: p.timeZone,
}); });
} }
}); });
@ -781,6 +785,7 @@ export const polls = router({
name: p.name, name: p.name,
email: p.email, email: p.email,
locale: p.locale ?? undefined, locale: p.locale ?? undefined,
timeZone: p.timeZone,
}); });
} }
}); });
@ -821,7 +826,7 @@ export const polls = router({
end: scheduledEvent.end, end: scheduledEvent.end,
allDay: scheduledEvent.allDay, allDay: scheduledEvent.allDay,
timeZone: scheduledEvent.timeZone, timeZone: scheduledEvent.timeZone,
// inviteeTimeZone: p.timeZone, // TODO: implement this inviteeTimeZone: p.timeZone,
}); });
getEmailClient(p.locale ?? undefined).queueTemplate( getEmailClient(p.locale ?? undefined).queueTemplate(
"FinalizeParticipantEmail", "FinalizeParticipantEmail",

View file

@ -129,6 +129,7 @@ export const participants = router({
pollId: z.string(), pollId: z.string(),
name: z.string().min(1, "Participant name is required").max(100), name: z.string().min(1, "Participant name is required").max(100),
email: z.string().optional(), email: z.string().optional(),
timeZone: z.string().optional(),
votes: z votes: z
.object({ .object({
optionId: z.string(), optionId: z.string(),
@ -137,100 +138,103 @@ export const participants = router({
.array(), .array(),
}), }),
) )
.mutation(async ({ ctx, input: { pollId, votes, name, email } }) => { .mutation(
const { user } = ctx; async ({ ctx, input: { pollId, votes, name, email, timeZone } }) => {
const { user } = ctx;
const participant = await prisma.$transaction(async (prisma) => { const participant = await prisma.$transaction(async (prisma) => {
const participantCount = await prisma.participant.count({ const participantCount = await prisma.participant.count({
where: { where: {
pollId, pollId,
deleted: false, deleted: false,
}, },
});
if (participantCount >= MAX_PARTICIPANTS) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `This poll has reached its maximum limit of ${MAX_PARTICIPANTS} participants`,
}); });
}
const participant = await prisma.participant.create({ if (participantCount >= MAX_PARTICIPANTS) {
data: { throw new TRPCError({
pollId: pollId, code: "BAD_REQUEST",
name: name, message: `This poll has reached its maximum limit of ${MAX_PARTICIPANTS} participants`,
email, });
...(user.isGuest ? { guestId: user.id } : { userId: user.id }), }
locale: user.locale ?? undefined,
}, const participant = await prisma.participant.create({
include: { data: {
poll: { pollId: pollId,
select: { name: name,
id: true, email,
title: true, timeZone,
...(user.isGuest ? { guestId: user.id } : { userId: user.id }),
locale: user.locale ?? undefined,
},
include: {
poll: {
select: {
id: true,
title: true,
},
}, },
}, },
}, });
});
const options = await prisma.option.findMany({ const options = await prisma.option.findMany({
where: { where: {
pollId, pollId,
}, },
select: { select: {
id: true, id: true,
},
});
const existingOptionIds = new Set(options.map((option) => option.id));
const validVotes = votes.filter(({ optionId }) =>
existingOptionIds.has(optionId),
);
await prisma.vote.createMany({
data: validVotes.map(({ optionId, type }) => ({
optionId,
type,
pollId,
participantId: participant.id,
})),
});
return participant;
});
if (email) {
const token = await createToken(
{ userId: user.id },
{
ttl: 0, // basically forever
},
);
ctx.user
.getEmailClient()
.queueTemplate("NewParticipantConfirmationEmail", {
to: email,
props: {
title: participant.poll.title,
editSubmissionUrl: absoluteUrl(
`/invite/${participant.poll.id}?token=${token}`,
),
}, },
}); });
}
waitUntil( const existingOptionIds = new Set(options.map((option) => option.id));
sendNewParticipantNotifcationEmail({
pollId,
pollTitle: participant.poll.title,
participantName: participant.name,
}),
);
return participant; const validVotes = votes.filter(({ optionId }) =>
}), existingOptionIds.has(optionId),
);
await prisma.vote.createMany({
data: validVotes.map(({ optionId, type }) => ({
optionId,
type,
pollId,
participantId: participant.id,
})),
});
return participant;
});
if (email) {
const token = await createToken(
{ userId: user.id },
{
ttl: 0, // basically forever
},
);
ctx.user
.getEmailClient()
.queueTemplate("NewParticipantConfirmationEmail", {
to: email,
props: {
title: participant.poll.title,
editSubmissionUrl: absoluteUrl(
`/invite/${participant.poll.id}?token=${token}`,
),
},
});
}
waitUntil(
sendNewParticipantNotifcationEmail({
pollId,
pollTitle: participant.poll.title,
participantName: participant.name,
}),
);
return participant;
},
),
rename: publicProcedure rename: publicProcedure
.input(z.object({ participantId: z.string(), newName: z.string() })) .input(z.object({ participantId: z.string(), newName: z.string() }))
.mutation(async ({ input: { participantId, newName } }) => { .mutation(async ({ input: { participantId, newName } }) => {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "participants" ADD COLUMN "time_zone" TEXT;

View file

@ -76,6 +76,7 @@ model Participant {
guestId String? @map("guest_id") guestId String? @map("guest_id")
pollId String @map("poll_id") pollId String @map("poll_id")
locale String? locale String?
timeZone String? @map("time_zone")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at") updatedAt DateTime? @updatedAt @map("updated_at")
deleted Boolean @default(false) deleted Boolean @default(false)