mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
✨ Add scheduled events schema (#1679)
This commit is contained in:
parent
22f32f9314
commit
56bd684c55
35 changed files with 1412 additions and 659 deletions
|
@ -200,10 +200,6 @@
|
|||
"upcoming": "Upcoming",
|
||||
"past": "Past",
|
||||
"copyLink": "Copy Link",
|
||||
"upcomingEventsEmptyStateTitle": "No Upcoming Events",
|
||||
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||
"pastEventsEmptyStateTitle": "No Past Events",
|
||||
"pastEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||
"createPoll": "Create poll",
|
||||
"addToCalendar": "Add to Calendar",
|
||||
"microsoft365": "Microsoft 365",
|
||||
|
@ -301,7 +297,6 @@
|
|||
"signUp": "Sign Up",
|
||||
"upgradeToPro": "Upgrade to Pro",
|
||||
"moreParticipants": "{count} more…",
|
||||
"noDates": "No dates",
|
||||
"commandMenuNoResults": "No results",
|
||||
"commandMenu": "Command Menu",
|
||||
"commandMenuDescription": "Select a command",
|
||||
|
@ -319,5 +314,19 @@
|
|||
"searchPollsPlaceholder": "Search polls by title...",
|
||||
"poll": "Poll",
|
||||
"sendFeedbackDesc": "Share your feedback with us.",
|
||||
"sendFeedbackSuccess": "Thank you for your feedback!"
|
||||
"sendFeedbackSuccess": "Thank you for your feedback!",
|
||||
"searchEventsPlaceholder": "Search events by title...",
|
||||
"canceled": "Canceled",
|
||||
"tomorrow": "Tomorrow",
|
||||
"yesterday": "Yesterday",
|
||||
"lastWeek": "Last Week",
|
||||
"unconfirmed": "Unconfirmed",
|
||||
"upcomingEventsEmptyStateTitle": "No Upcoming Events",
|
||||
"upcomingEventsEmptyStateDescription": "When you schedule events, they will appear here.",
|
||||
"pastEventsEmptyStateTitle": "No Past Events",
|
||||
"pastEventsEmptyStateDescription": "Past events will show up here.",
|
||||
"unconfirmedEventsEmptyStateTitle": "No Unconfirmed Events",
|
||||
"unconfirmedEventsEmptyStateDescription": "Unconfirmed events will show up here.",
|
||||
"canceledEventsEmptyStateTitle": "No Canceled Events",
|
||||
"canceledEventsEmptyStateDescription": "Canceled events will show up here."
|
||||
}
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { generateGradient } from "@/utils/color-hash";
|
||||
import { useDayjs } from "@/utils/dayjs";
|
||||
|
||||
import type { ScheduledEvent } from "./types";
|
||||
|
||||
export function EventList({ data }: { data: ScheduledEvent[] }) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: [],
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const { adjustTimeZone } = useDayjs();
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const start = adjustTimeZone(
|
||||
row.original.start,
|
||||
!row.original.timeZone,
|
||||
);
|
||||
|
||||
const end = adjustTimeZone(
|
||||
dayjs(row.original.start).add(row.original.duration, "minutes"),
|
||||
!row.original.timeZone,
|
||||
);
|
||||
return (
|
||||
<li key={row.id} className="p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:gap-8">
|
||||
<div className="flex shrink-0 justify-between gap-1 sm:w-24 sm:flex-col sm:text-right">
|
||||
<time dateTime={start.toISOString()} className="text-sm">
|
||||
{start.format("ddd, D MMM")}
|
||||
</time>
|
||||
<time
|
||||
dateTime={start.toISOString()}
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{start.format("YYYY")}
|
||||
</time>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span
|
||||
className="h-4 w-1 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: generateGradient(row.original.id),
|
||||
}}
|
||||
></span>
|
||||
<h2 className="truncate text-sm font-medium">
|
||||
{row.original.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{row.original.duration === 0 ? (
|
||||
<Trans i18nKey="allDay" />
|
||||
) : (
|
||||
<span>{`${start.format("LT")} - ${end.format("LT")}`}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
|
||||
export function EventsTabbedView({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams();
|
||||
const name = "status";
|
||||
const router = useRouter();
|
||||
const handleTabChange = React.useCallback(
|
||||
(value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set(name, value);
|
||||
|
||||
params.delete("page");
|
||||
|
||||
const newUrl = `?${params.toString()}`;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
},
|
||||
[name, router, searchParams],
|
||||
);
|
||||
|
||||
const value = searchParams.get(name) ?? "upcoming";
|
||||
|
||||
return (
|
||||
<Tabs value={value} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="upcoming">
|
||||
<Trans i18nKey="upcoming" defaults="Upcoming" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
<Trans i18nKey="past" defaults="Past" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent tabIndex={-1} value={value} key={value}>
|
||||
{children}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import { CalendarIcon } from "lucide-react";
|
||||
|
||||
import type { Params } from "@/app/[locale]/types";
|
||||
import { EventPageIcon } from "@/app/components/page-icons";
|
||||
import {
|
||||
|
@ -7,13 +9,101 @@ import {
|
|||
PageHeader,
|
||||
PageTitle,
|
||||
} from "@/app/components/page-layout";
|
||||
import { SearchInput } from "@/app/components/search-input";
|
||||
import {
|
||||
EmptyState,
|
||||
EmptyStateDescription,
|
||||
EmptyStateIcon,
|
||||
EmptyStateTitle,
|
||||
} from "@/components/empty-state";
|
||||
import { StackedList, StackedListItem } from "@/components/stacked-list";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { getScheduledEvents } from "@/features/scheduled-event/api/get-scheduled-events";
|
||||
import { ScheduledEventListItem } from "@/features/scheduled-event/components/scheduled-event-list";
|
||||
import type { Status } from "@/features/scheduled-event/schema";
|
||||
import { statusSchema } from "@/features/scheduled-event/schema";
|
||||
import { getTranslation } from "@/i18n/server";
|
||||
import { requireUser } from "@/next-auth";
|
||||
|
||||
import { UserScheduledEvents } from "./user-scheduled-events";
|
||||
import { EventsTabbedView } from "./events-tabbed-view";
|
||||
|
||||
async function loadData({
|
||||
status,
|
||||
search,
|
||||
}: {
|
||||
status: Status;
|
||||
search?: string;
|
||||
}) {
|
||||
const { userId } = await requireUser();
|
||||
const scheduledEvents = await getScheduledEvents({
|
||||
userId,
|
||||
status,
|
||||
search,
|
||||
});
|
||||
return scheduledEvents;
|
||||
}
|
||||
|
||||
async function ScheduledEventEmptyState({ status }: { status: Status }) {
|
||||
const { t } = await getTranslation();
|
||||
const contentByStatus = {
|
||||
upcoming: {
|
||||
title: t("upcomingEventsEmptyStateTitle", {
|
||||
defaultValue: "No Upcoming Events",
|
||||
}),
|
||||
description: t("upcomingEventsEmptyStateDescription", {
|
||||
defaultValue: "When you schedule events, they will appear here.",
|
||||
}),
|
||||
},
|
||||
past: {
|
||||
title: t("pastEventsEmptyStateTitle", {
|
||||
defaultValue: "No Past Events",
|
||||
}),
|
||||
description: t("pastEventsEmptyStateDescription", {
|
||||
defaultValue: "Past events will show up here.",
|
||||
}),
|
||||
},
|
||||
unconfirmed: {
|
||||
title: t("unconfirmedEventsEmptyStateTitle", {
|
||||
defaultValue: "No Unconfirmed Events",
|
||||
}),
|
||||
description: t("unconfirmedEventsEmptyStateDescription", {
|
||||
defaultValue: "Unconfirmed events will show up here.",
|
||||
}),
|
||||
},
|
||||
canceled: {
|
||||
title: t("canceledEventsEmptyStateTitle", {
|
||||
defaultValue: "No Canceled Events",
|
||||
}),
|
||||
description: t("canceledEventsEmptyStateDescription", {
|
||||
defaultValue: "Canceled events will show up here.",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const { title, description } = contentByStatus[status];
|
||||
|
||||
return (
|
||||
<EmptyState className="h-96">
|
||||
<EmptyStateIcon>
|
||||
<CalendarIcon />
|
||||
</EmptyStateIcon>
|
||||
<EmptyStateTitle>{title}</EmptyStateTitle>
|
||||
<EmptyStateDescription>{description}</EmptyStateDescription>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Params;
|
||||
searchParams?: { status: string; q?: string };
|
||||
}) {
|
||||
const { t } = await getTranslation(params.locale);
|
||||
const status = statusSchema.catch("upcoming").parse(searchParams?.status);
|
||||
const scheduledEvents = await loadData({ status, search: searchParams?.q });
|
||||
|
||||
export default async function Page({ params }: { params: Params }) {
|
||||
await getTranslation(params.locale);
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
|
@ -29,7 +119,39 @@ export default async function Page({ params }: { params: Params }) {
|
|||
</PageDescription>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<UserScheduledEvents />
|
||||
<EventsTabbedView>
|
||||
<div className="space-y-4">
|
||||
<SearchInput
|
||||
placeholder={t("searchEventsPlaceholder", {
|
||||
defaultValue: "Search events by title...",
|
||||
})}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{scheduledEvents.length === 0 && (
|
||||
<ScheduledEventEmptyState status={status} />
|
||||
)}
|
||||
{scheduledEvents.length > 0 && (
|
||||
<StackedList>
|
||||
{scheduledEvents.map((event) => (
|
||||
<StackedListItem key={event.id}>
|
||||
<ScheduledEventListItem
|
||||
eventId={event.id}
|
||||
key={event.id}
|
||||
floating={event.timeZone === null}
|
||||
title={event.title}
|
||||
start={event.start}
|
||||
end={event.end}
|
||||
status={event.status}
|
||||
allDay={event.allDay}
|
||||
invites={event.invites}
|
||||
/>
|
||||
</StackedListItem>
|
||||
))}
|
||||
</StackedList>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EventsTabbedView>
|
||||
</PageContent>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
"use client";
|
||||
import { CalendarPlusIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
EmptyStateDescription,
|
||||
EmptyStateIcon,
|
||||
EmptyStateTitle,
|
||||
} from "@/components/empty-state";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { trpc } from "@/trpc/client";
|
||||
|
||||
import { EventList } from "./event-list";
|
||||
|
||||
export function PastEvents() {
|
||||
const { data } = trpc.scheduledEvents.list.useQuery({
|
||||
period: "past",
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<EmptyState className="h-96">
|
||||
<EmptyStateIcon>
|
||||
<CalendarPlusIcon />
|
||||
</EmptyStateIcon>
|
||||
<EmptyStateTitle>
|
||||
<Trans
|
||||
i18nKey="pastEventsEmptyStateTitle"
|
||||
defaults="No Past Events"
|
||||
/>
|
||||
</EmptyStateTitle>
|
||||
<EmptyStateDescription>
|
||||
<Trans
|
||||
i18nKey="pastEventsEmptyStateDescription"
|
||||
defaults="When you schedule events, they will appear here."
|
||||
/>
|
||||
</EmptyStateDescription>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return <EventList data={data} />;
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
"use client";
|
||||
import { CalendarPlusIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
EmptyStateDescription,
|
||||
EmptyStateIcon,
|
||||
EmptyStateTitle,
|
||||
} from "@/components/empty-state";
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { trpc } from "@/trpc/client";
|
||||
|
||||
import { EventList } from "./event-list";
|
||||
|
||||
export function UpcomingEvents() {
|
||||
const { data } = trpc.scheduledEvents.list.useQuery({ period: "upcoming" });
|
||||
|
||||
if (!data) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<EmptyState className="h-96">
|
||||
<EmptyStateIcon>
|
||||
<CalendarPlusIcon />
|
||||
</EmptyStateIcon>
|
||||
<EmptyStateTitle>
|
||||
<Trans
|
||||
i18nKey="upcomingEventsEmptyStateTitle"
|
||||
defaults="No Upcoming Events"
|
||||
/>
|
||||
</EmptyStateTitle>
|
||||
<EmptyStateDescription>
|
||||
<Trans
|
||||
i18nKey="upcomingEventsEmptyStateDescription"
|
||||
defaults="When you schedule events, they will appear here."
|
||||
/>
|
||||
</EmptyStateDescription>
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return <EventList data={data} />;
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@rallly/ui/page-tabs";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
|
||||
import { PastEvents } from "./past-events";
|
||||
import { UpcomingEvents } from "./upcoming-events";
|
||||
|
||||
const eventPeriodSchema = z.enum(["upcoming", "past"]).catch("upcoming");
|
||||
|
||||
export function UserScheduledEvents() {
|
||||
const searchParams = useSearchParams();
|
||||
const period = eventPeriodSchema.parse(searchParams?.get("period"));
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs
|
||||
value={searchParams.get("period") ?? "upcoming"}
|
||||
onValueChange={(value) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("period", value);
|
||||
const newUrl = `?${params.toString()}`;
|
||||
router.replace(newUrl);
|
||||
}}
|
||||
aria-label="Event period"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="upcoming">
|
||||
<Trans i18nKey="upcoming" defaults="Upcoming" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
<Trans i18nKey="past" defaults="Past" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div>
|
||||
{period === "upcoming" && <UpcomingEvents />}
|
||||
{period === "past" && <PastEvents />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -6,6 +6,7 @@ import Link from "next/link";
|
|||
import { OptimizedAvatarImage } from "@/components/optimized-avatar-image";
|
||||
import { getUser } from "@/data/get-user";
|
||||
import { CommandMenu } from "@/features/navigation/command-menu";
|
||||
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||
|
||||
import { AppSidebar } from "./components/sidebar/app-sidebar";
|
||||
import { AppSidebarProvider } from "./components/sidebar/app-sidebar-provider";
|
||||
|
@ -18,36 +19,38 @@ export default async function Layout({
|
|||
}) {
|
||||
const user = await getUser();
|
||||
return (
|
||||
<AppSidebarProvider>
|
||||
<CommandMenu />
|
||||
<AppSidebar />
|
||||
<SidebarInset className="min-w-0">
|
||||
<TopBar className="sm:hidden">
|
||||
<TopBarLeft>
|
||||
<SidebarTrigger />
|
||||
</TopBarLeft>
|
||||
<TopBarRight>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
size="icon"
|
||||
>
|
||||
<Link href="/settings/profile">
|
||||
<OptimizedAvatarImage
|
||||
src={user.image}
|
||||
name={user.name}
|
||||
size="xs"
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
</TopBarRight>
|
||||
</TopBar>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
|
||||
</div>
|
||||
<ActionBar />
|
||||
</SidebarInset>
|
||||
</AppSidebarProvider>
|
||||
<TimezoneProvider initialTimezone={user.timeZone}>
|
||||
<AppSidebarProvider>
|
||||
<CommandMenu />
|
||||
<AppSidebar />
|
||||
<SidebarInset className="min-w-0">
|
||||
<TopBar className="sm:hidden">
|
||||
<TopBarLeft>
|
||||
<SidebarTrigger />
|
||||
</TopBarLeft>
|
||||
<TopBarRight>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
size="icon"
|
||||
>
|
||||
<Link href="/settings/profile">
|
||||
<OptimizedAvatarImage
|
||||
src={user.image}
|
||||
name={user.name}
|
||||
size="xs"
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
</TopBarRight>
|
||||
</TopBar>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4 md:p-8">{children}</div>
|
||||
</div>
|
||||
<ActionBar />
|
||||
</SidebarInset>
|
||||
</AppSidebarProvider>
|
||||
</TimezoneProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,9 +27,9 @@ import {
|
|||
EmptyStateIcon,
|
||||
EmptyStateTitle,
|
||||
} from "@/components/empty-state";
|
||||
import { FormattedDate } from "@/components/formatted-date";
|
||||
import { PayWallDialog } from "@/components/pay-wall-dialog";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
|
||||
import { requireUser } from "@/next-auth";
|
||||
import { isSelfHosted } from "@/utils/constants";
|
||||
|
||||
|
@ -153,7 +153,10 @@ export default async function Page() {
|
|||
{subscription.cancelAtPeriodEnd ? (
|
||||
"-"
|
||||
) : (
|
||||
<FormattedDate date={subscription.periodEnd} format="short" />
|
||||
<FormattedDateTime
|
||||
date={subscription.periodEnd}
|
||||
format="D MMM YYYY"
|
||||
/>
|
||||
)}
|
||||
</DescriptionDetails>
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import React from "react";
|
|||
|
||||
import { TimeZoneChangeDetector } from "@/app/[locale]/timezone-change-detector";
|
||||
import { UserProvider } from "@/components/user-provider";
|
||||
import { TimezoneProvider } from "@/features/timezone";
|
||||
import { TimezoneProvider } from "@/features/timezone/client/context";
|
||||
import { I18nProvider } from "@/i18n/client";
|
||||
import { auth } from "@/next-auth";
|
||||
import { TRPCProvider } from "@/trpc/client/provider";
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const formatMap = {
|
||||
short: "D MMM YYYY",
|
||||
};
|
||||
|
||||
type Format = keyof typeof formatMap | string;
|
||||
|
||||
export function FormattedDate({
|
||||
date,
|
||||
format,
|
||||
}: {
|
||||
date: Date;
|
||||
format: Format;
|
||||
}) {
|
||||
// If format is a key in formatMap, use the predefined format, otherwise use the format string directly
|
||||
const formatString =
|
||||
format in formatMap ? formatMap[format as keyof typeof formatMap] : format;
|
||||
return <>{dayjs(date).format(formatString)}</>;
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import { DateDisplay, DateTimeDisplay } from "@/features/timezone";
|
||||
|
||||
interface ScheduledEventDisplayProps {
|
||||
event?: {
|
||||
start: Date;
|
||||
duration: number;
|
||||
};
|
||||
dateOptions?: {
|
||||
first?: Date;
|
||||
last?: Date;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ScheduledEventDisplay = ({
|
||||
event,
|
||||
dateOptions,
|
||||
}: ScheduledEventDisplayProps) => {
|
||||
if (event) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{event.duration > 0 ? (
|
||||
<DateTimeDisplay date={dayjs(event.start)} />
|
||||
) : (
|
||||
<DateDisplay date={dayjs(event.start)} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dateOptions?.first || !dateOptions?.last || dateOptions.count === 0) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<Trans i18nKey="noDates" defaults="No dates" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
<Trans
|
||||
i18nKey="optionCount"
|
||||
defaults="{count, plural, one {# option} other {# options}}"
|
||||
values={{ count: dateOptions.count }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,80 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
|
||||
import type { Status } from "../schema";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const mapStatus = {
|
||||
upcoming: "confirmed",
|
||||
unconfirmed: "unconfirmed",
|
||||
past: undefined,
|
||||
canceled: "canceled",
|
||||
} as const;
|
||||
|
||||
export async function getScheduledEvents({
|
||||
userId,
|
||||
status,
|
||||
search,
|
||||
}: {
|
||||
userId: string;
|
||||
status: Status;
|
||||
search?: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
|
||||
const rawEvents = await prisma.scheduledEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
...(status != "past" && { start: { gte: now } }),
|
||||
...(status === "past" && { start: { lt: now } }),
|
||||
...(search && { title: { contains: search, mode: "insensitive" } }),
|
||||
status: mapStatus[status],
|
||||
},
|
||||
orderBy: {
|
||||
start: status === "past" ? "desc" : "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
location: true,
|
||||
start: true,
|
||||
end: true,
|
||||
allDay: true,
|
||||
timeZone: true,
|
||||
status: true,
|
||||
invites: {
|
||||
select: {
|
||||
id: true,
|
||||
inviteeName: true,
|
||||
user: {
|
||||
select: {
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const events = rawEvents.map((event) => ({
|
||||
...event,
|
||||
status:
|
||||
event.status === "confirmed"
|
||||
? // If the event is confirmed, it's either past or upcoming
|
||||
((event.start < now ? "past" : "upcoming") as Status)
|
||||
: event.status,
|
||||
invites: event.invites.map((invite) => ({
|
||||
id: invite.id,
|
||||
inviteeName: invite.inviteeName,
|
||||
inviteeImage: invite.user?.image ?? undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
return events;
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import { ParticipantAvatarBar } from "@/components/participant-avatar-bar";
|
||||
import { StackedList } from "@/components/stacked-list";
|
||||
import { Trans } from "@/components/trans";
|
||||
import { ScheduledEventStatusBadge } from "@/features/scheduled-event/components/scheduled-event-status-badge";
|
||||
import type { Status } from "@/features/scheduled-event/schema";
|
||||
import { FormattedDateTime } from "@/features/timezone/client/formatted-date-time";
|
||||
|
||||
export const ScheduledEventList = StackedList;
|
||||
|
||||
export function ScheduledEventListItem({
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
status,
|
||||
allDay,
|
||||
invites,
|
||||
floating: isFloating,
|
||||
}: {
|
||||
eventId: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
status: Status;
|
||||
allDay: boolean;
|
||||
invites: { id: string; inviteeName: string; inviteeImage?: string }[];
|
||||
floating: boolean;
|
||||
}) {
|
||||
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>
|
||||
<div>
|
||||
<ScheduledEventStatusBadge status={status} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center whitespace-nowrap text-sm lg:min-w-40">
|
||||
<div>
|
||||
<div>
|
||||
<FormattedDateTime
|
||||
date={start}
|
||||
floating={isFloating}
|
||||
format="LL"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{allDay ? (
|
||||
<Trans i18nKey="allDay" defaults="All day" />
|
||||
) : (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<FormattedDateTime
|
||||
date={start}
|
||||
floating={isFloating}
|
||||
format="LT"
|
||||
/>
|
||||
<span>-</span>
|
||||
<FormattedDateTime
|
||||
date={end}
|
||||
floating={isFloating}
|
||||
format="LT"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:block">
|
||||
<ParticipantAvatarBar
|
||||
participants={invites.map((invite) => ({
|
||||
id: invite.id,
|
||||
name: invite.inviteeName,
|
||||
image: invite.inviteeImage ?? undefined,
|
||||
}))}
|
||||
max={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { Badge } from "@rallly/ui/badge";
|
||||
|
||||
import { Trans } from "@/components/trans";
|
||||
import type { Status } from "@/features/scheduled-event/schema";
|
||||
|
||||
export function ScheduledEventStatusBadge({ status }: { status: Status }) {
|
||||
switch (status) {
|
||||
case "past":
|
||||
return (
|
||||
<Badge>
|
||||
<Trans i18nKey="past" defaults="Past" />
|
||||
</Badge>
|
||||
);
|
||||
case "upcoming":
|
||||
return null;
|
||||
case "canceled":
|
||||
return (
|
||||
<Badge>
|
||||
<Trans i18nKey="canceled" defaults="Canceled" />
|
||||
</Badge>
|
||||
);
|
||||
case "unconfirmed":
|
||||
return (
|
||||
<Badge>
|
||||
<Trans i18nKey="unconfirmed" defaults="Unconfirmed" />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
10
apps/web/src/features/scheduled-event/schema.ts
Normal file
10
apps/web/src/features/scheduled-event/schema.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const statusSchema = z.enum([
|
||||
"upcoming",
|
||||
"unconfirmed",
|
||||
"past",
|
||||
"canceled",
|
||||
]);
|
||||
|
||||
export type Status = z.infer<typeof statusSchema>;
|
33
apps/web/src/features/timezone/client/calendar-date.tsx
Normal file
33
apps/web/src/features/timezone/client/calendar-date.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import calendar from "dayjs/plugin/calendar";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
|
||||
import { useTimezone } from "@/features/timezone/client/context";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
|
||||
dayjs.extend(calendar);
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export function CalendarDate({ date }: { date: string }) {
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<time dateTime={dayjs(date).toISOString()}>
|
||||
{dayjs(date)
|
||||
.tz(timezone)
|
||||
.calendar(null, {
|
||||
sameDay: `[${t("today", { defaultValue: "Today" })}]`,
|
||||
nextDay: `[${t("tomorrow", { defaultValue: "Tomorrow" })}]`,
|
||||
nextWeek: "dddd",
|
||||
lastDay: `[${t("yesterday", { defaultValue: "Yesterday" })}]`,
|
||||
lastWeek: `[${t("lastWeek", { defaultValue: "Last Week" })}]`,
|
||||
sameElse: "DD MMM YYYY",
|
||||
})}
|
||||
</time>
|
||||
);
|
||||
}
|
55
apps/web/src/features/timezone/client/context.tsx
Normal file
55
apps/web/src/features/timezone/client/context.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import * as React from "react";
|
||||
|
||||
import { getBrowserTimeZone } from "@/utils/date-time-utils";
|
||||
|
||||
interface TimezoneContextProps {
|
||||
timezone: string;
|
||||
setTimezone: (timezone: string) => void;
|
||||
}
|
||||
|
||||
const TimezoneContext = React.createContext<TimezoneContextProps | null>(null);
|
||||
|
||||
interface TimezoneProviderProps {
|
||||
children: React.ReactNode;
|
||||
initialTimezone?: string;
|
||||
}
|
||||
|
||||
export const TimezoneProvider = ({
|
||||
children,
|
||||
initialTimezone,
|
||||
}: TimezoneProviderProps) => {
|
||||
const [timezone, setTimezone] = React.useState(() => {
|
||||
if (initialTimezone) {
|
||||
try {
|
||||
dayjs().tz(initialTimezone);
|
||||
return initialTimezone;
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
return getBrowserTimeZone();
|
||||
});
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ timezone, setTimezone }),
|
||||
[timezone, setTimezone],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimezoneContext.Provider value={value}>
|
||||
{children}
|
||||
</TimezoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTimezone = () => {
|
||||
const context = React.useContext(TimezoneContext);
|
||||
if (context === null) {
|
||||
throw new Error("useTimezone must be used within a TimezoneProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import type { ConfigType } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import * as React from "react";
|
||||
|
||||
import { useFormattedDateTime } from "@/features/timezone/hooks/use-formatted-date-time";
|
||||
|
||||
interface FormattedDateTimeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
/** The date/time to format (Accepts Date, ISO string, Unix timestamp, Dayjs object). */
|
||||
date: ConfigType | null | undefined;
|
||||
/** A dayjs format string (e.g., "YYYY-MM-DD HH:mm", "h:mm A"). Defaults to a locale-aware format. */
|
||||
format?: string;
|
||||
/** If true, formats the time without applying the context timezone. Defaults to false. */
|
||||
floating?: boolean;
|
||||
/** Optional locale string (e.g., "en-US", "fr-FR"). Defaults to browser/system locale. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render a formatted date/time string based on the current timezone context.
|
||||
*
|
||||
* Uses the `useFormattedDateTime` hook internally.
|
||||
*/
|
||||
export const FormattedDateTime = React.forwardRef<
|
||||
HTMLTimeElement,
|
||||
FormattedDateTimeProps
|
||||
>(({ date, format, floating, locale, ...props }, ref) => {
|
||||
const formattedDate = useFormattedDateTime(date, {
|
||||
format,
|
||||
floating,
|
||||
locale,
|
||||
});
|
||||
|
||||
return (
|
||||
<time
|
||||
dateTime={date ? dayjs(date).toISOString() : new Date().toISOString()}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{formattedDate}
|
||||
</time>
|
||||
);
|
||||
});
|
||||
|
||||
FormattedDateTime.displayName = "FormattedDateTime";
|
|
@ -0,0 +1,75 @@
|
|||
import { cn } from "@rallly/ui";
|
||||
import type { ConfigType } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
import * as React from "react";
|
||||
|
||||
interface FormattedDateTimeServerProps
|
||||
extends Omit<React.HTMLAttributes<HTMLTimeElement>, "dateTime"> {
|
||||
/** The date/time to format (Accepts Date, ISO string, Unix timestamp, Dayjs object). */
|
||||
date: ConfigType | null | undefined;
|
||||
/** The IANA timezone string to use for formatting. Required for server component. */
|
||||
timezone: string;
|
||||
/** A dayjs format string (e.g., "YYYY-MM-DD HH:mm", "h:mm A"). Defaults to a locale-aware format. */
|
||||
format?: string;
|
||||
/** If true, formats the time without applying the context timezone. Defaults to false. */
|
||||
floating?: boolean;
|
||||
/** Optional locale string (e.g., "en-US", "fr-FR"). Defaults to browser/system locale if applicable on server, otherwise server default. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server Component to render a formatted date/time string based on a provided timezone.
|
||||
*
|
||||
* Does NOT use React Context.
|
||||
*/
|
||||
export const FormattedDateTimeServer = ({
|
||||
date,
|
||||
timezone,
|
||||
format,
|
||||
floating = false,
|
||||
locale,
|
||||
className,
|
||||
...props
|
||||
}: FormattedDateTimeServerProps) => {
|
||||
if (!date) {
|
||||
return null; // Return null for invalid dates
|
||||
}
|
||||
|
||||
let dayjsInstance = dayjs(date);
|
||||
|
||||
// Apply locale if provided
|
||||
if (locale) {
|
||||
dayjsInstance = dayjsInstance.locale(locale);
|
||||
}
|
||||
|
||||
// Apply timezone unless floating is true
|
||||
if (!floating) {
|
||||
try {
|
||||
dayjsInstance = dayjsInstance.tz(timezone);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`FormattedDateTimeServer: Invalid timezone provided: "${timezone}". Falling back.`,
|
||||
error,
|
||||
);
|
||||
// Fallback or default behavior if timezone is invalid
|
||||
// Might default to UTC or system time depending on Dayjs config
|
||||
}
|
||||
} else {
|
||||
// Standardize floating times to UTC before formatting without tz
|
||||
dayjsInstance = dayjsInstance.utc();
|
||||
}
|
||||
|
||||
// Determine the format string
|
||||
const defaultFormat = floating ? "LT" : "LLLL"; // LT for floating, LLLL for timezone-aware
|
||||
const formatString = format ?? defaultFormat;
|
||||
|
||||
const formattedDate = dayjsInstance.format(formatString);
|
||||
// Provide machine-readable dateTime attribute, usually in ISO format
|
||||
const machineReadableDate = dayjs(date).toISOString();
|
||||
|
||||
return (
|
||||
<time dateTime={machineReadableDate} className={cn(className)} {...props}>
|
||||
{formattedDate}
|
||||
</time>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
import type { ConfigType } from "dayjs";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { useTimezone } from "@/features/timezone/client/context";
|
||||
|
||||
interface UseFormattedDateTimeOptions {
|
||||
/** A dayjs format string (e.g., "YYYY-MM-DD HH:mm", "h:mm A"). Defaults to a locale-aware format. */
|
||||
format?: string;
|
||||
/** If true, formats the time without applying the context timezone. Defaults to false. */
|
||||
floating?: boolean;
|
||||
/** Optional locale string (e.g., "en-US", "fr-FR"). Defaults to browser/system locale. */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to format a date/time value based on the current timezone context.
|
||||
*
|
||||
* @param date The date/time to format (Accepts Date, ISO string, Unix timestamp, Dayjs object).
|
||||
* @param options Formatting options including format string, floating flag, and locale.
|
||||
* @returns The formatted date/time string.
|
||||
*/
|
||||
export const useFormattedDateTime = (
|
||||
date: ConfigType | null | undefined,
|
||||
options: UseFormattedDateTimeOptions = {},
|
||||
): string => {
|
||||
const { timezone } = useTimezone();
|
||||
const { format, floating = false, locale } = options;
|
||||
|
||||
if (!date) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let dayjsInstance = dayjs(date);
|
||||
|
||||
// Apply locale if provided
|
||||
if (locale) {
|
||||
dayjsInstance = dayjsInstance.locale(locale);
|
||||
}
|
||||
|
||||
// Apply timezone unless floating is true
|
||||
if (!floating) {
|
||||
dayjsInstance = dayjsInstance.tz(timezone);
|
||||
}
|
||||
// For floating times, we might still want to ensure consistency,
|
||||
// especially if the input could be a Z-suffixed ISO string.
|
||||
// Converting to UTC first standardizes it before formatting without tz.
|
||||
else {
|
||||
dayjsInstance = dayjsInstance.utc();
|
||||
}
|
||||
|
||||
return dayjsInstance.format(format ?? "LLLL");
|
||||
};
|
|
@ -1,3 +1,2 @@
|
|||
export * from "./timezone-context";
|
||||
export * from "./timezone-display";
|
||||
export * from "./timezone-utils";
|
||||
export * from "./client/context";
|
||||
export * from "./utils";
|
||||
|
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
// Initialize dayjs plugins
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
// Default to browser timezone if not specified
|
||||
const getBrowserTimezone = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
return "UTC"; // Default to UTC for server-side rendering
|
||||
};
|
||||
|
||||
type TimezoneContextType = {
|
||||
timezone: string;
|
||||
setTimezone: (timezone: string) => void;
|
||||
formatDate: (date: string | Date | dayjs.Dayjs, format?: string) => string;
|
||||
formatTime: (date: string | Date | dayjs.Dayjs, format?: string) => string;
|
||||
formatDateTime: (
|
||||
date: string | Date | dayjs.Dayjs,
|
||||
format?: string,
|
||||
) => string;
|
||||
};
|
||||
|
||||
const TimezoneContext = createContext<TimezoneContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const TimezoneProvider = ({
|
||||
initialTimezone,
|
||||
children,
|
||||
}: {
|
||||
initialTimezone?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
// Initialize with browser timezone, but allow user preference to override
|
||||
const [timezone, setTimezone] = useState<string>(() => {
|
||||
if (initialTimezone) {
|
||||
return initialTimezone;
|
||||
}
|
||||
// Try to get from localStorage first (user preference)
|
||||
if (typeof window !== "undefined") {
|
||||
const savedTimezone = localStorage.getItem("userTimezone");
|
||||
if (savedTimezone) {
|
||||
return savedTimezone;
|
||||
}
|
||||
}
|
||||
return getBrowserTimezone();
|
||||
});
|
||||
|
||||
// Save timezone preference to localStorage when it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("userTimezone", timezone);
|
||||
}
|
||||
}, [timezone]);
|
||||
|
||||
// Format functions that automatically use the current timezone
|
||||
const formatDate = (
|
||||
date: string | Date | dayjs.Dayjs,
|
||||
format = "YYYY-MM-DD",
|
||||
) => {
|
||||
return dayjs(date).tz(timezone).format(format);
|
||||
};
|
||||
|
||||
const formatTime = (date: string | Date | dayjs.Dayjs, format = "HH:mm") => {
|
||||
return dayjs(date).tz(timezone).format(format);
|
||||
};
|
||||
|
||||
const formatDateTime = (
|
||||
date: string | Date | dayjs.Dayjs,
|
||||
format = "YYYY-MM-DD HH:mm",
|
||||
) => {
|
||||
return dayjs(date).tz(timezone).format(format);
|
||||
};
|
||||
|
||||
const value = {
|
||||
timezone,
|
||||
setTimezone,
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
};
|
||||
|
||||
return (
|
||||
<TimezoneContext.Provider value={value}>
|
||||
{children}
|
||||
</TimezoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTimezone = () => {
|
||||
const context = useContext(TimezoneContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTimezone must be used within a TimezoneProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
|
@ -1,31 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type dayjs from "dayjs";
|
||||
|
||||
import { useTimezone } from "./timezone-context";
|
||||
|
||||
type DateDisplayProps = {
|
||||
date: string | Date | dayjs.Dayjs;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
export function DateDisplay({ date, format = "LL" }: DateDisplayProps) {
|
||||
const { formatDate } = useTimezone();
|
||||
return <span>{formatDate(date, format)}</span>;
|
||||
}
|
||||
|
||||
export function TimeDisplay({ date, format = "HH:mm" }: DateDisplayProps) {
|
||||
const { formatTime } = useTimezone();
|
||||
return <span>{formatTime(date, format)}</span>;
|
||||
}
|
||||
|
||||
export function DateTimeDisplay({ date, format = "LL, LT" }: DateDisplayProps) {
|
||||
const { formatDateTime } = useTimezone();
|
||||
return <span>{formatDateTime(date, format)}</span>;
|
||||
}
|
||||
|
||||
// Component to display the current timezone
|
||||
export function CurrentTimezone() {
|
||||
const { timezone } = useTimezone();
|
||||
return <span>{timezone}</span>;
|
||||
}
|
|
@ -6,6 +6,7 @@ import { nanoid } from "@rallly/utils/nanoid";
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import dayjs from "dayjs";
|
||||
import * as ics from "ics";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { moderateContent } from "@/features/moderation";
|
||||
|
@ -253,6 +254,8 @@ export const polls = router({
|
|||
}
|
||||
}
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return { id: poll.id };
|
||||
}),
|
||||
update: possiblyPublicProcedure
|
||||
|
@ -349,6 +352,7 @@ export const polls = router({
|
|||
requireParticipantEmail: input.requireParticipantEmail,
|
||||
},
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
}),
|
||||
delete: possiblyPublicProcedure
|
||||
.input(
|
||||
|
@ -362,6 +366,7 @@ export const polls = router({
|
|||
where: { id: pollId },
|
||||
data: { deleted: true, deletedAt: new Date() },
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
}),
|
||||
// END LEGACY ROUTES
|
||||
getWatchers: publicProcedure
|
||||
|
@ -519,9 +524,16 @@ export const polls = router({
|
|||
},
|
||||
participants: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
locale: true,
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
votes: {
|
||||
select: {
|
||||
optionId: true,
|
||||
|
@ -579,6 +591,41 @@ export const polls = router({
|
|||
},
|
||||
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,
|
||||
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"
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
event: {
|
||||
create: {
|
||||
optionId: input.optionId,
|
||||
|
@ -748,6 +795,8 @@ export const polls = router({
|
|||
days_since_created: dayjs().diff(poll.createdAt, "day"),
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
}),
|
||||
reopen: possiblyPublicProcedure
|
||||
|
@ -774,7 +823,16 @@ export const polls = router({
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (poll.scheduledEventId) {
|
||||
await prisma.scheduledEvent.delete({
|
||||
where: {
|
||||
id: poll.scheduledEventId,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
}),
|
||||
pause: possiblyPublicProcedure
|
||||
.input(
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "scheduled_event_status" AS ENUM ('confirmed', 'canceled', 'unconfirmed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "scheduled_event_invite_status" AS ENUM ('pending', 'accepted', 'declined', 'tentative');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "scheduled_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"location" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"status" "scheduled_event_status" NOT NULL DEFAULT 'confirmed',
|
||||
"time_zone" TEXT,
|
||||
"start" TIMESTAMP(3) NOT NULL,
|
||||
"end" TIMESTAMP(3) NOT NULL,
|
||||
"all_day" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "scheduled_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "rescheduled_event_dates" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scheduled_event_id" TEXT NOT NULL,
|
||||
"start" TIMESTAMP(3) NOT NULL,
|
||||
"end" TIMESTAMP(3) NOT NULL,
|
||||
"all_day" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "rescheduled_event_dates_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "scheduled_event_invites" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scheduled_event_id" TEXT NOT NULL,
|
||||
"invitee_name" TEXT NOT NULL,
|
||||
"invitee_email" TEXT NOT NULL,
|
||||
"invitee_id" TEXT,
|
||||
"invitee_time_zone" TEXT,
|
||||
"status" "scheduled_event_invite_status" NOT NULL DEFAULT 'pending',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "scheduled_event_invites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "rescheduled_event_dates_scheduled_event_id_idx" ON "rescheduled_event_dates"("scheduled_event_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduled_event_invites_scheduled_event_id_idx" ON "scheduled_event_invites"("scheduled_event_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduled_event_invites_invitee_id_idx" ON "scheduled_event_invites"("invitee_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "scheduled_event_invites_invitee_email_idx" ON "scheduled_event_invites"("invitee_email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "scheduled_event_invites_scheduled_event_id_invitee_email_key" ON "scheduled_event_invites"("scheduled_event_id", "invitee_email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "scheduled_event_invites_scheduled_event_id_invitee_id_key" ON "scheduled_event_invites"("scheduled_event_id", "invitee_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduled_events" ADD CONSTRAINT "scheduled_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "rescheduled_event_dates" ADD CONSTRAINT "rescheduled_event_dates_scheduled_event_id_fkey" FOREIGN KEY ("scheduled_event_id") REFERENCES "scheduled_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduled_event_invites" ADD CONSTRAINT "scheduled_event_invites_scheduled_event_id_fkey" FOREIGN KEY ("scheduled_event_id") REFERENCES "scheduled_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "scheduled_event_invites" ADD CONSTRAINT "scheduled_event_invites_invitee_id_fkey" FOREIGN KEY ("invitee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "polls" ADD COLUMN "scheduled_event_id" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "polls" ADD CONSTRAINT "polls_scheduled_event_id_fkey" FOREIGN KEY ("scheduled_event_id") REFERENCES "scheduled_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -0,0 +1,52 @@
|
|||
-- Step 1: Insert data from Event into ScheduledEvent
|
||||
-- Reuse Event ID for ScheduledEvent ID
|
||||
-- Calculate 'end': For all-day (duration 0), end is start + 1 day. Otherwise, calculate based on duration.
|
||||
-- Set 'all_day' based on 'duration_minutes'
|
||||
-- Fetch 'location' and 'time_zone' from the related Poll using event_id
|
||||
-- Set defaults for other fields
|
||||
INSERT INTO "scheduled_events" (
|
||||
"id",
|
||||
"user_id",
|
||||
"title",
|
||||
"description",
|
||||
"location",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"status",
|
||||
"time_zone",
|
||||
"start",
|
||||
"end",
|
||||
"all_day",
|
||||
"deleted_at"
|
||||
)
|
||||
SELECT
|
||||
e."id", -- Reuse Event ID
|
||||
e."user_id",
|
||||
e."title",
|
||||
NULL, -- Default description
|
||||
p."location", -- Get location from the related Poll
|
||||
e."created_at",
|
||||
NOW(), -- Set updated_at to current time
|
||||
'confirmed'::"scheduled_event_status", -- Default status 'confirmed'
|
||||
p."time_zone", -- Get timeZone from the related Poll
|
||||
e."start",
|
||||
-- Calculate 'end': If duration is 0 (all-day), set end to start + 1 day. Otherwise, calculate based on duration.
|
||||
CASE
|
||||
WHEN e."duration_minutes" = 0 THEN e."start" + interval '1 day'
|
||||
ELSE e."start" + (e."duration_minutes" * interval '1 minute')
|
||||
END,
|
||||
-- Set 'all_day': TRUE if duration is 0, FALSE otherwise
|
||||
CASE
|
||||
WHEN e."duration_minutes" = 0 THEN TRUE
|
||||
ELSE FALSE
|
||||
END,
|
||||
NULL -- Default deletedAt to NULL
|
||||
FROM
|
||||
"events" e
|
||||
LEFT JOIN "polls" p ON e."id" = p."event_id";
|
||||
-- Step 2: Update the polls table to link to the new scheduled_event_id
|
||||
-- Set scheduled_event_id = event_id where event_id was previously set
|
||||
-- Only update if the corresponding ScheduledEvent was successfully created in Step 1
|
||||
UPDATE "polls" p
|
||||
SET "scheduled_event_id" = p."event_id"
|
||||
WHERE p."event_id" IS NOT NULL;
|
|
@ -0,0 +1,88 @@
|
|||
-- migrate_event_votes_to_invites.sql V7
|
||||
-- Migrate participants with emails from polls linked to events with a selected winning option (event.optionId)
|
||||
-- into scheduled_event_invites for the corresponding scheduled_event (poll.scheduled_event_id).
|
||||
-- Map the participant's vote on the winning option to the invite status.
|
||||
-- Uses CTE with ROW_NUMBER() to handle potential duplicates based on email *and* user_id per scheduled event, preferring the most recent participant.
|
||||
-- Uses NOT EXISTS in WHERE clause to avoid inserting invites if they already exist from other sources.
|
||||
-- Reuses the participant's unique ID (pt.id) as the invite ID for this migration.
|
||||
-- Excludes participants with NULL or empty string emails.
|
||||
|
||||
WITH PotentialInvites AS (
|
||||
SELECT
|
||||
pt.id as participant_id, -- Keep original participant ID for reuse
|
||||
p.scheduled_event_id,
|
||||
pt.name as invitee_name,
|
||||
pt.email as invitee_email,
|
||||
pt.user_id as invitee_id,
|
||||
u.time_zone as invitee_time_zone,
|
||||
v.type as vote_type,
|
||||
pt.created_at as participant_created_at,
|
||||
-- Assign row number partitioned by event and email, preferring most recent participant
|
||||
ROW_NUMBER() OVER(PARTITION BY p.scheduled_event_id, pt.email ORDER BY pt.created_at DESC) as rn_email,
|
||||
-- Assign row number partitioned by event and user_id (if not null), preferring most recent participant
|
||||
CASE
|
||||
WHEN pt.user_id IS NOT NULL THEN ROW_NUMBER() OVER(PARTITION BY p.scheduled_event_id, pt.user_id ORDER BY pt.created_at DESC)
|
||||
ELSE NULL
|
||||
END as rn_user
|
||||
FROM
|
||||
events e
|
||||
JOIN
|
||||
polls p ON e.id = p.event_id
|
||||
JOIN
|
||||
participants pt ON p.id = pt.poll_id
|
||||
LEFT JOIN
|
||||
votes v ON pt.id = v.participant_id AND e.option_id = v.option_id
|
||||
LEFT JOIN
|
||||
users u ON pt.user_id = u.id
|
||||
WHERE
|
||||
e.option_id IS NOT NULL
|
||||
AND p.scheduled_event_id IS NOT NULL
|
||||
AND pt.email IS NOT NULL
|
||||
AND pt.email != ''
|
||||
AND pt.deleted = false
|
||||
)
|
||||
INSERT INTO scheduled_event_invites (
|
||||
id,
|
||||
scheduled_event_id,
|
||||
invitee_name,
|
||||
invitee_email,
|
||||
invitee_id,
|
||||
invitee_time_zone,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
pi.participant_id as id, -- Reuse participant's unique CUID as invite ID
|
||||
pi.scheduled_event_id,
|
||||
pi.invitee_name,
|
||||
pi.invitee_email,
|
||||
pi.invitee_id,
|
||||
pi.invitee_time_zone,
|
||||
CASE pi.vote_type
|
||||
WHEN 'yes' THEN 'accepted'::scheduled_event_invite_status
|
||||
WHEN 'ifNeedBe' THEN 'tentative'::scheduled_event_invite_status
|
||||
WHEN 'no' THEN 'declined'::scheduled_event_invite_status
|
||||
ELSE 'pending'::scheduled_event_invite_status
|
||||
END as status,
|
||||
NOW() as created_at,
|
||||
NOW() as updated_at
|
||||
FROM
|
||||
PotentialInvites pi
|
||||
WHERE
|
||||
pi.rn_email = 1 -- Only take the first row for each email/event combo
|
||||
AND (pi.invitee_id IS NULL OR pi.rn_user = 1) -- Only take the first row for each user_id/event combo (if user_id exists)
|
||||
-- Check for existing invite by email for the same scheduled event
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scheduled_event_invites sei
|
||||
WHERE sei.scheduled_event_id = pi.scheduled_event_id
|
||||
AND sei.invitee_email = pi.invitee_email
|
||||
)
|
||||
-- Check for existing invite by user ID for the same scheduled event (only if participant has a user ID)
|
||||
AND (pi.invitee_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM scheduled_event_invites sei
|
||||
WHERE sei.scheduled_event_id = pi.scheduled_event_id
|
||||
AND sei.invitee_id = pi.invitee_id
|
||||
));
|
|
@ -5,8 +5,8 @@ datasource db {
|
|||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native"]
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native"]
|
||||
previewFeatures = ["relationJoins"]
|
||||
}
|
||||
|
||||
|
@ -38,31 +38,33 @@ model Account {
|
|||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique() @db.Citext
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
image String?
|
||||
timeZone String? @map("time_zone")
|
||||
weekStart Int? @map("week_start")
|
||||
timeFormat TimeFormat? @map("time_format")
|
||||
locale String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
customerId String? @map("customer_id")
|
||||
banned Boolean @default(false)
|
||||
bannedAt DateTime? @map("banned_at")
|
||||
banReason String? @map("ban_reason")
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique() @db.Citext
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
image String?
|
||||
timeZone String? @map("time_zone")
|
||||
weekStart Int? @map("week_start")
|
||||
timeFormat TimeFormat? @map("time_format")
|
||||
locale String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime? @updatedAt @map("updated_at")
|
||||
customerId String? @map("customer_id")
|
||||
banned Boolean @default(false)
|
||||
bannedAt DateTime? @map("banned_at")
|
||||
banReason String? @map("ban_reason")
|
||||
|
||||
comments Comment[]
|
||||
polls Poll[]
|
||||
watcher Watcher[]
|
||||
events Event[]
|
||||
accounts Account[]
|
||||
participants Participant[]
|
||||
paymentMethods PaymentMethod[]
|
||||
subscription Subscription? @relation("UserToSubscription")
|
||||
pollViews PollView[]
|
||||
comments Comment[]
|
||||
polls Poll[]
|
||||
watcher Watcher[]
|
||||
events Event[]
|
||||
accounts Account[]
|
||||
participants Participant[]
|
||||
paymentMethods PaymentMethod[]
|
||||
subscription Subscription? @relation("UserToSubscription")
|
||||
pollViews PollView[]
|
||||
scheduledEvents ScheduledEvent[]
|
||||
scheduledEventInvites ScheduledEventInvite[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
@ -155,19 +157,21 @@ model Poll {
|
|||
participantUrlId String @unique @map("participant_url_id")
|
||||
adminUrlId String @unique @map("admin_url_id")
|
||||
eventId String? @unique @map("event_id")
|
||||
scheduledEventId String? @map("scheduled_event_id")
|
||||
hideParticipants Boolean @default(false) @map("hide_participants")
|
||||
hideScores Boolean @default(false) @map("hide_scores")
|
||||
disableComments Boolean @default(false) @map("disable_comments")
|
||||
requireParticipantEmail Boolean @default(false) @map("require_participant_email")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
options Option[]
|
||||
participants Participant[]
|
||||
watchers Watcher[]
|
||||
comments Comment[]
|
||||
votes Vote[]
|
||||
views PollView[]
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
|
||||
scheduledEvent ScheduledEvent? @relation(fields: [scheduledEventId], references: [id], onDelete: SetNull)
|
||||
options Option[]
|
||||
participants Participant[]
|
||||
watchers Watcher[]
|
||||
comments Comment[]
|
||||
votes Vote[]
|
||||
views PollView[]
|
||||
|
||||
@@index([guestId])
|
||||
@@map("polls")
|
||||
|
@ -293,7 +297,7 @@ model PollView {
|
|||
userAgent String? @map("user_agent")
|
||||
viewedAt DateTime @default(now()) @map("viewed_at")
|
||||
|
||||
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||
poll Poll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([pollId], type: Hash)
|
||||
|
@ -310,3 +314,79 @@ model VerificationToken {
|
|||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
enum ScheduledEventStatus {
|
||||
confirmed
|
||||
canceled
|
||||
unconfirmed
|
||||
|
||||
@@map("scheduled_event_status")
|
||||
}
|
||||
|
||||
enum ScheduledEventInviteStatus {
|
||||
pending
|
||||
accepted
|
||||
declined
|
||||
tentative
|
||||
|
||||
@@map("scheduled_event_invite_status")
|
||||
}
|
||||
|
||||
model ScheduledEvent {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
status ScheduledEventStatus @default(confirmed)
|
||||
timeZone String? @map("time_zone")
|
||||
start DateTime
|
||||
end DateTime
|
||||
allDay Boolean @default(false) @map("all_day")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
rescheduledDates RescheduledEventDate[]
|
||||
invites ScheduledEventInvite[]
|
||||
polls Poll[]
|
||||
|
||||
@@map("scheduled_events")
|
||||
}
|
||||
|
||||
model RescheduledEventDate {
|
||||
id String @id @default(cuid())
|
||||
scheduledEventId String @map("scheduled_event_id")
|
||||
start DateTime @map("start")
|
||||
end DateTime @map("end")
|
||||
allDay Boolean @default(false) @map("all_day")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
scheduledEvent ScheduledEvent @relation(fields: [scheduledEventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([scheduledEventId])
|
||||
@@map("rescheduled_event_dates")
|
||||
}
|
||||
|
||||
model ScheduledEventInvite {
|
||||
id String @id @default(cuid())
|
||||
scheduledEventId String @map("scheduled_event_id")
|
||||
inviteeName String @map("invitee_name")
|
||||
inviteeEmail String @map("invitee_email")
|
||||
inviteeId String? @map("invitee_id")
|
||||
inviteeTimeZone String? @map("invitee_time_zone")
|
||||
status ScheduledEventInviteStatus @default(pending)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
scheduledEvent ScheduledEvent @relation(fields: [scheduledEventId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [inviteeId], references: [id], onDelete: SetNull) // Optional relation to User model
|
||||
|
||||
@@unique([scheduledEventId, inviteeEmail])
|
||||
@@unique([scheduledEventId, inviteeId])
|
||||
@@index([scheduledEventId])
|
||||
@@index([inviteeId])
|
||||
@@index([inviteeEmail])
|
||||
@@map("scheduled_event_invites")
|
||||
}
|
||||
|
|
|
@ -1,156 +1,17 @@
|
|||
import { faker } from "@faker-js/faker";
|
||||
import { PrismaClient, VoteType } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { seedPolls } from "./seed/polls";
|
||||
import { seedScheduledEvents } from "./seed/scheduled-events";
|
||||
import { seedUsers } from "./seed/users";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const randInt = (max = 1, floor = 0) => {
|
||||
return Math.round(Math.random() * max) + floor;
|
||||
};
|
||||
|
||||
function generateTitle() {
|
||||
const titleTemplates = [
|
||||
() => `${faker.company.catchPhrase()} Meeting`,
|
||||
() => `${faker.commerce.department()} Team Sync`,
|
||||
() => `Q${faker.datatype.number({ min: 1, max: 4 })} Planning`,
|
||||
() => `${faker.name.jobArea()} Workshop`,
|
||||
() => `Project ${faker.word.adjective()} Update`,
|
||||
() => `${faker.company.bsBuzz()} Strategy Session`,
|
||||
() => faker.company.catchPhrase(),
|
||||
() => `${faker.name.jobType()} Interview`,
|
||||
() => `${faker.commerce.productAdjective()} Product Review`,
|
||||
() => `Team ${faker.word.verb()} Day`,
|
||||
];
|
||||
|
||||
return faker.helpers.arrayElement(titleTemplates)();
|
||||
}
|
||||
|
||||
async function createPollForUser(userId: string) {
|
||||
const duration = 60 * randInt(8);
|
||||
let cursor = dayjs().add(randInt(30), "day").second(0).minute(0);
|
||||
const numberOfOptions = randInt(5, 2); // Reduced for realism
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
include: {
|
||||
participants: true,
|
||||
options: true,
|
||||
},
|
||||
data: {
|
||||
id: faker.random.alpha(10),
|
||||
title: generateTitle(),
|
||||
description: generateDescription(),
|
||||
location: faker.address.streetAddress(),
|
||||
deadline: faker.date.future(),
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
status: faker.helpers.arrayElement(["live", "paused", "finalized"]),
|
||||
timeZone: duration !== 0 ? "Europe/London" : undefined,
|
||||
options: {
|
||||
create: Array.from({ length: numberOfOptions }).map(() => {
|
||||
const startTime = cursor.toDate();
|
||||
cursor = cursor.add(randInt(72, 1), "hour");
|
||||
return {
|
||||
startTime,
|
||||
duration,
|
||||
};
|
||||
}),
|
||||
},
|
||||
participants: {
|
||||
create: Array.from({ length: Math.round(Math.random() * 10) }).map(
|
||||
() => ({
|
||||
name: faker.name.fullName(),
|
||||
email: faker.internet.email(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
adminUrlId: faker.random.alpha(10),
|
||||
participantUrlId: faker.random.alpha(10),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate vote data for all participants and options
|
||||
const voteData = poll.participants.flatMap((participant) =>
|
||||
poll.options.map((option) => ({
|
||||
id: faker.random.alpha(10),
|
||||
optionId: option.id,
|
||||
participantId: participant.id,
|
||||
pollId: poll.id,
|
||||
type: faker.helpers.arrayElement(["yes", "no", "ifNeedBe"]) as VoteType,
|
||||
})),
|
||||
);
|
||||
|
||||
// Create all votes in a single query
|
||||
await prisma.vote.createMany({
|
||||
data: voteData,
|
||||
});
|
||||
|
||||
return poll;
|
||||
}
|
||||
|
||||
// Function to generate realistic descriptions
|
||||
function generateDescription() {
|
||||
const descriptions = [
|
||||
"Discuss the quarterly results and strategize for the upcoming quarter. Please come prepared with your reports.",
|
||||
"Team meeting to align on project goals and timelines. Bring your ideas and feedback.",
|
||||
"An informal catch-up to discuss ongoing projects and any roadblocks. Open to all team members.",
|
||||
"Monthly review of our marketing strategies and performance metrics. Let's brainstorm new ideas.",
|
||||
"A brief meeting to go over the new software updates and how they will impact our workflow.",
|
||||
"Discussion on the upcoming product launch and marketing strategies. Your input is valuable!",
|
||||
"Weekly sync to check in on project progress and address any concerns. Please be on time.",
|
||||
"A brainstorming session for the new campaign. All creative minds are welcome!",
|
||||
"Review of the last sprint and planning for the next one. Let's ensure we're on track.",
|
||||
"An open forum for team members to share updates and challenges. Everyone is encouraged to speak up.",
|
||||
];
|
||||
|
||||
// Randomly select a description
|
||||
return faker.helpers.arrayElement(descriptions);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Create some users
|
||||
// Create some users and polls
|
||||
const freeUser = await prisma.user.create({
|
||||
data: {
|
||||
id: "free-user",
|
||||
name: "Dev User",
|
||||
email: "dev@rallly.co",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
});
|
||||
const users = await seedUsers();
|
||||
|
||||
const proUser = await prisma.user.create({
|
||||
data: {
|
||||
id: "pro-user",
|
||||
name: "Pro User",
|
||||
email: "dev+pro@rallly.co",
|
||||
subscription: {
|
||||
create: {
|
||||
id: "sub_123",
|
||||
currency: "usd",
|
||||
amount: 700,
|
||||
interval: "month",
|
||||
status: "active",
|
||||
active: true,
|
||||
priceId: "price_123",
|
||||
periodStart: new Date(),
|
||||
periodEnd: dayjs().add(1, "month").toDate(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
[freeUser, proUser].map(async (user) => {
|
||||
Array.from({ length: 20 }).forEach(async () => {
|
||||
await createPollForUser(user.id);
|
||||
});
|
||||
console.info(`✓ Added ${user.email}`);
|
||||
}),
|
||||
);
|
||||
console.info(`✓ Added polls for ${freeUser.email}`);
|
||||
for (const user of users) {
|
||||
await seedPolls(user.id);
|
||||
await seedScheduledEvents(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
119
packages/database/prisma/seed/polls.ts
Normal file
119
packages/database/prisma/seed/polls.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
import { faker } from "@faker-js/faker";
|
||||
import type { User } from "@prisma/client";
|
||||
import { VoteType } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
import { randInt } from "./utils";
|
||||
|
||||
function generateTitle() {
|
||||
const titleTemplates = [
|
||||
() => `${faker.company.catchPhrase()} Meeting`,
|
||||
() => `${faker.commerce.department()} Team Sync`,
|
||||
() => `Q${faker.datatype.number({ min: 1, max: 4 })} Planning`,
|
||||
() => `${faker.name.jobArea()} Workshop`,
|
||||
() => `Project ${faker.word.adjective()} Update`,
|
||||
() => `${faker.company.bsBuzz()} Strategy Session`,
|
||||
() => faker.company.catchPhrase(),
|
||||
() => `${faker.name.jobType()} Interview`,
|
||||
() => `${faker.commerce.productAdjective()} Product Review`,
|
||||
() => `Team ${faker.word.verb()} Day`,
|
||||
];
|
||||
|
||||
return faker.helpers.arrayElement(titleTemplates)();
|
||||
}
|
||||
|
||||
// Function to generate realistic descriptions
|
||||
function generateDescription() {
|
||||
const descriptions = [
|
||||
"Discuss the quarterly results and strategize for the upcoming quarter. Please come prepared with your reports.",
|
||||
"Team meeting to align on project goals and timelines. Bring your ideas and feedback.",
|
||||
"An informal catch-up to discuss ongoing projects and any roadblocks. Open to all team members.",
|
||||
"Monthly review of our marketing strategies and performance metrics. Let's brainstorm new ideas.",
|
||||
"A brief meeting to go over the new software updates and how they will impact our workflow.",
|
||||
"Discussion on the upcoming product launch and marketing strategies. Your input is valuable!",
|
||||
"Weekly sync to check in on project progress and address any concerns. Please be on time.",
|
||||
"A brainstorming session for the new campaign. All creative minds are welcome!",
|
||||
"Review of the last sprint and planning for the next one. Let's ensure we're on track.",
|
||||
"An open forum for team members to share updates and challenges. Everyone is encouraged to speak up.",
|
||||
];
|
||||
|
||||
// Randomly select a description
|
||||
return faker.helpers.arrayElement(descriptions);
|
||||
}
|
||||
|
||||
async function createPollForUser(userId: string) {
|
||||
const duration = 60 * randInt(8);
|
||||
let cursor = dayjs().add(randInt(30), "day").second(0).minute(0);
|
||||
const numberOfOptions = randInt(5, 2); // Reduced for realism
|
||||
|
||||
const poll = await prisma.poll.create({
|
||||
include: {
|
||||
participants: true,
|
||||
options: true,
|
||||
},
|
||||
data: {
|
||||
id: faker.random.alpha(10),
|
||||
title: generateTitle(),
|
||||
description: generateDescription(),
|
||||
location: faker.address.streetAddress(),
|
||||
deadline: faker.date.future(),
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
status: faker.helpers.arrayElement(["live", "paused", "finalized"]),
|
||||
timeZone: duration !== 0 ? "Europe/London" : undefined,
|
||||
options: {
|
||||
create: Array.from({ length: numberOfOptions }).map(() => {
|
||||
const startTime = cursor.toDate();
|
||||
cursor = cursor.add(randInt(72, 1), "hour");
|
||||
return {
|
||||
startTime,
|
||||
duration,
|
||||
};
|
||||
}),
|
||||
},
|
||||
participants: {
|
||||
create: Array.from({ length: randInt(10) }).map(() => ({
|
||||
name: faker.name.fullName(),
|
||||
email: faker.internet.email(),
|
||||
})),
|
||||
},
|
||||
adminUrlId: faker.random.alpha(10),
|
||||
participantUrlId: faker.random.alpha(10),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate vote data for all participants and options
|
||||
const voteData = poll.participants.flatMap((participant) =>
|
||||
poll.options.map((option) => ({
|
||||
id: faker.random.alpha(10),
|
||||
optionId: option.id,
|
||||
participantId: participant.id,
|
||||
pollId: poll.id,
|
||||
type: faker.helpers.arrayElement(["yes", "no", "ifNeedBe"]) as VoteType,
|
||||
})),
|
||||
);
|
||||
|
||||
// Create all votes in a single query
|
||||
if (voteData.length > 0) {
|
||||
await prisma.vote.createMany({
|
||||
data: voteData,
|
||||
});
|
||||
}
|
||||
|
||||
return poll;
|
||||
}
|
||||
|
||||
export async function seedPolls(userId: string) {
|
||||
console.info("Seeding polls...");
|
||||
const pollPromises = Array.from({ length: 20 }).map(() =>
|
||||
createPollForUser(userId),
|
||||
);
|
||||
|
||||
await Promise.all(pollPromises);
|
||||
|
||||
console.info(`✓ Seeded polls for ${userId}`);
|
||||
}
|
144
packages/database/prisma/seed/scheduled-events.ts
Normal file
144
packages/database/prisma/seed/scheduled-events.ts
Normal file
|
@ -0,0 +1,144 @@
|
|||
import { ScheduledEventInviteStatus } from "@prisma/client";
|
||||
import { Prisma, ScheduledEventStatus } from "@prisma/client"; // Ensure Prisma is imported
|
||||
import dayjs from "dayjs";
|
||||
import { faker } from "@faker-js/faker";
|
||||
|
||||
import { prisma } from "@rallly/database";
|
||||
import { randInt } from "./utils";
|
||||
|
||||
// Realistic event titles and descriptions
|
||||
function generateEventDetails() {
|
||||
const titles = [
|
||||
"Team Sync Meeting",
|
||||
"Product Strategy Session",
|
||||
"Design Review",
|
||||
"Engineering Standup",
|
||||
"Client Check-in Call",
|
||||
"Marketing Campaign Kickoff",
|
||||
"Sales Pipeline Review",
|
||||
"HR Training Workshop",
|
||||
"Finance Budget Planning",
|
||||
"All Hands Company Update",
|
||||
"Sprint Retrospective",
|
||||
"User Research Debrief",
|
||||
"Technical Deep Dive",
|
||||
"Content Calendar Planning",
|
||||
"Partnership Discussion",
|
||||
];
|
||||
const descriptions = [
|
||||
"Discussing project updates and blockers.",
|
||||
"Aligning on the product roadmap for the next quarter.",
|
||||
"Gathering feedback on the latest UI mockups.",
|
||||
"Quick daily updates from the engineering team.",
|
||||
"Reviewing progress and addressing client concerns.",
|
||||
"Launching the new social media campaign.",
|
||||
"Analyzing the current sales funnel and opportunities.",
|
||||
"Mandatory compliance training session.",
|
||||
"Meeting to finalize budget decisions across different departments.",
|
||||
"Sharing company performance and upcoming goals.",
|
||||
"Reflecting on the past sprint, celebrating successes, and identifying areas for improvement.",
|
||||
"Sharing key insights gathered from recent user research sessions.",
|
||||
"Exploring the architecture of the new microservice.",
|
||||
"Planning blog posts and social media content for the month.",
|
||||
"Exploring potential collaboration opportunities with external partners.",
|
||||
"Team building activity to foster collaboration.",
|
||||
"Workshop on improving presentation skills.",
|
||||
"Onboarding session for new hires.",
|
||||
"Reviewing customer feedback and planning improvements.",
|
||||
"Brainstorming session for new feature ideas.",
|
||||
];
|
||||
|
||||
return {
|
||||
title: faker.helpers.arrayElement(titles),
|
||||
description: faker.helpers.arrayElement(descriptions),
|
||||
};
|
||||
}
|
||||
|
||||
async function createScheduledEventForUser(userId: string) {
|
||||
const { title, description } = generateEventDetails();
|
||||
const isAllDay = Math.random() < 0.3; // ~30% chance of being all-day
|
||||
|
||||
let startTime: Date;
|
||||
let endTime: Date | null;
|
||||
|
||||
if (isAllDay) {
|
||||
const startDate = dayjs(
|
||||
faker.datatype.boolean() ? faker.date.past(1) : faker.date.soon(30),
|
||||
)
|
||||
.startOf("day")
|
||||
.toDate();
|
||||
startTime = startDate;
|
||||
|
||||
// Decide if it's a multi-day event
|
||||
const isMultiDay = Math.random() < 0.2; // ~20% chance of multi-day
|
||||
if (isMultiDay) {
|
||||
const durationDays = faker.datatype.number({ min: 1, max: 3 });
|
||||
// End date is the start of the day *after* the last full day
|
||||
endTime = dayjs(startDate)
|
||||
.add(durationDays + 1, "day")
|
||||
.toDate();
|
||||
} else {
|
||||
// Single all-day event ends at the start of the next day
|
||||
endTime = dayjs(startDate).add(1, "day").toDate();
|
||||
}
|
||||
} else {
|
||||
// Generate times for non-all-day events
|
||||
startTime = dayjs(
|
||||
faker.datatype.boolean() ? faker.date.past(1) : faker.date.soon(30),
|
||||
)
|
||||
.second(faker.helpers.arrayElement([0, 15, 30, 45])) // Add some variance
|
||||
.minute(faker.helpers.arrayElement([0, 15, 30, 45]))
|
||||
.hour(faker.datatype.number({ min: 8, max: 20 })) // Wider range for hours
|
||||
.toDate();
|
||||
const durationMinutes = faker.helpers.arrayElement([30, 60, 90, 120, 180]); // Longer durations possible
|
||||
endTime = dayjs(startTime).add(durationMinutes, "minute").toDate();
|
||||
}
|
||||
|
||||
// Use only valid statuses from the schema
|
||||
const status = faker.helpers.arrayElement<ScheduledEventStatus>([
|
||||
ScheduledEventStatus.confirmed,
|
||||
ScheduledEventStatus.canceled,
|
||||
]);
|
||||
const timeZone = faker.address.timeZone();
|
||||
|
||||
const data: Prisma.ScheduledEventCreateInput = {
|
||||
title,
|
||||
description,
|
||||
start: startTime, // Use correct model field name 'start'
|
||||
end: endTime, // Use correct model field name 'end'
|
||||
timeZone,
|
||||
status, // Assign the randomly selected valid status
|
||||
user: { connect: { id: userId } }, // Connect to existing user
|
||||
allDay: isAllDay,
|
||||
location: faker.datatype.boolean()
|
||||
? faker.address.streetAddress()
|
||||
: undefined,
|
||||
// Add invites (optional, example below)
|
||||
invites: {
|
||||
create: Array.from({ length: randInt(5, 0) }).map(() => ({
|
||||
inviteeEmail: faker.internet.email(),
|
||||
inviteeName: faker.name.fullName(),
|
||||
inviteeTimeZone: faker.address.timeZone(),
|
||||
status: faker.helpers.arrayElement<ScheduledEventInviteStatus>([
|
||||
"accepted",
|
||||
"declined",
|
||||
"tentative",
|
||||
"pending",
|
||||
]),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.scheduledEvent.create({ data });
|
||||
}
|
||||
|
||||
export async function seedScheduledEvents(userId: string) {
|
||||
console.info("Seeding scheduled events...");
|
||||
const eventPromises = Array.from({ length: 15 }).map((_, i) =>
|
||||
createScheduledEventForUser(userId),
|
||||
);
|
||||
|
||||
await Promise.all(eventPromises);
|
||||
|
||||
console.info(`✓ Seeded scheduled events for ${userId}`);
|
||||
}
|
43
packages/database/prisma/seed/users.ts
Normal file
43
packages/database/prisma/seed/users.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import dayjs from "dayjs";
|
||||
import { prisma } from "@rallly/database";
|
||||
|
||||
export async function seedUsers() {
|
||||
console.info("Seeding users...");
|
||||
const freeUser = await prisma.user.upsert({
|
||||
where: { email: "dev@rallly.co" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "free-user",
|
||||
name: "Dev User",
|
||||
email: "dev@rallly.co",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
});
|
||||
|
||||
const proUser = await prisma.user.upsert({
|
||||
where: { email: "dev+pro@rallly.co" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "pro-user",
|
||||
name: "Pro User",
|
||||
email: "dev+pro@rallly.co",
|
||||
subscription: {
|
||||
create: {
|
||||
id: "sub_123",
|
||||
currency: "usd",
|
||||
amount: 700,
|
||||
interval: "month",
|
||||
status: "active",
|
||||
active: true,
|
||||
priceId: "price_123",
|
||||
periodStart: new Date(),
|
||||
periodEnd: dayjs().add(1, "month").toDate(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
console.info(`✓ Seeded user ${freeUser.email}`);
|
||||
console.info(`✓ Seeded user ${proUser.email}`);
|
||||
|
||||
return [freeUser, proUser];
|
||||
}
|
9
packages/database/prisma/seed/utils.ts
Normal file
9
packages/database/prisma/seed/utils.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Generates a random integer between floor and max (inclusive).
|
||||
* @param max The maximum value.
|
||||
* @param floor The minimum value (default: 0).
|
||||
* @returns A random integer.
|
||||
*/
|
||||
export const randInt = (max = 1, floor = 0): number => {
|
||||
return Math.round(Math.random() * max) + floor;
|
||||
};
|
Loading…
Add table
Reference in a new issue