Add option to cancel a scheduled event (#1800)

This commit is contained in:
Luke Vella 2025-07-09 11:21:34 +03:00 committed by GitHub
parent 4b4dfef3e5
commit 20f7c6bfb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 429 additions and 84 deletions

View file

@ -395,5 +395,6 @@
"actionErrorUnauthorized": "You are not authorized to perform this action",
"actionErrorNotFound": "The resource was not found",
"actionErrorForbidden": "You are not allowed to perform this action",
"actionErrorInternalServerError": "An internal server error occurred"
"actionErrorInternalServerError": "An internal server error occurred",
"cancelEvent": "Cancel Event"
}

View file

@ -38,6 +38,9 @@ export function EventsTabbedView({ children }: { children: React.ReactNode }) {
<TabsTrigger value="past">
<Trans i18nKey="past" defaults="Past" />
</TabsTrigger>
<TabsTrigger value="canceled">
<Trans i18nKey="canceled" defaults="Canceled" />
</TabsTrigger>
</TabsList>
<TabsContent
tabIndex={-1}

View file

@ -161,6 +161,7 @@ export default async function Page(props: {
end={event.end}
allDay={event.allDay}
invites={event.invites}
status={event.status}
/>
</StackedListItem>
))}

View file

@ -8,6 +8,7 @@ import { Trans } from "@/components/trans";
import { IfParticipantsVisible } from "@/components/visibility";
import { usePoll } from "@/contexts/poll";
import { useDayjs } from "@/utils/dayjs";
import { Badge } from "@rallly/ui/badge";
function FinalDate({ start }: { start: Date }) {
const poll = usePoll();
@ -92,6 +93,11 @@ export function ScheduledEvent() {
</div>
</div>
</div>
{event.status === "canceled" && (
<Badge>
<Trans i18nKey="canceled" defaults="Canceled" />
</Badge>
)}
</div>
<div className="flex items-center gap-4">
<IfParticipantsVisible>

View file

@ -9,6 +9,7 @@ import type {
Comment,
Participant,
Poll,
ScheduledEvent,
Space,
SpaceMember,
Subscription,
@ -29,7 +30,8 @@ export type Action =
| "delete"
| "manage"
| "finalize"
| "access";
| "access"
| "cancel";
export type Subject =
| Subjects<{
@ -40,6 +42,7 @@ export type Subject =
Participant: Participant;
SpaceMember: SpaceMember;
Subscription: Subscription;
ScheduledEvent: ScheduledEvent;
}>
| "ControlPanel";
@ -91,5 +94,7 @@ export const defineAbilityFor = (
},
});
can("cancel", "ScheduledEvent", { userId: user.id });
return build();
};

View file

@ -0,0 +1,84 @@
"use server";
import { ActionError, authActionClient } from "@/features/safe-action/server";
import { getEmailClient } from "@/utils/emails";
import { subject } from "@casl/ability";
import { prisma } from "@rallly/database";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { formatEventDateTime } from "./utils";
export const cancelEventAction = authActionClient
.inputSchema(
z.object({
eventId: z.string(),
}),
)
.action(async ({ ctx, parsedInput }) => {
const event = await prisma.scheduledEvent.findUnique({
where: {
id: parsedInput.eventId,
},
});
if (!event) {
throw new ActionError({
code: "NOT_FOUND",
message: "Event not found",
});
}
if (ctx.ability.cannot("cancel", subject("ScheduledEvent", event))) {
throw new ActionError({
code: "UNAUTHORIZED",
message: "You do not have permission to cancel this event",
});
}
await prisma.scheduledEvent.update({
where: {
id: parsedInput.eventId,
},
data: {
status: "canceled",
},
});
revalidatePath("/", "layout");
// notify attendees
const attendees = await prisma.scheduledEventInvite.findMany({
where: {
scheduledEventId: parsedInput.eventId,
},
select: {
inviteeEmail: true,
inviteeName: true,
inviteeTimeZone: true,
status: true,
},
});
for (const attendee of attendees) {
if (attendee.status !== "declined") {
const { day, dow, date, time } = formatEventDateTime({
start: event.start,
end: event.end,
allDay: event.allDay,
timeZone: event.timeZone,
inviteeTimeZone: attendee.inviteeTimeZone,
});
getEmailClient().queueTemplate("EventCanceledEmail", {
to: attendee.inviteeEmail,
props: {
title: event.title,
hostName: ctx.user.name,
day,
dow,
date,
time,
},
});
}
}
});

