From 0d24f7e50465b7d08c5e54fac0ceb7e7db37162d Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Mon, 16 Dec 2024 16:10:47 -0700 Subject: [PATCH 1/5] wip: add createEggheadCourse Co-authored-by: Creeland --- .../egghead/create-course-in-egghead.ts | 43 ++++++++ apps/egghead/src/lib/egghead.ts | 99 +++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts diff --git a/apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts b/apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts new file mode 100644 index 000000000..befc6d6ab --- /dev/null +++ b/apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts @@ -0,0 +1,43 @@ +import { POST_CREATED_EVENT } from '@/inngest/events/post-created' +import { inngest } from '@/inngest/inngest.server' +import { createEggheadCourse } from '@/lib/egghead' +import { loadEggheadInstructorForUser } from '@/lib/users' +import { NonRetriableError } from 'inngest' + +export const createCourseInEgghead = inngest.createFunction( + { + id: 'create-course-in-egghead', + name: 'Create Course in Egghead', + }, + { + event: POST_CREATED_EVENT, + }, + async ({ event, step }) => { + const post = await step.run('verify-post', async () => { + const { post } = event.data + if (!post) { + throw new NonRetriableError('Post not found') + } + + if (post.fields?.postType !== 'course') { + throw new NonRetriableError('Post is not a course') + } + + return post + }) + + const instructor = await step.run('get-instructor', async () => { + const instructor = await loadEggheadInstructorForUser(post.createdById) + + if (!instructor) { + return null + } + + return instructor + }) + + const course = await step.run('create-course', async () => { + const { fields } = post + }) + }, +) diff --git a/apps/egghead/src/lib/egghead.ts b/apps/egghead/src/lib/egghead.ts index d387ab00f..e62967393 100644 --- a/apps/egghead/src/lib/egghead.ts +++ b/apps/egghead/src/lib/egghead.ts @@ -12,6 +12,8 @@ import { getPost } from './posts-query' import 'server-only' +import slugify from '@sindresorhus/slugify' + export type EggheadLessonState = 'published' | 'approved' | 'retired' export type EggheadLessonVisibilityState = 'indexed' | 'hidden' @@ -278,3 +280,100 @@ export const eggheadLessonSchema = z.object({ }) export type EggheadLesson = z.infer + +export const KvstoreSchema = z.object({ + tags: z.array(z.string()).optional(), + difficulty: z.string().optional(), +}) +export type Kvstore = z.infer + +export const EggheadDbCourseSchema = z.object({ + id: z.number().optional(), + access_state: z.string().optional(), + code_url: z.string().optional(), + created_at: z.coerce.date().optional(), + description: z.string().optional(), + featured: z.boolean().optional(), + guid: z.string().optional(), + is_complete: z.boolean().optional(), + kvstore: KvstoreSchema.optional(), + owner_id: z.number().optional(), + price: z.number().optional(), + published: z.boolean().optional(), + published_at: z.coerce.date().optional(), + queue_order: z.number().optional(), + revshare_percent: z.number().optional(), + row_order: z.number().optional(), + shared_id: z.string().optional(), + site: z.string().optional(), + slug: z.string().optional(), + square_cover_content_type: z.string().optional(), + square_cover_file_name: z.string().optional(), + square_cover_file_size: z.number().optional(), + square_cover_processing: z.boolean().optional(), + square_cover_updated_at: z.coerce.date().optional(), + state: z.string().optional(), + summary: z.string().optional(), + tagline: z.string().optional(), + title: z.string().optional(), + tweeted_on: z.coerce.date().optional(), + updated_at: z.coerce.date().optional(), + visibility_state: z.string().optional(), +}) +export type EggheadDbCourse = z.infer + +const EGGHEAD_INITIAL_COURSE_STATE = 'draft' + +export async function createEggheadCourse(input: { + title: string + guid: string + ownerId: number +}) { + const { title, guid, ownerId } = input + + const columns = [ + 'title', + 'owner_id', + 'slug', + 'guid', + 'state', + 'visibility_state', + 'created_at', + 'updated_at', + ] + + const slug = slugify(title) + `~${guid}` + + const values = [ + title, + ownerId, + slug, + guid, + EGGHEAD_INITIAL_COURSE_STATE, + 'hidden', + new Date(), // created_at + new Date(), // updated_at + ] + + const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ') + + const query = ` + INSERT INTO playlists (${columns.join(', ')}) + VALUES (${placeholders}) + RETURNING id + ` + + const eggheadCourseResult = await eggheadPgQuery(query, values) + + const eggheadCourse = EggheadDbCourseSchema.safeParse( + eggheadCourseResult.rows[0], + ) + + if (!eggheadCourse.success) { + throw new Error('Failed to create course in egghead', { + cause: eggheadCourse.error.flatten().fieldErrors, + }) + } + + return eggheadCourse.data +} From b18c2ff6ec1e9b140731f6148a2674ab477191dd Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Tue, 17 Dec 2024 10:52:00 -0700 Subject: [PATCH 2/5] feat(egh): add egghead course created event and type definition - Introduced a new event for course creation: `EGGHEAD_COURSE_CREATED_EVENT`. - Added type definition for `EggheadCourseCreated` and its associated schema using Zod for validation. - Updated the event types in `inngest.server.ts` to include the new course created event. Co-authored-by: Creeland --- .../src/inngest/events/egghead/course-created.ts | 15 +++++++++++++++ apps/egghead/src/inngest/inngest.server.ts | 5 +++++ 2 files changed, 20 insertions(+) create mode 100644 apps/egghead/src/inngest/events/egghead/course-created.ts diff --git a/apps/egghead/src/inngest/events/egghead/course-created.ts b/apps/egghead/src/inngest/events/egghead/course-created.ts new file mode 100644 index 000000000..4a7d89837 --- /dev/null +++ b/apps/egghead/src/inngest/events/egghead/course-created.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +export const EGGHEAD_COURSE_CREATED_EVENT = 'egghead/course-created' + +export type EggheadCourseCreated = { + name: typeof EGGHEAD_COURSE_CREATED_EVENT + data: EggheadCourseCreatedEvent +} +export const EggheadCourseCreatedEventSchema = z.object({ + id: z.number(), +}) + +export type EggheadCourseCreatedEvent = z.infer< + typeof EggheadCourseCreatedEventSchema +> diff --git a/apps/egghead/src/inngest/inngest.server.ts b/apps/egghead/src/inngest/inngest.server.ts index 403729c7c..63cc8b6b9 100644 --- a/apps/egghead/src/inngest/inngest.server.ts +++ b/apps/egghead/src/inngest/inngest.server.ts @@ -20,6 +20,10 @@ import DeepgramProvider from '@coursebuilder/core/providers/deepgram' import OpenAIProvider from '@coursebuilder/core/providers/openai' import PartykitProvider from '@coursebuilder/core/providers/partykit' +import { + EGGHEAD_COURSE_CREATED_EVENT, + EggheadCourseCreated, +} from './events/egghead/course-created' import { EGGHEAD_LESSON_CREATED_EVENT, EggheadLessonCreated, @@ -53,6 +57,7 @@ export type Events = { [POST_CREATED_EVENT]: PostCreated [SYNC_POSTS_TO_EGGHEAD_LESSONS_EVENT]: SyncPostsToEggheadLessonsEvent [INSTRUCTOR_INVITE_CREATED_EVENT]: InstructorInviteCreated + [EGGHEAD_COURSE_CREATED_EVENT]: EggheadCourseCreated } const callbackBase = From 047a4611a0ab941bbe7d04fca2f0ec50bfbdd5b1 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Tue, 17 Dec 2024 10:54:11 -0700 Subject: [PATCH 3/5] feat(egh): add instructor schema and enforce id requirement in EggheadDbCourseSchema - Changed the `id` field in `EggheadDbCourseSchema` from optional to required. - Introduced a new `InstructorDbSchema` with comprehensive fields for instructor details, including `user_id`, `email`, and various optional attributes. - Updated the `loadEggheadInstructorForUser` function to validate the instructor data using the new schema, ensuring robust error handling. Co-authored-by: Creeland --- apps/egghead/src/lib/egghead.ts | 2 +- apps/egghead/src/lib/users.ts | 41 +++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/egghead/src/lib/egghead.ts b/apps/egghead/src/lib/egghead.ts index e62967393..6a34704d5 100644 --- a/apps/egghead/src/lib/egghead.ts +++ b/apps/egghead/src/lib/egghead.ts @@ -288,7 +288,7 @@ export const KvstoreSchema = z.object({ export type Kvstore = z.infer export const EggheadDbCourseSchema = z.object({ - id: z.number().optional(), + id: z.number(), access_state: z.string().optional(), code_url: z.string().optional(), created_at: z.coerce.date().optional(), diff --git a/apps/egghead/src/lib/users.ts b/apps/egghead/src/lib/users.ts index eca630d45..75f7f5dfa 100644 --- a/apps/egghead/src/lib/users.ts +++ b/apps/egghead/src/lib/users.ts @@ -40,6 +40,36 @@ export const getCachedEggheadInstructorForUser = unstable_cache( { revalidate: 3600, tags: ['users'] }, ) +export const InstructorDbSchema = z.object({ + id: z.number(), + user_id: z.number(), + email: z.string(), + avatar_content_type: z.string().nullish(), + avatar_file_name: z.string().nullish(), + avatar_file_size: z.number().nullish(), + avatar_processing: z.boolean().nullish(), + avatar_updated_at: z.coerce.date().nullish(), + bio_short: z.string().nullish(), + contract_id: z.string().nullish(), + created_at: z.coerce.date().nullish(), + first_name: z.string().nullish(), + gear_tracking_number: z.string().nullish(), + internal_note: z.string().nullish(), + last_name: z.string().nullish(), + percentage: z.string().nullish(), + profile_picture_url: z.string().nullish(), + skip_onboarding: z.boolean().nullish(), + slack_group_id: z.string().nullish(), + slack_id: z.string().nullish(), + slug: z.string().nullish(), + state: z.string().nullish(), + trained_by_instructor_id: z.number().nullish(), + twitter: z.string().nullish(), + updated_at: z.coerce.date().nullish(), + website: z.string().nullish(), +}) +export type InstructorDb = z.infer + export const loadEggheadInstructorForUser = async (userId: string) => { const user = await db.query.users.findFirst({ where: eq(users.id, userId), @@ -60,11 +90,18 @@ export const loadEggheadInstructorForUser = async (userId: string) => { return null } - const instructor = await eggheadPgQuery( + const instructorResult = await eggheadPgQuery( `SELECT * FROM instructors WHERE user_id = ${eggheadAccount.providerAccountId}`, ) - return instructor.rows[0] + const instructor = InstructorDbSchema.safeParse(instructorResult.rows[0]) + + if (!instructor.success) { + console.log('instructor.error', instructor.error) + throw new Error('Failed to load instructor from egghead') + } + + return instructor.data } export const loadUsersForRole = async (role: string) => { From d4ea6291a05f4b7373871f3aaf86fb847506f3a4 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Tue, 17 Dec 2024 10:55:37 -0700 Subject: [PATCH 4/5] feat(egh): implement createCourseInEgghead function for course creation - Added the `createCourseInEgghead` function to handle course creation in Egghead. - Integrated error handling for instructor retrieval and course creation. - Emitted `EGGHEAD_COURSE_CREATED_EVENT` upon successful course creation. - Updated the database to sync the created course ID with the corresponding post. This enhancement improves the workflow for creating courses and ensures proper event handling and data synchronization. Co-authored-by: Creeland --- .../egghead/create-course-in-egghead.ts | 46 ++++++++++++++++++- apps/egghead/src/inngest/inngest.config.ts | 2 + 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts b/apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts index befc6d6ab..5edeb4708 100644 --- a/apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts +++ b/apps/egghead/src/inngest/functions/egghead/create-course-in-egghead.ts @@ -1,7 +1,11 @@ +import { db } from '@/db' +import { contentResource } from '@/db/schema' +import { EGGHEAD_COURSE_CREATED_EVENT } from '@/inngest/events/egghead/course-created' import { POST_CREATED_EVENT } from '@/inngest/events/post-created' import { inngest } from '@/inngest/inngest.server' import { createEggheadCourse } from '@/lib/egghead' import { loadEggheadInstructorForUser } from '@/lib/users' +import { eq } from 'drizzle-orm' import { NonRetriableError } from 'inngest' export const createCourseInEgghead = inngest.createFunction( @@ -36,8 +40,48 @@ export const createCourseInEgghead = inngest.createFunction( return instructor }) + if (!instructor) { + throw new NonRetriableError('Instructor not found', { + cause: { + postId: post.id, + instructorId: post.createdById, + }, + }) + } + const course = await step.run('create-course', async () => { - const { fields } = post + const courseGuid = post.fields?.slug.split('~').pop() + + const course = await createEggheadCourse({ + title: post.fields?.title, + guid: courseGuid, + ownerId: instructor.user_id, + }) + + return course + }) + + step.sendEvent(EGGHEAD_COURSE_CREATED_EVENT, { + name: EGGHEAD_COURSE_CREATED_EVENT, + data: { + id: course.id, + }, + }) + + await step.run('sync-course-id-to-builder', async () => { + await db + .update(contentResource) + .set({ + fields: { + ...post.fields, + eggheadCourseId: course.id, + }, + }) + .where(eq(contentResource.id, post.id)) + .catch((error) => { + console.error('🚨 Error creating post', error) + throw error + }) }) }, ) diff --git a/apps/egghead/src/inngest/inngest.config.ts b/apps/egghead/src/inngest/inngest.config.ts index 242325326..464623cdb 100644 --- a/apps/egghead/src/inngest/inngest.config.ts +++ b/apps/egghead/src/inngest/inngest.config.ts @@ -3,6 +3,7 @@ import { inngest } from '@/inngest/inngest.server' import { courseBuilderCoreFunctions } from '@coursebuilder/core/inngest' +import { createCourseInEgghead } from './functions/egghead/create-course-in-egghead' import { instructorInviteCreated } from './functions/instructor-invite-created' import { migrateTipsToPosts } from './functions/migrate-tips-to-posts' import { notifySlack } from './functions/notify-slack-for-post' @@ -29,5 +30,6 @@ export const inngestConfig = { syncPostsToEggheadLessons, createCourseInSanity, instructorInviteCreated, + createCourseInEgghead, ], } From 4e20a70c92c458bc6997b29bd46574ac974bc542 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Tue, 17 Dec 2024 10:57:09 -0700 Subject: [PATCH 5/5] feat(egh): wait for course created event in sanity course sync - Imported `EGGHEAD_COURSE_CREATED_EVENT` to wait for course creation events from Egghead. - Implemented error handling to throw a `NonRetriableError` if the course is not created. - Mapped the `railsCourseId` to the newly created course ID and updated the `sharedId` to use the course's slug. - Improved the overall workflow for course creation in Sanity by ensuring proper event synchronization. Co-authored-by: Creeland --- .../sanity/create-course-in-sanity.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/apps/egghead/src/inngest/functions/sanity/create-course-in-sanity.ts b/apps/egghead/src/inngest/functions/sanity/create-course-in-sanity.ts index eebbe1b3c..9283a6167 100644 --- a/apps/egghead/src/inngest/functions/sanity/create-course-in-sanity.ts +++ b/apps/egghead/src/inngest/functions/sanity/create-course-in-sanity.ts @@ -1,3 +1,4 @@ +import { EGGHEAD_COURSE_CREATED_EVENT } from '@/inngest/events/egghead/course-created' import { POST_CREATED_EVENT } from '@/inngest/events/post-created' import { inngest } from '@/inngest/inngest.server' import { SanityCourseSchema } from '@/lib/sanity-content' @@ -39,9 +40,25 @@ export const createCourseInSanity = inngest.createFunction( return contributor }) + const courseCreatedEvent = await step.waitForEvent( + `wait for course created in egghead-rails`, + { + event: EGGHEAD_COURSE_CREATED_EVENT, + timeout: '3 mins', + }, + ) + + if (!courseCreatedEvent) { + throw new NonRetriableError('Course not created in egghead') + } + + const courseId = courseCreatedEvent.data.id + const sanityCourse = await step.run('create-course', async () => { const { fields } = post + const courseGuid = fields?.slug.split('~').pop() + const coursePayload = SanityCourseSchema.safeParse({ title: fields?.title, slug: { @@ -49,12 +66,12 @@ export const createCourseInSanity = inngest.createFunction( _type: 'slug', }, description: fields?.body, - railsCourseId: fields?.railsCourseId, collaborators: [contributor], searchIndexingState: 'hidden', accessLevel: 'pro', productionProcessState: 'new', - sharedId: post.id, + sharedId: courseGuid, + railsCourseId: courseId, }) if (!coursePayload.success) {