diff --git a/src/backend/src/prisma/dates.ts b/src/backend/src/prisma/dates.ts index 8af22dc738..e674440783 100644 --- a/src/backend/src/prisma/dates.ts +++ b/src/backend/src/prisma/dates.ts @@ -5,10 +5,6 @@ import dayjs from 'dayjs'; https://fakerjs.dev/api/date.html */ -interface WithFaker { - faker: Faker; -} - export const SECOND_MS = 1000; export const MINUTE_MS = SECOND_MS * 60; export const HOUR_MS = MINUTE_MS * 60; @@ -17,15 +13,21 @@ export const WEEK_MS = DAY_MS * 7; export const DAYS_PER_WEEK = 7; -export function generateRandomDate({ faker }: WithFaker, from?: Date, to?: Date) { +export function generateRandomDate(faker: Faker, from?: Date, to?: Date) { return faker.date.between({ from: from ?? '2000-01-01', to: to ?? Date.now() }); } -export function generateRandomDateAround({ faker }: WithFaker, date: Date) { - return faker.date.recent({ days: 5, refDate: date }); +export function generateRandomDateAround(faker: Faker, date: Date, days: number) { + return faker.date.recent({ days, refDate: date }); } export const daysBetween = ({ start, end }: DateRange): number => Math.max(0, dayjs(end).diff(dayjs(start), 'day')); export const clampDate = (date: Date, { start, end }: DateRange): Date => new Date(Math.min(Math.max(date.getTime(), start.getTime()), end.getTime())); + +export const subtractDaysFromDate = (start: Date, days: number) => { + const end = new Date(start); + end.setDate(start.getDate() - days); + return end; +}; diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index 71c5577af3..1c2b0cb3cc 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -19,6 +19,7 @@ import { DescriptionBulletProcess } from './seed/description-bullet.process.js'; import { BOMProcess } from './seed/bom.process.js'; import { TaskProcess } from './seed/tasks.process.js'; import { WorkPackageProcess } from './seed/work-package.process.js'; +import { EventProcess } from './seed/event.process.js'; const prisma = new PrismaClient(); @@ -37,6 +38,7 @@ await new SeedRunner() new WorkPackageProcess(), new DescriptionBulletProcess(), new BOMProcess(), + new EventProcess(), new TaskProcess() ) .run(); diff --git a/src/backend/src/prisma/factories/car.factory.ts b/src/backend/src/prisma/factories/car.factory.ts index 8c72cbe507..92b7e66ccb 100644 --- a/src/backend/src/prisma/factories/car.factory.ts +++ b/src/backend/src/prisma/factories/car.factory.ts @@ -5,10 +5,10 @@ import { DateRange } from '../context.js'; const CURRENT_YEAR = new Date().getFullYear(); const CAR_COUNT = 5; -const FROM_MONTH = 1; // January +const FROM_MONTH = 5; // May const FROM_DAY = 1; -const TO_MONTH = 12; // December -const TO_DAY = 30; +const TO_MONTH = 7; // July +const TO_DAY = 31; export const getCarConfigs = (faker: Faker) => { return Array.from({ length: CAR_COUNT }, (_, i) => { diff --git a/src/backend/src/prisma/factories/event.factory.ts b/src/backend/src/prisma/factories/event.factory.ts new file mode 100644 index 0000000000..fe3c8b8655 --- /dev/null +++ b/src/backend/src/prisma/factories/event.factory.ts @@ -0,0 +1,290 @@ +import { Faker } from '@faker-js/faker'; +import { Conflict_Status, Event_Status, Prisma } from '@prisma/client'; +import { arrayOrNull } from '../utils/arrays.js'; +import { DAY_MS, MINUTE_MS } from '../dates.js'; +import { addDaysToDate } from 'shared'; + +type ConflictResult = { + approved: Conflict_Status; + approvalRequiredFromUserId: string | undefined; +}; + +const EVENT_TITLE_PREFIXES = [ + 'Design Review -', + 'Design Review -', + 'Design Review -', + 'Design Review -', + 'Weekly Meeting -', + 'Team Meeting -', + 'Knowledge Transfer -', + 'Office Hours -', + 'Bay Time -', + 'Planning Meeting -' +]; + +export const EVENT_LOCATIONS = [ + 'The Bay', + 'Richards 054', + 'Richards 300', + 'Hastings 211', + 'Egan 206', + 'Curry 435', + 'Snell 031', + 'Robinson 107', + 'Zoom', + 'Bay/Zoom', + null, + null, + null +]; + +export const EVENT_DESCRIPTIONS = [ + 'Weekly team meeting to go over project updates.', + 'Design review for current project phase.', + 'Office hours for team members to ask questions.', + 'Knowledge transfer session for new members.', + 'Bay time for hands-on work.', + 'Planning session for upcoming milestones.' +]; + +export const EVENTS_PER_PROJECT = 3; +export const DOCUMENT_PROBABILITY = 0.6; +export const MEETING_ATTENDANCE_PROBABILITY = 0.4; +const EVENT_DATE_BUFFER_DAYS = 7; +const MAX_ATTENDEES = 10; +export const DAYS_AFTER_NO_EVENT = 20; +const CONFIRMED_WINDOW = 14; + +export const generateEventCount = (faker: Faker): number => faker.number.int({ min: 1, max: EVENTS_PER_PROJECT }); + +export const generateScheduleSlotCount = (faker: Faker, title: string): number => { + const isRecurring = + title.includes('Weekly') || title.includes('Meeting') || title.includes('Office Hours') || title.includes('Bay Time'); + + if (isRecurring) { + return faker.number.int({ min: 4, max: 12 }); + } + + return faker.number.int({ min: 1, max: 2 }); +}; + +export const generateInitialDateOffset = (faker: Faker, availableDays: number, isCurrentYear: boolean): number => { + if (isCurrentYear) { + return faker.number.int({ min: 0, max: Math.min(DAYS_AFTER_NO_EVENT, availableDays) }); + } + return faker.number.int({ min: 0, max: Math.max(0, availableDays) }); +}; + +export const shouldCreateDocument = (faker: Faker): boolean => faker.datatype.boolean({ probability: DOCUMENT_PROBABILITY }); + +export const shouldCreateMeetingAttendance = (faker: Faker): boolean => + faker.datatype.boolean({ probability: MEETING_ATTENDANCE_PROBABILITY }); + +export const generateAttendeeCount = (faker: Faker, maxUsers: number): number => + faker.number.int({ min: 2, max: Math.min(MAX_ATTENDEES, maxUsers) }); + +export const generateLocation = (faker: Faker): string | null => arrayOrNull(faker, EVENT_LOCATIONS, 0.5); + +export const generateEventDescription = (faker: Faker): string | null => arrayOrNull(faker, EVENT_DESCRIPTIONS, 0.5); + +export const generateZoomLink = (faker: Faker): string | null => { + if (!faker.datatype.boolean({ probability: 0.5 })) return null; + return `https://northeastern.zoom.us/j/${faker.string.numeric(11)}`; +}; + +export const generateQuestionDocumentLink = (faker: Faker): string | null => { + if (!faker.datatype.boolean({ probability: 0.6 })) return null; + return `https://docs.google.com/document/d/${faker.string.alphanumeric(44)}/edit`; +}; + +export const generateEventTitle = (faker: Faker, projectName: string): string => { + const prefix = faker.helpers.arrayElement(EVENT_TITLE_PREFIXES); + return `${prefix} ${projectName}`; +}; + +export const generateEventDateCreated = (faker: Faker, initialDateScheduled: Date): Date => + addDaysToDate(initialDateScheduled, -faker.number.int({ min: 1, max: 14 })); + +export const generateEventStatus = ( + faker: Faker, + requiresConfirmation: boolean, + initialDateScheduled: Date, + now: Date = new Date() +): Event_Status => { + const daysUntilDue = Math.floor((initialDateScheduled.getTime() - now.getTime()) / DAY_MS); + + if (requiresConfirmation && daysUntilDue > 0) return Event_Status.UNCONFIRMED; + + // Confirmed status only gets a change in a window of time since they are very rare and not common amongst an aggegate dataset + if (requiresConfirmation && daysUntilDue > 0 && daysUntilDue < CONFIRMED_WINDOW) { + return faker.helpers.weightedArrayElement([ + { weight: 70, value: Event_Status.UNCONFIRMED }, + { weight: 20, value: Event_Status.SCHEDULED }, + { weight: 10, value: Event_Status.CONFIRMED } + ]); + } + + if (daysUntilDue < -EVENT_DATE_BUFFER_DAYS) { + return faker.helpers.weightedArrayElement([ + { weight: 50, value: Event_Status.DONE }, + { weight: 50, value: Event_Status.SCHEDULED } + ]); + } + + if (daysUntilDue > 0) { + return requiresConfirmation + ? faker.helpers.weightedArrayElement([ + { weight: 60, value: Event_Status.UNCONFIRMED }, + { weight: 40, value: Event_Status.SCHEDULED } + ]) + : faker.helpers.weightedArrayElement([ + { weight: 60, value: Event_Status.SCHEDULED }, + { weight: 40, value: Event_Status.UNCONFIRMED } + ]); + } + + return faker.helpers.weightedArrayElement([ + { weight: 50, value: Event_Status.SCHEDULED }, + { weight: 40, value: Event_Status.UNCONFIRMED }, + { weight: 10, value: Event_Status.DONE } + ]); +}; + +export const generateConflictStatus = (faker: Faker, creators: { userId: string }[]): ConflictResult => { + const approved = faker.helpers.weightedArrayElement([ + { weight: 85, value: Conflict_Status.NO_CONFLICT }, + { weight: 10, value: Conflict_Status.PENDING }, + { weight: 5, value: Conflict_Status.APPROVED } + ]); + + if (approved === Conflict_Status.NO_CONFLICT) { + return { approved, approvalRequiredFromUserId: undefined }; + } + + return { + approved, + approvalRequiredFromUserId: faker.helpers.arrayElement(creators).userId + }; +}; + +export const generateScheduleSlotTimes = (faker: Faker, baseDate: Date): { startTime: Date; endTime: Date } => { + const startTime = new Date(baseDate); + startTime.setHours(faker.number.int({ min: 9, max: 20 }), faker.helpers.arrayElement([0, 30]), 0, 0); + + const durationHours = faker.helpers.weightedArrayElement([ + { weight: 50, value: 1 }, + { weight: 30, value: 2 }, + { weight: 15, value: 1.5 }, + { weight: 5, value: 3 } + ]); + + const endTime = new Date(startTime); + endTime.setTime(endTime.getTime() + durationHours * 60 * 60 * 1000); + + return { startTime, endTime }; +}; + +export const eventCreateInput = ( + eventType: { + requiresConfirmation: boolean; + location: boolean; + zoomLink: boolean; + questionDocument: boolean; + description: boolean; + documents: boolean; + requiredMembers: boolean; + optionalMembers: boolean; + }, + title: string, + userCreatedId: string, + eventTypeId: string, + status: Event_Status, + approved: Conflict_Status, + initialDateScheduled: Date, + location: string | null, + zoomLink: string | null, + description: string | null, + questionDocumentLink: string | null, + requiredMemberIds: string[], + optionalMemberIds: string[], + confirmedMemberIds: string[], + deniedMemberIds: string[], + dateCreated: Date, + approvalRequiredFromUserId?: string +): Prisma.EventCreateInput => ({ + title, + status, + approved, + initialDateScheduled, + calendarEventIds: [], + userCreated: { connect: { userId: userCreatedId } }, + eventType: { connect: { eventTypeId } }, + dateCreated, + requiredMembers: { + connect: [ + { userId: userCreatedId }, + ...requiredMemberIds.filter((id) => id !== userCreatedId).map((id) => ({ userId: id })) + ] + }, + ...(eventType.optionalMembers && optionalMemberIds.length > 0 + ? { optionalMembers: { connect: optionalMemberIds.map((userId) => ({ userId })) } } + : {}), + confirmedMembers: { connect: confirmedMemberIds.map((userId) => ({ userId })) }, + deniedMembers: { connect: deniedMemberIds.map((userId) => ({ userId })) }, + ...(eventType.location && location ? { location } : {}), + ...(eventType.zoomLink && zoomLink ? { zoomLink } : {}), + ...(eventType.description && description ? { description } : {}), + ...(eventType.questionDocument && questionDocumentLink ? { questionDocumentLink } : {}), + ...(approved !== Conflict_Status.NO_CONFLICT && approvalRequiredFromUserId + ? { approvalRequiredBy: { connect: { userId: approvalRequiredFromUserId } } } + : {}) +}); + +export const scheduleSlotCreateInput = ( + eventId: string, + startTime: Date, + endTime: Date, + allDay: boolean = false +): Prisma.Schedule_SlotCreateInput => ({ + startTime, + endTime, + allDay, + event: { connect: { eventId } } +}); + +export const documentCreateInput = ( + faker: Faker, + eventId: string, + createdByUserId: string, + dateCreated: Date +): Prisma.DocumentCreateInput => ({ + googleFileId: `${faker.string.alphanumeric(33)}`, + name: `document-${faker.string.uuid()}.pdf`, + dateCreated, + createdBy: { connect: { userId: createdByUserId } }, + documentEvent: { connect: { eventId } } +}); + +export const meetingAttendanceCreateInput = ( + faker: Faker, + organizationId: string, + teamId: string, + userCreatedId: string, + attendeeIds: string[], + initialDateScheduled: Date +): Prisma.Meeting_AttendanceCreateInput => { + const openedAt = initialDateScheduled; + const closedAt = new Date(openedAt); + closedAt.setTime(openedAt.getTime() + faker.number.int({ min: 60, max: 120 }) * MINUTE_MS); + + return { + slackChannelId: `C${faker.string.alphanumeric(10).toUpperCase()}`, + slackMessageTimestamp: `${Math.floor(openedAt.getTime() / 1000)}.${faker.string.numeric(6)}`, + openedAt, + closedAt, + organization: { connect: { organizationId } }, + team: { connect: { teamId } }, + userCreated: { connect: { userId: userCreatedId } }, + attendees: { connect: attendeeIds.map((userId) => ({ userId })) } + }; +}; diff --git a/src/backend/src/prisma/seed/event.process.ts b/src/backend/src/prisma/seed/event.process.ts new file mode 100644 index 0000000000..fab59f12e3 --- /dev/null +++ b/src/backend/src/prisma/seed/event.process.ts @@ -0,0 +1,198 @@ +import { SeedProcess } from '../processes/seed-process.js'; +import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; +import { UsersOutput, UsersProcess } from './user.process.js'; +import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; +import { TeamOutput, TeamProcess } from './team.process.js'; +import { ProjectOutput, ProjectProcess } from './project.process.js'; +import { CarOutput } from '../context.js'; +import { CarProcess } from './car.process.js'; +import { + DAYS_AFTER_NO_EVENT, + documentCreateInput, + eventCreateInput, + generateAttendeeCount, + generateConflictStatus, + generateEventCount, + generateEventDateCreated, + generateEventDescription, + generateEventStatus, + generateEventTitle, + generateInitialDateOffset, + generateLocation, + generateQuestionDocumentLink, + generateScheduleSlotCount, + generateScheduleSlotTimes, + generateZoomLink, + meetingAttendanceCreateInput, + scheduleSlotCreateInput, + shouldCreateDocument, + shouldCreateMeetingAttendance +} from '../factories/event.factory.js'; +import { addDaysToDate } from 'shared'; +import { clampDate, DAY_MS, daysBetween } from '../dates.js'; +import { Event_Status } from '@prisma/client'; + +type EventInput = OrganizationOutput & UsersOutput & ConfigDataOutput & TeamOutput & CarOutput & ProjectOutput; + +export class EventProcess extends SeedProcess> { + dependencies() { + return [OrganizationProcess, UsersProcess, ConfigDataProcess, TeamProcess, CarProcess, ProjectProcess]; + } + + async run({ + organization, + projects, + leadership, + heads, + admins, + appAdmins, + members, + teams, + eventTypes + }: EventInput): Promise> { + const { organizationId } = organization; + const creators = [...leadership, ...heads, ...admins, ...appAdmins]; + const allUsers = [...members, ...leadership, ...heads, ...admins, ...appAdmins]; + + const now = new Date(); + + const BATCH_SIZE = 20; + for (let i = 0; i < projects.length; i += BATCH_SIZE) { + const batch = projects.slice(i, i + BATCH_SIZE); + await Promise.all( + batch.map(({ project, timeline }) => { + return this.generateEventsForProject( + organizationId, + project.wbsElement.name, + timeline, + creators, + allUsers, + teams, + eventTypes, + now + ); + }) + ); + } + + return {}; + } + + private async generateEventsForProject( + organizationId: string, + projectName: string, + timeline: { start: Date; end: Date }, + creators: UsersOutput['leadership'], + allUsers: UsersOutput['members'], + teams: TeamOutput['teams'], + eventTypes: ConfigDataOutput['eventTypes'], + now: Date + ) { + if ((timeline.start.getTime() - now.getTime()) / DAY_MS > DAYS_AFTER_NO_EVENT) return; + + const count = generateEventCount(this.faker); + // Increase the window with about 50 days of padding to account for car switching + + for (let i = 0; i < count; i++) { + const creator = this.faker.helpers.arrayElement(creators); + const eventType = this.faker.helpers.arrayElement(eventTypes); + const { approved, approvalRequiredFromUserId } = generateConflictStatus(this.faker, creators); + const title = generateEventTitle(this.faker, projectName); + + const availableDays = daysBetween(timeline); + const offsetDays = generateInitialDateOffset(this.faker, availableDays, timeline.end > now); + const initialDateScheduled = addDaysToDate(timeline.start, offsetDays); + const status = generateEventStatus(this.faker, eventType.requiresConfirmation, initialDateScheduled); + + const location = generateLocation(this.faker); + const zoomLink = generateZoomLink(this.faker); + const description = generateEventDescription(this.faker); + const questionDocumentLink = generateQuestionDocumentLink(this.faker); + const dateCreated = generateEventDateCreated(this.faker, initialDateScheduled); + + const requiredMemberIds = eventType.requiredMembers + ? [ + creator.userId, + ...this.faker.helpers + .arrayElements(allUsers, this.faker.number.int({ min: 0, max: 4 })) + .map((user) => user.userId) + ] + : [creator.userId]; + const optionalMemberIds = eventType.optionalMembers + ? this.faker.helpers + .arrayElements( + allUsers.filter((user) => !requiredMemberIds.includes(user.userId)), + this.faker.number.int({ min: 0, max: 5 }) + ) + .map((user) => user.userId) + : []; + + const confirmedMemberIds = eventType.requiresConfirmation + ? status === Event_Status.CONFIRMED + ? [...requiredMemberIds, ...this.faker.helpers.arrayElements(optionalMemberIds)] + : [...this.faker.helpers.arrayElements(requiredMemberIds), ...this.faker.helpers.arrayElements(optionalMemberIds)] + : []; + + // Unused. For reference if later reused. + const deniedMemberIds: string[] = []; + + const event = await this.prisma.event.create({ + data: eventCreateInput( + eventType, + title, + creator.userId, + eventType.eventTypeId, + status, + approved, + initialDateScheduled, + location, + zoomLink, + description, + questionDocumentLink, + requiredMemberIds, + optionalMemberIds, + confirmedMemberIds, + deniedMemberIds, + dateCreated, + approvalRequiredFromUserId + ) + }); + + const slotCount = generateScheduleSlotCount(this.faker, title); + const daysRemaining = daysBetween({ start: initialDateScheduled, end: timeline.end }); + const maxSlots = Math.max(1, Math.floor(daysRemaining / 7)); + const actualSlotCount = Math.min(slotCount, maxSlots); + + for (let s = 0; s < actualSlotCount; s++) { + const slotDate = addDaysToDate(initialDateScheduled, s * 7); + const { startTime, endTime } = generateScheduleSlotTimes(this.faker, clampDate(slotDate, timeline)); + await this.prisma.schedule_Slot.create({ + data: scheduleSlotCreateInput(event.eventId, startTime, endTime) + }); + } + + if (shouldCreateDocument(this.faker)) { + await this.prisma.document.create({ + data: documentCreateInput(this.faker, event.eventId, creator.userId, dateCreated) + }); + } + + if (shouldCreateMeetingAttendance(this.faker)) { + const team = this.faker.helpers.arrayElement(teams); + const attendeeCount = generateAttendeeCount(this.faker, allUsers.length); + const attendees = this.faker.helpers.arrayElements(allUsers, attendeeCount); + + await this.prisma.meeting_Attendance.create({ + data: meetingAttendanceCreateInput( + this.faker, + organizationId, + team.teamId, + creator.userId, + attendees.map((u) => u.userId), + initialDateScheduled + ) + }); + } + } + } +} diff --git a/src/backend/src/prisma/utils/arrays.ts b/src/backend/src/prisma/utils/arrays.ts new file mode 100644 index 0000000000..b29e80a91e --- /dev/null +++ b/src/backend/src/prisma/utils/arrays.ts @@ -0,0 +1,6 @@ +import { Faker } from '@faker-js/faker'; + +export const arrayOrNull = (faker: Faker, array: Array, nullChance: number): T | null => { + if (!faker.datatype.boolean({ probability: nullChance })) return null; + return faker.helpers.arrayElement(array); +}; diff --git a/src/backend/src/prisma/utils/strings.ts b/src/backend/src/prisma/utils/strings.ts new file mode 100644 index 0000000000..8dcdd9e0a3 --- /dev/null +++ b/src/backend/src/prisma/utils/strings.ts @@ -0,0 +1,4 @@ +import { Faker } from '@faker-js/faker'; + +export const generateLoremIpsum = (faker: Faker, maxSentences: number): string => + faker.lorem.sentences(faker.number.int({ min: 1, max: maxSentences }));