From fb82a0d505bc6bfa96793dfede530e6b6a7c9711 Mon Sep 17 00:00:00 2001 From: Nicoll Guarnizo Date: Wed, 3 Sep 2025 14:58:01 -0500 Subject: [PATCH] dumping my changes so i dont loose them --- .../app/(content)/events/[slug]/edit/page.tsx | 27 + .../src/app/(content)/events/[slug]/page.tsx | 210 +++++++ .../src/app/(content)/events/new/page.tsx | 16 + .../egghead/src/app/(content)/events/page.tsx | 79 +++ .../src/app/admin/events/[slug]/edit/page.tsx | 80 +++ .../egghead/src/app/admin/events/new/page.tsx | 45 ++ apps/egghead/src/app/admin/events/page.tsx | 136 +++++ apps/egghead/src/app/admin/layout.tsx | 7 +- .../src/inngest/events/resource-management.ts | 19 + apps/egghead/src/inngest/inngest.server.ts | 8 + apps/egghead/src/lib/events-query.ts | 561 ++++++++++++++++++ apps/egghead/src/lib/events.ts | 201 +++++++ 12 files changed, 1388 insertions(+), 1 deletion(-) create mode 100644 apps/egghead/src/app/(content)/events/[slug]/edit/page.tsx create mode 100644 apps/egghead/src/app/(content)/events/[slug]/page.tsx create mode 100644 apps/egghead/src/app/(content)/events/new/page.tsx create mode 100644 apps/egghead/src/app/(content)/events/page.tsx create mode 100644 apps/egghead/src/app/admin/events/[slug]/edit/page.tsx create mode 100644 apps/egghead/src/app/admin/events/new/page.tsx create mode 100644 apps/egghead/src/app/admin/events/page.tsx create mode 100644 apps/egghead/src/inngest/events/resource-management.ts create mode 100644 apps/egghead/src/lib/events-query.ts create mode 100644 apps/egghead/src/lib/events.ts diff --git a/apps/egghead/src/app/(content)/events/[slug]/edit/page.tsx b/apps/egghead/src/app/(content)/events/[slug]/edit/page.tsx new file mode 100644 index 000000000..c916664ce --- /dev/null +++ b/apps/egghead/src/app/(content)/events/[slug]/edit/page.tsx @@ -0,0 +1,27 @@ +import { notFound, redirect } from 'next/navigation' +import { getEventOrEventSeries } from '@/lib/events-query' +import { getServerAuthSession } from '@/server/auth' + +interface EditEventPageProps { + params: { slug: string } +} + +/** + * @description Edit event page - redirects to admin for now + */ +export default async function EditEventPage({ params }: EditEventPageProps) { + const { session, ability } = await getServerAuthSession() + + if (!session?.user || !ability.can('update', 'Content')) { + redirect('/login') + } + + const event = await getEventOrEventSeries(params.slug) + + if (!event) { + notFound() + } + + // For now, redirect to admin - we'll add the form here later + redirect(`/admin/events/${event.fields.slug}/edit`) +} diff --git a/apps/egghead/src/app/(content)/events/[slug]/page.tsx b/apps/egghead/src/app/(content)/events/[slug]/page.tsx new file mode 100644 index 000000000..4059e49ad --- /dev/null +++ b/apps/egghead/src/app/(content)/events/[slug]/page.tsx @@ -0,0 +1,210 @@ +import { Metadata } from 'next' +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { getEventOrEventSeries } from '@/lib/events-query' +import { getServerAuthSession } from '@/server/auth' + +interface EventPageProps { + params: { slug: string } +} + +/** + * @description Generate metadata for event pages + */ +export async function generateMetadata({ + params, +}: EventPageProps): Promise { + const event = await getEventOrEventSeries(params.slug) + + if (!event) { + return { + title: 'Event Not Found', + } + } + + return { + title: event.fields.title, + description: event.fields.description || undefined, + openGraph: { + title: event.fields.title, + description: event.fields.description || undefined, + images: event.fields.image ? [event.fields.image] : undefined, + }, + } +} + +/** + * @description Individual event detail page + */ +export default async function EventPage({ params }: EventPageProps) { + const event = await getEventOrEventSeries(params.slug) + const { session, ability } = await getServerAuthSession() + + if (!event) { + notFound() + } + + const canEdit = session?.user && ability.can('update', 'Content') + + return ( +
+
+ {/* Header */} +
+ {canEdit && ( +
+ + Edit Event + +
+ )} + +

{event.fields.title}

+ + {(event.fields as any).startsAt && ( +
+ 📅{' '} + {new Date((event.fields as any).startsAt).toLocaleDateString( + 'en-US', + { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', + }, + )} +
+ )} +
+ + {/* Image */} + {event.fields.image && ( +
+ {event.fields.title} +
+ )} + + {/* Description */} + {event.fields.description && ( +
+

About This Event

+
+

{event.fields.description}

+
+
+ )} + + {/* Body/Details */} + {event.fields.body && ( +
+

Details

+
+
+
+
+ )} + + {/* Event Details */} +
+
+

Event Information

+
+ {(event.fields as any).startsAt && ( +
+ Start:{' '} + {new Date((event.fields as any).startsAt).toLocaleString()} +
+ )} + {(event.fields as any).endsAt && ( +
+ End:{' '} + {new Date((event.fields as any).endsAt).toLocaleString()} +
+ )} + {(event.fields as any).timezone && ( +
+ Timezone: {(event.fields as any).timezone} +
+ )} +
+
+ + {/* Attendee Instructions */} + {(event.fields as any).attendeeInstructions && ( +
+

+ Instructions for Attendees +

+
+

{(event.fields as any).attendeeInstructions}

+
+
+ )} +
+ + {/* Event Series Information */} + {event.type === 'event-series' && event.resources && ( +
+

+ Events in this Series +

+
+ {event.resources.map((resourceRef) => { + const childEvent = resourceRef.resource + if (childEvent.type === 'event') { + return ( +
+

+ {childEvent.fields.title} +

+ {(childEvent.fields as any).startsAt && ( +

+ {new Date( + (childEvent.fields as any).startsAt, + ).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + })} +

+ )} + {childEvent.fields.description && ( +

+ {childEvent.fields.description} +

+ )} +
+ ) + } + return null + })} +
+
+ )} + + {/* Back to Events */} +
+ + ← Back to all events + +
+
+
+ ) +} diff --git a/apps/egghead/src/app/(content)/events/new/page.tsx b/apps/egghead/src/app/(content)/events/new/page.tsx new file mode 100644 index 000000000..589f44ee6 --- /dev/null +++ b/apps/egghead/src/app/(content)/events/new/page.tsx @@ -0,0 +1,16 @@ +import { redirect } from 'next/navigation' +import { getServerAuthSession } from '@/server/auth' + +/** + * @description Create new event page - redirects to admin for now + */ +export default async function NewEventPage() { + const { session, ability } = await getServerAuthSession() + + if (!session?.user || !ability.can('create', 'Content')) { + redirect('/login') + } + + // For now, redirect to admin - we'll add the form here later + redirect('/admin/events/new') +} diff --git a/apps/egghead/src/app/(content)/events/page.tsx b/apps/egghead/src/app/(content)/events/page.tsx new file mode 100644 index 000000000..715d98cfb --- /dev/null +++ b/apps/egghead/src/app/(content)/events/page.tsx @@ -0,0 +1,79 @@ +import { Metadata } from 'next' +import Link from 'next/link' +import { getActiveEvents } from '@/lib/events-query' + +export const metadata: Metadata = { + title: 'Events', + description: 'Browse upcoming events and workshops', +} + +/** + * @description Events listing page showing all active/upcoming events + */ +export default async function EventsPage() { + const events = await getActiveEvents() + + return ( +
+
+

Events

+

+ Join us for live workshops, webinars, and educational events. +

+
+ + {events.length === 0 ? ( +
+

No upcoming events

+

+ Check back soon for new events and workshops. +

+
+ ) : ( +
+ {events.map((event) => ( + +
+ {event.fields.image && ( +
+ {event.fields.title} +
+ )} +

+ {event.fields.title} +

+ {event.fields.description && ( +

+ {event.fields.description} +

+ )} + {(event.fields as any).startsAt && ( +

+ {new Date( + (event.fields as any).startsAt, + ).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + })} +

+ )} +
+ + ))} +
+ )} +
+ ) +} diff --git a/apps/egghead/src/app/admin/events/[slug]/edit/page.tsx b/apps/egghead/src/app/admin/events/[slug]/edit/page.tsx new file mode 100644 index 000000000..cd70777b3 --- /dev/null +++ b/apps/egghead/src/app/admin/events/[slug]/edit/page.tsx @@ -0,0 +1,80 @@ +import { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { getEventOrEventSeries } from '@/lib/events-query' +import { getServerAuthSession } from '@/server/auth' + +interface AdminEditEventPageProps { + params: { slug: string } +} + +/** + * @description Generate metadata for admin edit event page + */ +export async function generateMetadata({ + params, +}: AdminEditEventPageProps): Promise { + const event = await getEventOrEventSeries(params.slug) + + return { + title: event ? `Edit ${event.fields.title}` : 'Edit Event', + } +} + +/** + * @description Admin edit event page + */ +export default async function AdminEditEventPage({ + params, +}: AdminEditEventPageProps) { + const { session, ability } = await getServerAuthSession() + + if (!session?.user || !ability.can('update', 'Content')) { + redirect('/login') + } + + const event = await getEventOrEventSeries(params.slug) + + if (!event) { + notFound() + } + + return ( +
+
+
+

Edit Event

+

Editing: {event.fields.title}

+
+ +
+
+

Event Edit Form

+

+ Event editing form will be implemented in Phase 2 +

+
+

Current Event Data:

+
+								{JSON.stringify(
+									{
+										id: event.id,
+										type: event.type,
+										title: event.fields.title,
+										slug: event.fields.slug,
+										description: event.fields.description,
+										startsAt: (event.fields as any).startsAt,
+										endsAt: (event.fields as any).endsAt,
+										state: event.fields.state,
+										visibility: event.fields.visibility,
+									},
+									null,
+									2,
+								)}
+							
+
+
+
+
+
+ ) +} diff --git a/apps/egghead/src/app/admin/events/new/page.tsx b/apps/egghead/src/app/admin/events/new/page.tsx new file mode 100644 index 000000000..8a611e282 --- /dev/null +++ b/apps/egghead/src/app/admin/events/new/page.tsx @@ -0,0 +1,45 @@ +import { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { getServerAuthSession } from '@/server/auth' + +export const metadata: Metadata = { + title: 'Create Event', + description: 'Create a new event or workshop', +} + +/** + * @description Create new event page in admin + */ +export default async function AdminNewEventPage() { + const { session, ability } = await getServerAuthSession() + + if (!session?.user || !ability.can('create', 'Content')) { + redirect('/login') + } + + return ( +
+
+
+

Create New Event

+

+ Create a workshop, webinar, or live event +

+
+ +
+
+

Event Creation Form

+

+ Event creation form will be implemented in Phase 2 +

+

+ For now, events can be created via the CourseBuilder adapter + directly. +

+
+
+
+
+ ) +} diff --git a/apps/egghead/src/app/admin/events/page.tsx b/apps/egghead/src/app/admin/events/page.tsx new file mode 100644 index 000000000..fa6efd5a0 --- /dev/null +++ b/apps/egghead/src/app/admin/events/page.tsx @@ -0,0 +1,136 @@ +import { Metadata } from 'next' +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getAllEvents } from '@/lib/events-query' +import { getServerAuthSession } from '@/server/auth' + +export const metadata: Metadata = { + title: 'Events Management', + description: 'Manage events and workshops', +} + +/** + * @description Admin events management page + */ +export default async function AdminEventsPage() { + const { session, ability } = await getServerAuthSession() + + if (!session?.user || !ability.can('read', 'Content')) { + redirect('/login') + } + + const events = await getAllEvents() + + return ( +
+
+
+

Events Management

+

+ Create and manage events and workshops +

+
+ {ability.can('create', 'Content') && ( + + + Create Event + + )} +
+ + {events.length === 0 ? ( +
+

No events yet

+

+ Get started by creating your first event. +

+ {ability.can('create', 'Content') && ( + + Create Your First Event + + )} +
+ ) : ( +
+ + + + + + + + + + + {events.map((event) => ( + + + + + + + ))} + +
+ Event + + Status + + Start Date + + Actions +
+
+
+ {event.fields.title} +
+ {event.fields.description && ( +
+ {event.fields.description} +
+ )} +
+
+ + {event.fields.state} + + + {(event.fields as any).startsAt + ? new Date( + (event.fields as any).startsAt, + ).toLocaleDateString() + : 'Not set'} + + + View + + {ability.can('update', 'Content') && ( + + Edit + + )} +
+
+ )} +
+ ) +} diff --git a/apps/egghead/src/app/admin/layout.tsx b/apps/egghead/src/app/admin/layout.tsx index 9e322e977..82b470356 100644 --- a/apps/egghead/src/app/admin/layout.tsx +++ b/apps/egghead/src/app/admin/layout.tsx @@ -10,9 +10,14 @@ import { SidebarTrigger, } from '@/components/ui/sidebar' import { getServerAuthSession } from '@/server/auth' -import { MailIcon, MicIcon, TagIcon } from 'lucide-react' +import { CalendarIcon, MailIcon, MicIcon, TagIcon } from 'lucide-react' const adminSidebar = [ + { + label: 'Events', + href: '/admin/events', + icon: CalendarIcon, + }, { label: 'Instructors', href: '/admin/instructors', diff --git a/apps/egghead/src/inngest/events/resource-management.ts b/apps/egghead/src/inngest/events/resource-management.ts new file mode 100644 index 000000000..a93da0e4c --- /dev/null +++ b/apps/egghead/src/inngest/events/resource-management.ts @@ -0,0 +1,19 @@ +/** + * @description Event triggered when any content resource is successfully created. + */ +export const RESOURCE_CREATED_EVENT = 'resource/created' as const + +export type ResourceCreated = { + name: typeof RESOURCE_CREATED_EVENT + data: { id: string; type: string } +} + +/** + * @description Event triggered when any content resource is successfully updated. + */ +export const RESOURCE_UPDATED_EVENT = 'resource/updated' as const + +export type ResourceUpdated = { + name: typeof RESOURCE_UPDATED_EVENT + data: { id: string; type: string } +} diff --git a/apps/egghead/src/inngest/inngest.server.ts b/apps/egghead/src/inngest/inngest.server.ts index f37690b7c..a23e42cea 100644 --- a/apps/egghead/src/inngest/inngest.server.ts +++ b/apps/egghead/src/inngest/inngest.server.ts @@ -37,6 +37,12 @@ import { InstructorInviteCreated, } from './events/instructor-invite-created' import { POST_CREATED_EVENT, PostCreated } from './events/post-created' +import { + RESOURCE_CREATED_EVENT, + RESOURCE_UPDATED_EVENT, + ResourceCreated, + ResourceUpdated, +} from './events/resource-management' import { TIPS_UPDATED_EVENT, TipsUpdated, @@ -63,6 +69,8 @@ export type Events = { [INSTRUCTOR_INVITE_CREATED_EVENT]: InstructorInviteCreated [INSTRUCTOR_INVITE_COMPLETED_EVENT]: InstructorInviteCompleted [EGGHEAD_COURSE_CREATED_EVENT]: EggheadCourseCreated + [RESOURCE_CREATED_EVENT]: ResourceCreated + [RESOURCE_UPDATED_EVENT]: ResourceUpdated } const callbackBase = diff --git a/apps/egghead/src/lib/events-query.ts b/apps/egghead/src/lib/events-query.ts new file mode 100644 index 000000000..1bcdd1cfa --- /dev/null +++ b/apps/egghead/src/lib/events-query.ts @@ -0,0 +1,561 @@ +'use server' + +import { revalidateTag, unstable_cache } from 'next/cache' +import { courseBuilderAdapter, db } from '@/db' +import { + contentResource, + contentResourceResource, + contentResourceTag, +} from '@/db/schema' +import { + EventSchema, + EventSeriesSchema, + multipleEventsToEventSeriesAndEvents, + type Event, + type EventFormData, + type EventSeries, + type EventSeriesFormData, +} from '@/lib/events' +import { upsertPostToTypeSense } from '@/lib/typesense/post' +import { logger as log } from '@/lib/utils/logger' +import { getServerAuthSession } from '@/server/auth' +import { guid } from '@/utils/guid' +import { subject } from '@casl/ability' +import slugify from '@sindresorhus/slugify' +import { and, asc, eq, inArray, or, sql } from 'drizzle-orm' +import { z } from 'zod' + +import { + RESOURCE_CREATED_EVENT, + RESOURCE_UPDATED_EVENT, +} from '../inngest/events/resource-management' +import { inngest } from '../inngest/inngest.server' + +/** + * @description Get a single event by ID or slug + */ +export async function getEvent(eventIdOrSlug: string) { + const eventData = await courseBuilderAdapter.getEvent(eventIdOrSlug, { + withResources: true, + withTags: true, + withProducts: true, + withPricing: true, + }) + + const parsedEvent = EventSchema.safeParse(eventData) + if (!parsedEvent.success) { + console.error('Error parsing event', eventData) + return null + } + + return parsedEvent.data +} + +/** + * @description Get cached event or event series + */ +export const getCachedEventOrEventSeries = unstable_cache( + async (eventIdOrSlug: string) => { + return await getEventOrEventSeries(eventIdOrSlug) + }, + ['events'], + { revalidate: 3600, tags: ['events'] }, +) + +/** + * @description Get an event or event series by ID or slug + */ +export async function getEventOrEventSeries(eventIdOrSlug: string) { + const eventData = await courseBuilderAdapter.getEvent(eventIdOrSlug, { + withResources: true, + withTags: true, + withProducts: true, + withPricing: true, + }) + + let parsedEvent + if (eventData?.type === 'event') { + parsedEvent = EventSchema.safeParse(eventData) + } else if (eventData?.type === 'event-series') { + parsedEvent = EventSeriesSchema.safeParse(eventData) + } else { + console.error('Error parsing event', eventData) + return null + } + if (!parsedEvent.success) { + console.error('Error parsing event', eventData) + return null + } + + return parsedEvent.data +} + +/** + * @description Get all events + */ +export async function getAllEvents() { + const events = await db.query.contentResource.findMany({ + where: eq(contentResource.type, 'event'), + }) + + const parsedEvents = z.array(EventSchema).safeParse(events) + + if (!parsedEvents.success) { + console.error('Error parsing events', events) + return [] + } + + return parsedEvents.data +} + +/** + * @description Get an event series by ID or slug + */ +export async function getEventSeries(eventSeriesIdOrSlug: string) { + const eventSeriesData = await courseBuilderAdapter.getEvent( + eventSeriesIdOrSlug, + { + withResources: true, + withTags: true, + withProducts: true, + withPricing: true, + }, + ) + + const parsedEventSeries = EventSeriesSchema.safeParse(eventSeriesData) + if (!parsedEventSeries.success) { + console.error('Error parsing event series', eventSeriesData) + return null + } + + return parsedEventSeries.data +} + +/** + * @description Update an event + */ +export async function updateEvent( + input: Partial, + action: 'save' | 'publish' | 'archive' | 'unpublish' = 'save', + revalidate = true, +) { + const { session, ability } = await getServerAuthSession() + const user = session?.user + + if (!input.id) { + throw new Error('Event id is required') + } + + const currentEvent = await getEventOrEventSeries(input.id) + + if (!currentEvent) { + log.error('event.update.notfound', { + eventId: input.id, + userId: user?.id, + action, + }) + throw new Error(`Event with id ${input.id} not found.`) + } + + if (!user || !ability.can(action, subject('Content', currentEvent))) { + log.error('event.update.unauthorized', { + eventId: input.id, + userId: user?.id, + action, + }) + throw new Error('Unauthorized') + } + + let eventSlug = currentEvent.fields.slug + + if ( + input.fields?.title !== currentEvent.fields.title && + input.fields?.slug?.includes('~') + ) { + const splitSlug = currentEvent.fields.slug.split('~') || ['', guid()] + eventSlug = `${slugify(input.fields.title)}~${splitSlug[1] || guid()}` + log.info('event.update.slug.changed', { + eventId: input.id, + oldSlug: currentEvent.fields.slug, + newSlug: eventSlug, + userId: user.id, + }) + } else if (input?.fields?.slug !== currentEvent.fields.slug) { + eventSlug = input?.fields?.slug || '' + log.info('event.update.slug.manual', { + eventId: input.id, + oldSlug: currentEvent.fields.slug, + newSlug: eventSlug, + userId: user.id, + }) + } + + try { + await upsertPostToTypeSense( + { + ...currentEvent, + resources: [], + fields: { + ...currentEvent.fields, + ...input.fields, + description: input.fields?.description || '', + slug: eventSlug, + }, + } as any, + action, + ) + log.info('event.update.typesense.success', { + eventId: input.id, + action, + userId: user.id, + }) + console.log('🔍 Event updated in Typesense') + } catch (error) { + log.error('event.update.typesense.failed', { + eventId: input.id, + error: getErrorMessage(error), + stack: getErrorStack(error), + action, + userId: user.id, + }) + console.log('❌ Error updating event in Typesense', error) + } + + try { + const updatedEvent = await courseBuilderAdapter.updateContentResourceFields( + { + id: currentEvent.id, + fields: { + ...currentEvent.fields, + ...input.fields, + slug: eventSlug, + }, + }, + ) + + if (!updatedEvent) { + console.error(`Failed to fetch updated event: ${currentEvent.id}`) + return null + } + + log.info('event.update.success', { + eventId: input.id, + action, + userId: user.id, + changes: Object.keys(input.fields || {}), + }) + + revalidate && revalidateTag('events') + try { + console.log( + `Dispatching ${RESOURCE_UPDATED_EVENT} for resource: ${updatedEvent.id} (type: ${updatedEvent.type})`, + ) + const result = await inngest.send({ + name: RESOURCE_UPDATED_EVENT, + data: { + id: updatedEvent.id, + type: updatedEvent.type, + }, + }) + console.log( + `Dispatched ${RESOURCE_UPDATED_EVENT} for resource: ${updatedEvent.id} (type: ${updatedEvent.type})`, + result, + ) + } catch (error) { + console.error(`Error dispatching ${RESOURCE_UPDATED_EVENT}`, error) + } + return updatedEvent + } catch (error) { + log.error('event.update.failed', { + eventId: input.id, + error: getErrorMessage(error), + stack: getErrorStack(error), + action, + userId: user.id, + }) + throw error + } +} + +/** + * @description Get active events (not past or sold out) + */ +export async function getActiveEvents() { + const excludedEventIds = await getSoldOutOrPastEventIds() + + const events = await db.query.contentResource.findMany({ + where: and( + eq(contentResource.type, 'event'), + excludedEventIds.length > 0 + ? sql`${contentResource.id} NOT IN ${excludedEventIds}` + : undefined, + ), + orderBy: asc(sql`JSON_EXTRACT (${contentResource.fields}, "$.startsAt")`), + with: { + resources: { + with: { + resource: true, + }, + orderBy: asc(contentResourceResource.position), + }, + tags: { + with: { + tag: true, + }, + orderBy: asc(contentResourceTag.position), + }, + resourceProducts: { + with: { + product: { + with: { + price: true, + }, + }, + }, + }, + }, + }) + + const parsedEvents = z.array(EventSchema).safeParse(events) + + if (!parsedEvents.success) { + console.error('Error parsing active events', events) + return [] + } + + return parsedEvents.data +} + +/** + * @description Create a new event + */ +export async function createEvent(input: EventFormData) { + const { session, ability } = await getServerAuthSession() + const user = session?.user + if (!user || !ability.can('create', 'Content')) { + throw new Error('Unauthorized') + } + const event = await courseBuilderAdapter.createEvent(input, user.id) + + if (!event) { + throw new Error('Failed to create event') + } + + try { + console.log( + `Dispatching ${RESOURCE_CREATED_EVENT} for resource: ${event.id} (type: ${event.type})`, + ) + await inngest.send({ + name: RESOURCE_CREATED_EVENT, + data: { + id: event.id, + type: event.type, + }, + }) + } catch (error) { + console.error(`Error dispatching ${RESOURCE_CREATED_EVENT}`, error) + } + + await upsertPostToTypeSense(event as any, 'save') + return event +} + +/** + * @description Create an event series with multiple child events + */ +export async function createEventSeries(input: EventSeriesFormData) { + const { session, ability } = await getServerAuthSession() + const user = session?.user + if (!user || !ability.can('create', 'Content')) { + throw new Error('Unauthorized') + } + + if (input.childEvents.length === 0) { + throw new Error('At least one event is required') + } + + const { eventSeries: eventSeriesInput, childEvents: childEventsInput } = + multipleEventsToEventSeriesAndEvents(input) + + try { + // Create event series and child events using the adapter + const result = await courseBuilderAdapter.createEventSeries( + { + eventSeries: eventSeriesInput, + childEvents: childEventsInput, + coupon: input.coupon, + }, + user.id, + ) + + const { eventSeries, childEvents } = result + + // Handle external service calls + try { + // Send Inngest events for the event series + console.log( + `Dispatching ${RESOURCE_CREATED_EVENT} for resource: ${eventSeries.id} (type: ${eventSeries.type})`, + ) + await inngest.send({ + name: RESOURCE_CREATED_EVENT, + data: { + id: eventSeries.id, + type: eventSeries.type, + }, + }) + + // Send Inngest events for each child event + for (const childEvent of childEvents) { + console.log( + `Dispatching ${RESOURCE_CREATED_EVENT} for resource: ${childEvent.id} (type: ${childEvent.type})`, + ) + await inngest.send({ + name: RESOURCE_CREATED_EVENT, + data: { + id: childEvent.id, + type: childEvent.type, + }, + }) + } + } catch (error) { + log.error('event.series.inngest.failed', { + eventSeriesId: eventSeries.id, + error: getErrorMessage(error), + stack: getErrorStack(error), + }) + console.error(`Error dispatching ${RESOURCE_CREATED_EVENT}`, error) + } + + // Update TypeSense + try { + await upsertPostToTypeSense(eventSeries as any, 'save') + for (const childEvent of childEvents) { + await upsertPostToTypeSense(childEvent as any, 'save') + } + } catch (error) { + log.error('event.series.typesense.failed', { + eventSeriesId: eventSeries.id, + error: getErrorMessage(error), + stack: getErrorStack(error), + }) + console.error('Error updating TypeSense', error) + } + + // Logging for successful creation + log.info('event.series.created', { + eventSeriesId: eventSeries.id, + userId: user.id, + childEventCount: childEvents.length, + }) + + for (let i = 0; i < childEvents.length; i++) { + log.info('event.series.child.created', { + childEventId: childEvents[i]!.id, + eventSeriesId: eventSeries.id, + position: i, + userId: user.id, + }) + } + + log.info('event.series.completed', { + eventSeriesId: eventSeries.id, + childEventIds: childEvents.map((e) => e.id), + userId: user.id, + }) + + return { eventSeries, childEvents } + } catch (error) { + log.error('event.series.creation.failed', { + error: getErrorMessage(error), + stack: getErrorStack(error), + userId: user.id, + eventCount: input.childEvents.length, + }) + throw error + } +} + +/** + * @description Get list of past event IDs + */ +export async function getPastEventIds(): Promise { + const actualEvents = await getAllEvents() + const excludedEventIds: string[] = [] + const now = new Date() + + for (const item of actualEvents) { + const fields = item.fields || {} + if (fields.endsAt && new Date(fields.endsAt) < now) { + excludedEventIds.push(item.id) + continue + } + + // If there's no endsAt, check startsAt for past events + if (!fields.endsAt && fields.startsAt && new Date(fields.startsAt) < now) { + excludedEventIds.push(item.id) + continue + } + } + + return excludedEventIds +} + +/** + * @description Get list of sold out or past event IDs + */ +export async function getSoldOutOrPastEventIds(): Promise { + const actualEvents = await getAllEvents() + const excludedEventIds: string[] = [] + const now = new Date() + + for (const item of actualEvents) { + const fields = item.fields || {} + if (fields.endsAt && new Date(fields.endsAt) < now) { + excludedEventIds.push(item.id) + continue + } + + // If there's no endsAt, check startsAt for past events + if (!fields.endsAt && fields.startsAt && new Date(fields.startsAt) < now) { + excludedEventIds.push(item.id) + continue + } + + // TODO: Add product availability check when we have product integration + // const productInfo = await getMinimalProductInfoWithoutUser(item.id) + // if (productInfo && productInfo.quantityAvailable <= 0 && productInfo.totalQuantity !== -1) { + // excludedEventIds.push(item.id) + // } + } + + return excludedEventIds +} + +// Utility functions +function getErrorMessage(error: unknown) { + if (isErrorWithMessage(error)) return error.message + return String(error) +} + +function getErrorStack(error: unknown) { + if (isErrorWithStack(error)) return error.stack + return undefined +} + +function isErrorWithMessage(error: unknown): error is { message: string } { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as { message: string }).message === 'string' + ) +} + +function isErrorWithStack(error: unknown): error is { stack: string } { + return ( + typeof error === 'object' && + error !== null && + 'stack' in error && + typeof (error as { stack: string }).stack === 'string' + ) +} diff --git a/apps/egghead/src/lib/events.ts b/apps/egghead/src/lib/events.ts new file mode 100644 index 000000000..5b8a982c1 --- /dev/null +++ b/apps/egghead/src/lib/events.ts @@ -0,0 +1,201 @@ +import { db } from '@/db' +import { contentResource, contentResourceResource } from '@/db/schema' +import { logger as log } from '@/lib/utils/logger' +import { and, eq, sql } from 'drizzle-orm' +import { z } from 'zod' + +import { + ContentResourceProductSchema, + ContentResourceSchema, + ResourceStateSchema, + ResourceVisibilitySchema, +} from '@coursebuilder/core/schemas/content-resource-schema' +import { productSchema } from '@coursebuilder/core/schemas/index' +import type { EventSeriesFormData } from '@coursebuilder/ui/event-creation/create-event-form' + +export { + type EventSeriesFormData, + type EventFormData, +} from '@coursebuilder/ui/event-creation/create-event-form' + +/** + * @description Event-specific field validation schema + */ +export const EventFieldsSchema = z.object({ + startsAt: z.string().datetime().nullable().optional(), + endsAt: z.string().datetime().nullable().optional(), + timezone: z.string().default('America/Los_Angeles').nullish(), + attendeeInstructions: z.string().nullable().optional(), +}) + +/** + * @description Schema for time-bound events like workshops, webinars, and live sessions + */ +export const EventSchema = ContentResourceSchema.merge( + z.object({ + videoResourceId: z.string().optional().nullable(), + tags: z.array(z.any()).default([]), + fields: z.object({ + body: z.string().nullable().optional(), + title: z.string().min(2).max(90), + description: z.string().optional(), + details: z.string().optional(), + slug: z.string(), + state: ResourceStateSchema.default('draft'), + visibility: ResourceVisibilitySchema.default('unlisted'), + ...EventFieldsSchema.shape, + image: z.string().optional(), + socialImage: z + .object({ + type: z.string(), + url: z.string().url(), + }) + .optional(), + calendarId: z.string().optional(), + thumbnailTime: z.number().nullish(), + featured: z.boolean().default(false).optional(), + }), + }), +) + +export type Event = z.infer + +/** + * @description Schema for event series that contain multiple related events + */ +export const EventSeriesSchema = ContentResourceSchema.merge( + z.object({ + videoResourceId: z.string().optional().nullable(), + tags: z.array(z.any()).default([]), + fields: z.object({ + body: z.string().nullable().optional(), + title: z.string().min(2).max(90), + description: z.string().optional(), + slug: z.string(), + state: ResourceStateSchema.default('draft'), + visibility: ResourceVisibilitySchema.default('unlisted'), + image: z.string().optional(), + socialImage: z + .object({ + type: z.string(), + url: z.string().url(), + }) + .optional(), + thumbnailTime: z.number().nullish(), + attendeeInstructions: z.string().nullable().optional(), + }), + }), +) + +export type EventSeries = z.infer + +/** + * @description Schema for creating new events + */ +export const NewEventSchema = z.object({ + type: z.literal('event').default('event'), + fields: z.object({ + title: z.string().min(2).max(90), + startsAt: z.date().nullish(), + endsAt: z.date().nullish(), + price: z.number().min(0).nullish(), + quantity: z.number().min(-1).nullish(), + description: z.string().optional(), + tagIds: z + .array( + z.object({ + id: z.string(), + fields: z.object({ + label: z.string(), + name: z.string(), + }), + }), + ) + .nullish(), + }), +}) + +export type NewEvent = z.infer + +/** + * @description Schema for creating new event series + */ +export const NewEventSeriesSchema = z.object({ + type: z.literal('event-series').default('event-series'), + fields: z.object({ + title: z.string().min(2).max(90), + description: z.string().optional(), + tagIds: z + .array( + z.object({ + id: z.string(), + fields: z.object({ + label: z.string(), + name: z.string(), + }), + }), + ) + .nullish(), + }), +}) + +export type NewEventSeries = z.infer + +/** + * @description Child event type for event series + */ +export type ChildEvent = { + type: 'event' + fields: { + title: string + startsAt: Date | null | undefined + endsAt: Date | null | undefined + description?: string | undefined + tagIds?: + | { id: string; fields: { label: string; name: string } }[] + | null + | undefined + } +} + +/** + * @description Convert MultipleEvents UI data to event series and child events + * The event series contains the product information and acts as a container + * Child events contain individual event details without pricing + */ +export function multipleEventsToEventSeriesAndEvents( + input: EventSeriesFormData, +): { + eventSeries: NewEventSeries & { + sharedFields: { + price: number | null | undefined + quantity: number | null | undefined + } + } + childEvents: ChildEvent[] +} { + const eventSeries = { + type: 'event-series' as const, + fields: { + title: input.eventSeries.title, + description: input.eventSeries.description, + tagIds: input.eventSeries.tagIds, + }, + sharedFields: { + price: input.sharedFields.price, + quantity: input.sharedFields.quantity, + }, + } + + const childEvents: ChildEvent[] = input.childEvents.map((event) => ({ + type: 'event' as const, + fields: { + title: event.fields.title, + startsAt: event.fields.startsAt, + endsAt: event.fields.endsAt, + tagIds: event.fields.tagIds, + }, + })) + + return { eventSeries, childEvents } +}