diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json
index 9dd127eb2..67f1900f9 100644
--- a/apps/web/public/locales/en/app.json
+++ b/apps/web/public/locales/en/app.json
@@ -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."
}
diff --git a/apps/web/src/app/[locale]/(space)/events/event-list.tsx b/apps/web/src/app/[locale]/(space)/events/event-list.tsx
deleted file mode 100644
index c22c6496a..000000000
--- a/apps/web/src/app/[locale]/(space)/events/event-list.tsx
+++ /dev/null
@@ -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 (
-
-
- {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 (
- -
-
-
-
-
-
-
-
-
-
- {row.original.title}
-
-
-
- {row.original.duration === 0 ? (
-
- ) : (
- {`${start.format("LT")} - ${end.format("LT")}`}
- )}
-
-
-
-
- );
- })}
-
-
- );
-}
diff --git a/apps/web/src/app/[locale]/(space)/events/events-tabbed-view.tsx b/apps/web/src/app/[locale]/(space)/events/events-tabbed-view.tsx
new file mode 100644
index 000000000..65a7cc4e4
--- /dev/null
+++ b/apps/web/src/app/[locale]/(space)/events/events-tabbed-view.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/app/[locale]/(space)/events/page.tsx b/apps/web/src/app/[locale]/(space)/events/page.tsx
index de14d0388..4b09e791f 100644
--- a/apps/web/src/app/[locale]/(space)/events/page.tsx
+++ b/apps/web/src/app/[locale]/(space)/events/page.tsx
@@ -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 (
+
+
+
+
+ {title}
+ {description}
+
+ );
+}
+
+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 (
@@ -29,7 +119,39 @@ export default async function Page({ params }: { params: Params }) {
-
+
+
+
+
+ {scheduledEvents.length === 0 && (
+
+ )}
+ {scheduledEvents.length > 0 && (
+
+ {scheduledEvents.map((event) => (
+
+
+
+ ))}
+
+ )}
+
+
+
);
diff --git a/apps/web/src/app/[locale]/(space)/events/past-events.tsx b/apps/web/src/app/[locale]/(space)/events/past-events.tsx
deleted file mode 100644
index b4d67cb0d..000000000
--- a/apps/web/src/app/[locale]/(space)/events/past-events.tsx
+++ /dev/null
@@ -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 ;
- }
-
- if (data.length === 0) {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
- return ;
-}
diff --git a/apps/web/src/app/[locale]/(space)/events/upcoming-events.tsx b/apps/web/src/app/[locale]/(space)/events/upcoming-events.tsx
deleted file mode 100644
index 39be20d21..000000000
--- a/apps/web/src/app/[locale]/(space)/events/upcoming-events.tsx
+++ /dev/null
@@ -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 ;
- }
-
- if (data.length === 0) {
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
- return ;
-}
diff --git a/apps/web/src/app/[locale]/(space)/events/user-scheduled-events.tsx b/apps/web/src/app/[locale]/(space)/events/user-scheduled-events.tsx
deleted file mode 100644
index 04b6bb897..000000000
--- a/apps/web/src/app/[locale]/(space)/events/user-scheduled-events.tsx
+++ /dev/null
@@ -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 (
-
-
{
- const params = new URLSearchParams(searchParams);
- params.set("period", value);
- const newUrl = `?${params.toString()}`;
- router.replace(newUrl);
- }}
- aria-label="Event period"
- >
-
-
-
-
-
-
-
-
-
-
- {period === "upcoming" &&
}
- {period === "past" &&
}
-
-
- );
-}
diff --git a/apps/web/src/app/[locale]/(space)/layout.tsx b/apps/web/src/app/[locale]/(space)/layout.tsx
index 1d18cff48..ac55bb1e6 100644
--- a/apps/web/src/app/[locale]/(space)/layout.tsx
+++ b/apps/web/src/app/[locale]/(space)/layout.tsx
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx b/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx
index 5fa936089..a4941e133 100644
--- a/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx
+++ b/apps/web/src/app/[locale]/(space)/settings/billing/page.tsx
@@ -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 ? (
"-"
) : (
-
+
)}
diff --git a/apps/web/src/app/[locale]/layout.tsx b/apps/web/src/app/[locale]/layout.tsx
index e61aa7981..29cefcb15 100644
--- a/apps/web/src/app/[locale]/layout.tsx
+++ b/apps/web/src/app/[locale]/layout.tsx
@@ -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";
diff --git a/apps/web/src/components/formatted-date.tsx b/apps/web/src/components/formatted-date.tsx
deleted file mode 100644
index 6bde0531f..000000000
--- a/apps/web/src/components/formatted-date.tsx
+++ /dev/null
@@ -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)}>;
-}
diff --git a/apps/web/src/components/poll/scheduled-event-display.tsx b/apps/web/src/components/poll/scheduled-event-display.tsx
deleted file mode 100644
index 2aad075dc..000000000
--- a/apps/web/src/components/poll/scheduled-event-display.tsx
+++ /dev/null
@@ -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 (
-
-
- {event.duration > 0 ? (
-
- ) : (
-
- )}
-
-
- );
- }
-
- if (!dateOptions?.first || !dateOptions?.last || dateOptions.count === 0) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- );
-};
diff --git a/apps/web/src/features/scheduled-event/api/get-scheduled-events.ts b/apps/web/src/features/scheduled-event/api/get-scheduled-events.ts
new file mode 100644
index 000000000..1c77cf2a6
--- /dev/null
+++ b/apps/web/src/features/scheduled-event/api/get-scheduled-events.ts
@@ -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;
+}
diff --git a/apps/web/src/features/scheduled-event/components/scheduled-event-list.tsx b/apps/web/src/features/scheduled-event/components/scheduled-event-list.tsx
new file mode 100644
index 000000000..6e13cc48e
--- /dev/null
+++ b/apps/web/src/features/scheduled-event/components/scheduled-event-list.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {allDay ? (
+
+ ) : (
+
+
+ -
+
+
+ )}
+
+
+
+
+
+
+
({
+ id: invite.id,
+ name: invite.inviteeName,
+ image: invite.inviteeImage ?? undefined,
+ }))}
+ max={5}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/features/scheduled-event/components/scheduled-event-status-badge.tsx b/apps/web/src/features/scheduled-event/components/scheduled-event-status-badge.tsx
new file mode 100644
index 000000000..7c70a4313
--- /dev/null
+++ b/apps/web/src/features/scheduled-event/components/scheduled-event-status-badge.tsx
@@ -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 (
+
+
+
+ );
+ case "upcoming":
+ return null;
+ case "canceled":
+ return (
+
+
+
+ );
+ case "unconfirmed":
+ return (
+
+
+
+ );
+ }
+}
diff --git a/apps/web/src/features/scheduled-event/schema.ts b/apps/web/src/features/scheduled-event/schema.ts
new file mode 100644
index 000000000..d6b350264
--- /dev/null
+++ b/apps/web/src/features/scheduled-event/schema.ts
@@ -0,0 +1,10 @@
+import { z } from "zod";
+
+export const statusSchema = z.enum([
+ "upcoming",
+ "unconfirmed",
+ "past",
+ "canceled",
+]);
+
+export type Status = z.infer;
diff --git a/apps/web/src/features/timezone/client/calendar-date.tsx b/apps/web/src/features/timezone/client/calendar-date.tsx
new file mode 100644
index 000000000..dc024539a
--- /dev/null
+++ b/apps/web/src/features/timezone/client/calendar-date.tsx
@@ -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 (
+
+ );
+}
diff --git a/apps/web/src/features/timezone/client/context.tsx b/apps/web/src/features/timezone/client/context.tsx
new file mode 100644
index 000000000..47a3fa9db
--- /dev/null
+++ b/apps/web/src/features/timezone/client/context.tsx
@@ -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(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 (
+
+ {children}
+
+ );
+};
+
+export const useTimezone = () => {
+ const context = React.useContext(TimezoneContext);
+ if (context === null) {
+ throw new Error("useTimezone must be used within a TimezoneProvider");
+ }
+ return context;
+};
diff --git a/apps/web/src/features/timezone/client/formatted-date-time.tsx b/apps/web/src/features/timezone/client/formatted-date-time.tsx
new file mode 100644
index 000000000..0fcabdd99
--- /dev/null
+++ b/apps/web/src/features/timezone/client/formatted-date-time.tsx
@@ -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 {
+ /** 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 (
+
+ );
+});
+
+FormattedDateTime.displayName = "FormattedDateTime";
diff --git a/apps/web/src/features/timezone/formatted-date-time-server.tsx b/apps/web/src/features/timezone/formatted-date-time-server.tsx
new file mode 100644
index 000000000..32c83e323
--- /dev/null
+++ b/apps/web/src/features/timezone/formatted-date-time-server.tsx
@@ -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, "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 (
+
+ );
+};
diff --git a/apps/web/src/features/timezone/hooks/use-formatted-date-time.ts b/apps/web/src/features/timezone/hooks/use-formatted-date-time.ts
new file mode 100644
index 000000000..3d6041c54
--- /dev/null
+++ b/apps/web/src/features/timezone/hooks/use-formatted-date-time.ts
@@ -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");
+};
diff --git a/apps/web/src/features/timezone/index.ts b/apps/web/src/features/timezone/index.ts
index 84aa6a7ed..512fc980c 100644
--- a/apps/web/src/features/timezone/index.ts
+++ b/apps/web/src/features/timezone/index.ts
@@ -1,3 +1,2 @@
-export * from "./timezone-context";
-export * from "./timezone-display";
-export * from "./timezone-utils";
+export * from "./client/context";
+export * from "./utils";
diff --git a/apps/web/src/features/timezone/timezone-context.tsx b/apps/web/src/features/timezone/timezone-context.tsx
deleted file mode 100644
index a118e8375..000000000
--- a/apps/web/src/features/timezone/timezone-context.tsx
+++ /dev/null
@@ -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(
- 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(() => {
- 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 (
-
- {children}
-
- );
-};
-
-export const useTimezone = () => {
- const context = useContext(TimezoneContext);
- if (context === undefined) {
- throw new Error("useTimezone must be used within a TimezoneProvider");
- }
- return context;
-};
diff --git a/apps/web/src/features/timezone/timezone-display.tsx b/apps/web/src/features/timezone/timezone-display.tsx
deleted file mode 100644
index d7d35b892..000000000
--- a/apps/web/src/features/timezone/timezone-display.tsx
+++ /dev/null
@@ -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 {formatDate(date, format)};
-}
-
-export function TimeDisplay({ date, format = "HH:mm" }: DateDisplayProps) {
- const { formatTime } = useTimezone();
- return {formatTime(date, format)};
-}
-
-export function DateTimeDisplay({ date, format = "LL, LT" }: DateDisplayProps) {
- const { formatDateTime } = useTimezone();
- return {formatDateTime(date, format)};
-}
-
-// Component to display the current timezone
-export function CurrentTimezone() {
- const { timezone } = useTimezone();
- return {timezone};
-}
diff --git a/apps/web/src/features/timezone/timezone-utils.ts b/apps/web/src/features/timezone/utils.ts
similarity index 100%
rename from apps/web/src/features/timezone/timezone-utils.ts
rename to apps/web/src/features/timezone/utils.ts
diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts
index 1ad9a6430..f83af8fed 100644
--- a/apps/web/src/trpc/routers/polls.ts
+++ b/apps/web/src/trpc/routers/polls.ts
@@ -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(
diff --git a/packages/database/prisma/migrations/20250415153024_scheduled_events/migration.sql b/packages/database/prisma/migrations/20250415153024_scheduled_events/migration.sql
new file mode 100644
index 000000000..06050300d
--- /dev/null
+++ b/packages/database/prisma/migrations/20250415153024_scheduled_events/migration.sql
@@ -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;
diff --git a/packages/database/prisma/migrations/20250415155219_migrate_events/migration.sql b/packages/database/prisma/migrations/20250415155219_migrate_events/migration.sql
new file mode 100644
index 000000000..1e83493d7
--- /dev/null
+++ b/packages/database/prisma/migrations/20250415155219_migrate_events/migration.sql
@@ -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;
\ No newline at end of file
diff --git a/packages/database/prisma/migrations/20250421170921_migrate_invites/migration.sql b/packages/database/prisma/migrations/20250421170921_migrate_invites/migration.sql
new file mode 100644
index 000000000..27341e338
--- /dev/null
+++ b/packages/database/prisma/migrations/20250421170921_migrate_invites/migration.sql
@@ -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
+ ));
\ No newline at end of file
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 1ba5e2e93..a04fd812f 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -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)
@@ -309,4 +313,80 @@ model VerificationToken {
@@unique([identifier, token])
@@map("verification_tokens")
-}
\ No newline at end of file
+}
+
+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")
+}
diff --git a/packages/database/prisma/seed.ts b/packages/database/prisma/seed.ts
index ffc17435f..e4df37174 100644
--- a/packages/database/prisma/seed.ts
+++ b/packages/database/prisma/seed.ts
@@ -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()
diff --git a/packages/database/prisma/seed/polls.ts b/packages/database/prisma/seed/polls.ts
new file mode 100644
index 000000000..b16eb9eb6
--- /dev/null
+++ b/packages/database/prisma/seed/polls.ts
@@ -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}`);
+}
diff --git a/packages/database/prisma/seed/scheduled-events.ts b/packages/database/prisma/seed/scheduled-events.ts
new file mode 100644
index 000000000..a9cea522d
--- /dev/null
+++ b/packages/database/prisma/seed/scheduled-events.ts
@@ -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.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([
+ "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}`);
+}
diff --git a/packages/database/prisma/seed/users.ts b/packages/database/prisma/seed/users.ts
new file mode 100644
index 000000000..184716f35
--- /dev/null
+++ b/packages/database/prisma/seed/users.ts
@@ -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];
+}
diff --git a/packages/database/prisma/seed/utils.ts b/packages/database/prisma/seed/utils.ts
new file mode 100644
index 000000000..f52845038
--- /dev/null
+++ b/packages/database/prisma/seed/utils.ts
@@ -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;
+};