Add scheduled events schema (#1679)

This commit is contained in:
Luke Vella 2025-04-22 14:28:15 +01:00 committed by GitHub
parent 22f32f9314
commit 56bd684c55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1412 additions and 659 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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
));

View file

@ -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")
}
}
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")
}

View file

@ -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()

View 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}`);
}

View 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}`);
}

View 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];
}

View 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;
};