View file

@ -1,31 +1,55 @@
"use client";
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
import { StackedList } from "@/components/stacked-list";
import { Trans } from "@/components/trans";
import { useSafeAction } from "@/features/safe-action/client";
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
import type { ScheduledEventStatus } from "@rallly/database";
import { Badge } from "@rallly/ui/badge";
import { Button } from "@rallly/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@rallly/ui/dropdown-menu";
import { Icon } from "@rallly/ui/icon";
import { MoreHorizontalIcon, XIcon } from "lucide-react";
import { cancelEventAction } from "../actions";
export const ScheduledEventList = StackedList;
export function ScheduledEventListItem({
eventId,
title,
start,
end,
allDay,
invites,
floating: isFloating,
status,
}: {
eventId: string;
title: string;
status: ScheduledEventStatus;
start: Date;
end: Date;
allDay: boolean;
invites: { id: string; inviteeName: string; inviteeImage?: string }[];
floating: boolean;
}) {
const cancelEvent = useSafeAction(cancelEventAction);
return (
<div className="flex w-full gap-6">
<div className="flex flex-1 flex-col gap-y-1 lg:flex-row-reverse lg:justify-end lg:gap-x-4">
<div className="flex items-center gap-4 text-sm">
<div>{title}</div>
{status === "canceled" && (
<Badge>
<Trans i18nKey="canceled" defaults="Canceled" />
</Badge>
)}
</div>
<div className="flex items-center whitespace-nowrap text-sm lg:min-w-40">
<div>
@ -69,6 +93,31 @@ export function ScheduledEventListItem({
max={5}
/>
</div>
{status !== "canceled" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Icon>
<MoreHorizontalIcon />
</Icon>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
cancelEvent.executeAsync({
eventId,
})
}
>
<Icon>
<XIcon />
</Icon>
<Trans i18nKey="cancelEvent" defaults="Cancel Event" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);

View file

@ -11,7 +11,7 @@ dayjs.extend(utc);
const mapStatus = {
upcoming: "confirmed",
unconfirmed: "unconfirmed",
past: undefined,
past: "confirmed",
canceled: "canceled",
} as const;
@ -35,7 +35,7 @@ function getEventsWhereInput({
...(status === "upcoming" && {
OR: [
{ allDay: false, start: { gte: now } },
{ allDay: true, start: { gte: todayStart, lte: todayEnd } },
{ allDay: true, start: { gte: todayStart } },
],
}),
...(status === "past" && {

View file

@ -0,0 +1,75 @@
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
export interface FormattedEventDateTime {
date: string;
day: string;
dow: string;
time: string;
}
interface FormatEventDateTimeOptions {
start: Date;
end: Date;
allDay: boolean;
timeZone?: string | null;
inviteeTimeZone?: string | null;
}
/**
* Formats event date and time based on event type and timezone settings
*
* Handles three scenarios:
* 1. All-day events (same date for everyone)
* 2. Events with a timezone (adjust to invitee's timezone if available)
* 3. Events without a timezone (floating time - show in UTC)
*/
export const formatEventDateTime = ({
start,
end,
allDay,
timeZone,
inviteeTimeZone,
}: FormatEventDateTimeOptions): FormattedEventDateTime => {
if (allDay) {
// For all-day events, show the same date to everyone
const eventDate = dayjs(start).utc();
return {
date: eventDate.format("MMMM D, YYYY"),
day: eventDate.format("D"),
dow: eventDate.format("ddd"),
time: "All day",
};
}
if (timeZone) {
// If event has a timezone, adjust to invitee's timezone if available
const targetTimeZone = inviteeTimeZone || timeZone;
const startTime = dayjs(start).tz(targetTimeZone);
const endTime = dayjs(end).tz(targetTimeZone);
return {
date: startTime.format("MMMM D, YYYY"),
day: startTime.format("D"),
dow: startTime.format("ddd"),
time: `${startTime.format("HH:mm")} - ${endTime.format("HH:mm z")}`,
};
}
const startTime = dayjs(start).utc();
const endTime = dayjs(end).utc();
return {
date: startTime.format("MMMM D, YYYY"),
day: startTime.format("D"),
dow: startTime.format("ddd"),
time: `${startTime.format("HH:mm")} - ${endTime.format("HH:mm")}`,
};
};

View file

@ -12,8 +12,8 @@ import { z } from "zod";
import { moderateContent } from "@/features/moderation";
import { getEmailClient } from "@/utils/emails";
import { formatEventDateTime } from "@/features/scheduled-event/utils";
import { getDefaultSpace } from "@/features/spaces/queries";
import { getTimeZoneAbbreviation } from "../../utils/date";
import {
createRateLimitMiddleware,
possiblyPublicProcedure,
@ -479,6 +479,7 @@ export const polls = router({
start: true,
end: true,
allDay: true,
status: true,
invites: {
select: {
id: true,
@ -526,6 +527,7 @@ export const polls = router({
(invite) =>
invite.status === "accepted" || invite.status === "tentative",
),
status: res.scheduledEvent.status,
}
: null;
@ -629,56 +631,64 @@ export const polls = router({
eventStart = eventStart.utc();
}
if (!poll.spaceId) {
const { spaceId } = poll;
if (!spaceId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Poll has no space",
});
}
await prisma.poll.update({
where: {
id: input.pollId,
},
data: {
status: "finalized",
scheduledEvent: {
create: {
id: input.pollId,
start: eventStart.toDate(),
end: eventStart.add(option.duration, "minute").toDate(),
title: poll.title,
location: poll.location,
timeZone: poll.timeZone,
userId: ctx.user.id,
spaceId: poll.spaceId,
allDay: option.duration === 0,
status: "confirmed",
invites: {
createMany: {
data: poll.participants
.filter((p) => p.email || p.user?.email) // Filter out participants without email
.map((p) => ({
inviteeName: p.name,
inviteeEmail:
p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone
status: (
{
yes: "accepted",
ifNeedBe: "tentative",
no: "declined",
} as const
)[
p.votes.find((v) => v.optionId === input.optionId)
?.type ?? "no"
],
})),
},
const scheduledEvent = await prisma.$transaction(async (tx) => {
// create scheduled event
const event = await tx.scheduledEvent.create({
data: {
start: eventStart.toDate(),
end: eventStart.add(option.duration, "minute").toDate(),
title: poll.title,
location: poll.location,
timeZone: poll.timeZone,
userId: ctx.user.id,
spaceId,
allDay: option.duration === 0,
status: "confirmed",
invites: {
createMany: {
data: poll.participants
.filter((p) => p.email || p.user?.email) // Filter out participants without email
.map((p) => ({
inviteeName: p.name,
inviteeEmail:
p.user?.email ?? p.email ?? `${p.id}@rallly.co`,
inviteeTimeZone: p.user?.timeZone ?? poll.timeZone, // We should track participant's timezone
status: (
{
yes: "accepted",
ifNeedBe: "tentative",
no: "declined",
} as const
)[
p.votes.find((v) => v.optionId === input.optionId)
?.type ?? "no"
],
})),
},
},
},
},
});
// update poll status
await tx.poll.update({
where: {
id: poll.id,
},
data: {
status: "finalized",
scheduledEventId: event.id,
},
});
return event;
});
const attendees = poll.participants.filter((p) =>
@ -699,6 +709,8 @@ export const polls = router({
: eventStart.add(1, "day");
const event = ics.createEvent({
uid: scheduledEvent.id,
sequence: 0, // TODO: Get sequence from database
title: poll.title,
location: poll.location ?? undefined,
description: poll.description ?? undefined,
@ -742,20 +754,6 @@ export const polls = router({
message: "Failed to generate ics",
});
} else {
const timeZoneAbbrev = poll.timeZone
? getTimeZoneAbbreviation(eventStart.toDate(), poll.timeZone)
: "";
const date = eventStart.format("dddd, MMMM D, YYYY");
const day = eventStart.format("D");
const dow = eventStart.format("ddd");
const startTime = eventStart.format("hh:mm A");
const endTime = eventEnd.format("hh:mm A");
const time =
option.duration > 0
? `${startTime} - ${endTime} ${timeZoneAbbrev}`
: "All-day";
const participantsToEmail: Array<{
name: string;
email: string;
@ -788,6 +786,13 @@ export const polls = router({
});
}
const { date, day, dow, time } = formatEventDateTime({
start: scheduledEvent.start,
end: scheduledEvent.end,
allDay: scheduledEvent.allDay,
timeZone: scheduledEvent.timeZone,
});
ctx.user.getEmailClient().queueTemplate("FinalizeHostEmail", {
to: poll.user.email,
props: {
@ -811,6 +816,13 @@ export const polls = router({
});
for (const p of participantsToEmail) {
const { date, day, dow, time } = formatEventDateTime({
start: scheduledEvent.start,
end: scheduledEvent.end,
allDay: scheduledEvent.allDay,
timeZone: scheduledEvent.timeZone,
// inviteeTimeZone: p.timeZone, // TODO: implement this
});
getEmailClient(p.locale ?? undefined).queueTemplate(
"FinalizeParticipantEmail",
{

View file

@ -1,20 +0,0 @@
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);
if (!timeZoneDisplayFormat) {
console.error(`No timezone display format for ${timeZone}`);
}
const standardAbbrev = timeZoneDisplayFormat?.standard.abbr ?? timeZone;
const dstAbbrev = timeZoneDisplayFormat?.daylight?.abbr ?? timeZone;
const abbrev = spaceTimeDate.isDST() ? dstAbbrev : standardAbbrev;
return abbrev;
};

View file

@ -74,5 +74,9 @@
"license_key_support": "Reply to this email or contact us at <a>{supportEmail}</a> if you need help.",
"license_key_signoff": "Thank you for choosing Rallly!",
"license_key_preview": "Your license key has been generated.",
"license_key_subject": "Your Rallly Self-Hosted {tier} License"
"license_key_subject": "Your Rallly Self-Hosted {tier} License",
"eventCanceledContent": "<b>{{hostName}}</b> has canceled <b>{{title}}</b> that was scheduled for:",
"eventCanceledPreview": "Event canceled",
"eventCanceledHeading": "Event canceled",
"eventCanceledSubject": "Canceled: {title}"
}

View file

@ -0,0 +1,16 @@
import { previewEmailContext } from "../components/email-context";
import { EventCanceledEmail } from "../templates/event-canceled";
export default function EventCanceledPreview() {
return (
<EventCanceledEmail
title="Untitled Poll"
hostName="Host"
day="12"
dow="Fri"
date="Friday, 12th June 2020"
time="6:00 PM to 11:00 PM BST"
ctx={previewEmailContext}
/>
);
}

View file

@ -1,5 +1,6 @@
import { AbandonedCheckoutEmail } from "./templates/abandoned-checkout";
import { ChangeEmailRequest } from "./templates/change-email-request";
import { EventCanceledEmail } from "./templates/event-canceled";
import { FinalizeHostEmail } from "./templates/finalized-host";
import { FinalizeParticipantEmail } from "./templates/finalized-participant";
import { LicenseKeyEmail } from "./templates/license-key";
@ -23,6 +24,7 @@ const templates = {
ChangeEmailRequest,
AbandonedCheckoutEmail,
LicenseKeyEmail,
EventCanceledEmail,
};
export const emailTemplates = Object.keys(templates) as TemplateName[];

View file

@ -0,0 +1,107 @@
import { Column, Row, Section } from "@react-email/components";
import { Trans } from "react-i18next/TransWithoutContext";
import { EmailLayout } from "../components/email-layout";
import { Heading, Text, borderColor } from "../components/styled-components";
import type { EmailContext } from "../types";
export interface EventCanceledEmailProps {
title: string;
hostName: string;
day: string;
dow: string;
date: string;
time: string;
ctx: EmailContext;
}
const EventCanceledEmail = ({
title,
hostName,
day,
dow,
date,
time,
ctx,
}: EventCanceledEmailProps) => {
return (
<EmailLayout
ctx={ctx}
preview={ctx.t("eventCanceledPreview", {
defaultValue: "Event canceled",
ns: "emails",
})}
>
<Heading>
{ctx.t("eventCanceledHeading", {
defaultValue: "Event canceled",
ns: "emails",
})}
</Heading>
<Text>
<Trans
i18n={ctx.i18n}
t={ctx.t}
i18nKey="eventCanceledContent"
ns="emails"
defaults="<b>{hostName}</b> has canceled <b>{title}</b> that was scheduled for:"
values={{ hostName, title }}
components={{
b: <strong />,
}}
/>
</Text>
<Section data-testid="date-section">
<Row>
<Column style={{ width: 48 }}>
<Section
style={{
borderRadius: 5,
margin: 0,
width: 48,
height: 48,
textAlign: "center",
border: `1px solid ${borderColor}`,
}}
>
<Text
style={{ margin: "0 0 4px 0", fontSize: 10, lineHeight: 1 }}
>
{dow}
</Text>
<Text
style={{
fontSize: 20,
lineHeight: 1,
fontWeight: "bold",
margin: 0,
}}
>
{day}
</Text>
</Section>
</Column>
<Column style={{ paddingLeft: 16 }} align="left">
<Text style={{ margin: 0, fontWeight: "bold" }}>{date}</Text>
<Text light={true} style={{ margin: 0 }}>
{time}
</Text>
</Column>
</Row>
</Section>
</EmailLayout>
);
};
EventCanceledEmail.getSubject = (
props: EventCanceledEmailProps,
ctx: EmailContext,
) => {
return ctx.t("eventCanceledSubject", {
defaultValue: "Canceled: {title}",
title: props.title,
ns: "emails",
});
};
export { EventCanceledEmail };

View file

@ -123,7 +123,7 @@ FinalizeHostEmail.getSubject = (
ctx: EmailContext,
) => {
return ctx.t("finalizeHost_subject", {
defaultValue: "Date booked for {{title}}",
defaultValue: "Date booked for {title}",
title: props.title,
ns: "emails",
});

View file

@ -51,7 +51,7 @@ const FinalizeParticipantEmail = ({
t={ctx.t}
i18nKey="finalizeParticipant_content"
ns="emails"
defaults="<b>{{hostName}}</b> has booked <b>{{title}}</b> for the following date:"
defaults="<b>{hostName}</b> has booked <b>{title}</b> for the following date:"
values={{ hostName, title }}
components={{
b: <strong />,
@ -114,7 +114,7 @@ FinalizeParticipantEmail.getSubject = (
ctx: EmailContext,
) => {
return ctx.t("finalizeParticipant_subject", {
defaultValue: "Date booked for {{title}}",
defaultValue: "Date booked for {title}",
title: props.title,
ns: "emails",
});