Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(egh): Course Creation Sync Flow #350

Merged
merged 6 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions apps/egghead/src/inngest/events/egghead/course-created.ts
Original file line number Diff line number Diff line change
@@ -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
>
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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(
{
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') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be part of the trigger instead of here which imo makes more sense than throwing errors when any other kind of post is created

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could throw the error instead of returning null and probably remove the need for the guard below

}

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 () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insider. among this eggheadCourse instead of just course because so many courses

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, {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind of weird to go through all of the above and then send this different 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
})
})
},
)
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -39,22 +40,38 @@ export const createCourseInSanity = inngest.createFunction(
return contributor
})

const courseCreatedEvent = await step.waitForEvent(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not super stoked about the way these are connected together and think we might consider tightening it up or making the flow more obvious

this is the danger of event driven

entropy

`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: {
current: fields?.slug,
_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) {
Expand Down
2 changes: 2 additions & 0 deletions apps/egghead/src/inngest/inngest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -29,5 +30,6 @@ export const inngestConfig = {
syncPostsToEggheadLessons,
createCourseInSanity,
instructorInviteCreated,
createCourseInEgghead,
],
}
5 changes: 5 additions & 0 deletions apps/egghead/src/inngest/inngest.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
99 changes: 99 additions & 0 deletions apps/egghead/src/lib/egghead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -278,3 +280,100 @@ export const eggheadLessonSchema = z.object({
})

export type EggheadLesson = z.infer<typeof eggheadLessonSchema>

export const KvstoreSchema = z.object({
tags: z.array(z.string()).optional(),
difficulty: z.string().optional(),
})
export type Kvstore = z.infer<typeof KvstoreSchema>

export const EggheadDbCourseSchema = z.object({
id: z.number(),
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<typeof EggheadDbCourseSchema>

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
}
41 changes: 39 additions & 2 deletions apps/egghead/src/lib/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,36 @@ export const getCachedEggheadInstructorForUser = unstable_cache(
{ revalidate: 3600, tags: ['users'] },
)

export const InstructorDbSchema = z.object({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the db here egghead? let's label it if so

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<typeof InstructorDbSchema>

export const loadEggheadInstructorForUser = async (userId: string) => {
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
Expand All @@ -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) => {
Expand Down
Loading