Skip to content

feat(egh): core instructor invite flow #435

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

Merged
merged 19 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
45fac7e
wip: send email to instructor on invite
zacjones93 Mar 14, 2025
f8e8514
feat(egh): send email to invite instructor
zacjones93 Mar 14, 2025
8426009
fix(email): await email rendering in sendAnEmail function
zacjones93 Mar 14, 2025
92eef50
feat(egh): implement instructor onboarding flow
zacjones93 Mar 17, 2025
f290100
refactor(egh): extract steps into functions
zacjones93 Mar 17, 2025
3c5c7a6
feat(egh): enhance instructor invite flow
zacjones93 Mar 17, 2025
9991170
fix(egh): use inviteId properly to update invite record
zacjones93 Mar 18, 2025
8981a59
fix(egh): satisfy PageProps constraint
zacjones93 Mar 18, 2025
e102c81
fix(egh): satisfy more PageProps constraints
zacjones93 Mar 18, 2025
3ef7305
fix: set production org id
zacjones93 Mar 18, 2025
cd13d51
feat: add profile picture upload
zacjones93 Mar 18, 2025
c76e41c
Merge branch 'main' of github.com:joelhooks/course-builder into zj-ng…
zacjones93 Mar 18, 2025
c776d7d
refactor: move instructor invite logic to egghead lib
zacjones93 Mar 18, 2025
dd1096f
Update apps/egghead/src/lib/egghead/instructor.ts
zacjones93 Mar 18, 2025
936b1cd
feat(eggo): invite process with Slack integration
nicollguarnizo Mar 19, 2025
2a60a69
Merge branch 'main' into zj-ng/instructor-invite-flow
nicollguarnizo Mar 19, 2025
91c82d1
fix(instructor-invite): update Slack user ID for invite completion no…
nicollguarnizo Mar 19, 2025
a5df567
Merge branch 'main' into zj-ng/instructor-invite-flow
zacjones93 Apr 2, 2025
96b32ab
Merge branch 'main' into zj-ng/instructor-invite-flow
zacjones93 Apr 8, 2025
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
5 changes: 5 additions & 0 deletions apps/egghead/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ const config = {
hostname: 'neatly-diverse-goldfish.ngrok-free.app',
port: '',
},
{
protocol: 'https',
hostname: 'res.cloudinary.com',
port: '',
},
],
},
pageExtensions: ['mdx', 'ts', 'tsx'],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use client'

import { useState } from 'react'

import { Button, Input, Label, useToast } from '@coursebuilder/ui'

import { acceptInstructorInvite } from '../actions'

interface AcceptInviteFormProps {
inviteId: string
inviteEmail: string
}

export function AcceptInviteForm({
inviteId,
inviteEmail,
}: AcceptInviteFormProps) {
const [email, setEmail] = useState(inviteEmail)
const [isSubmitting, setIsSubmitting] = useState(false)
const { toast } = useToast()

async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setIsSubmitting(true)

try {
await acceptInstructorInvite({ inviteId, email })
} catch (error) {
if ((error as Error).message === 'NEXT_REDIRECT') {
toast({
title: 'Invitation accepted!',
description: 'You will be redirected to complete your profile.',
})
} else {
Comment on lines +29 to +34
Copy link
Contributor Author

Choose a reason for hiding this comment

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

for some reason next redirects show up as errors so the 'error' toast was popping up even though the invite was successfully accepted. Had to check for NEXT_REDIRECT in the message

toast({
title: 'Error',
description: 'Failed to accept invitation. Please try again.',
variant: 'destructive',
})
}
} finally {
setIsSubmitting(false)
}
}

return (
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<p className="text-sm text-gray-500">
Please enter the email address you would like to use as your
instructor account for egghead. We've pre-filled this with the email
address you were invited with.
</p>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isSubmitting}
/>
</div>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Accepting...' : 'Accept Invitation'}
</Button>
</form>
)
}
43 changes: 43 additions & 0 deletions apps/egghead/src/app/(user)/invites/[inviteId]/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use server'

import { redirect } from 'next/navigation'
import { db } from '@/db'
import { invites } from '@/db/schema'
import { eq } from 'drizzle-orm'

interface AcceptInstructorInviteParams {
inviteId: string
email: string
}

export async function acceptInstructorInvite({
inviteId,
email,
}: AcceptInstructorInviteParams) {
const invite = await db.query.invites.findFirst({
where: eq(invites.id, inviteId),
columns: {
inviteEmail: true,
inviteState: true,
},
})

if (!invite) {
throw new Error('Invite not found')
}

if (invite.inviteState !== 'INITIATED') {
throw new Error('Invite has already been used or expired')
}

await db
.update(invites)
.set({
inviteState: 'VERIFIED',
acceptedEmail: email,
confirmedAt: new Date(),
})
.where(eq(invites.id, inviteId))
Comment on lines +33 to +40
Copy link
Contributor Author

Choose a reason for hiding this comment

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

not much happens when an email is accepted, we use the invite.acceptedEmail later onto check for egghead account and create a new one if it doesn't exist


redirect(`/invites/${inviteId}/onboarding`)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client'

import React, { type Dispatch, type SetStateAction } from 'react'
import Script from 'next/script'
import { env } from '@/env.mjs'

import { Button } from '@coursebuilder/ui'

export const CloudinaryUploadButton: React.FC<{
Copy link
Contributor Author

Choose a reason for hiding this comment

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

created a onboarding specific upload button that doesn't do an auth check and saves the uploaded profile picture in state to send with the rest of the profile information. This button doesn't save the uploaded profile picture as a resource as there is no user to associate it to

dir: string
id: string
onImageUploadedAction: Dispatch<SetStateAction<string>>
}> = ({ dir, id, onImageUploadedAction }) => {
const cloudinaryRef = React.useRef<any>(null)
const widgetRef = React.useRef<any>(null)
const containerRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
cloudinaryRef.current = (window as any).cloudinary
}, [])

return (
<div className="">
<Script
strategy="afterInteractive"
onLoad={() => {
cloudinaryRef.current = (window as any).cloudinary
}}
src="https://upload-widget.cloudinary.com/global/all.js"
type="text/javascript"
/>
<div className="w-fit p-5">
<span className="text-muted-foreground mb-2 block text-balance text-sm">
Upload a profile image for your instructor profile. A square 1000x1000
image works best.
</span>
<Button
type="button"
variant="outline"
className="flex w-full"
onClick={() => {
widgetRef.current = cloudinaryRef.current.createUploadWidget(
{
cloudName: env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
uploadPreset: env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET,
// inline_container: '#cloudinary-upload-widget-container',
folder: `${dir}/${id}`,
},
(error: any, result: any) => {
if (!error && result && result.event === 'success') {
console.debug('Done! Here is the image info: ', result.info)
onImageUploadedAction(result.info.secure_url)
}
Comment on lines +48 to +52
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve error handling in widget callback.

The callback doesn't handle errors, potentially leaving users without feedback when uploads fail.

(error: any, result: any) => {
	if (!error && result && result.event === 'success') {
		console.debug('Done! Here is the image info: ', result.info)
		onImageUploadedAction(result.info.secure_url)
+	} else if (error) {
+		console.error('Error during upload:', error)
+		// Consider adding user-facing error feedback here
	}
},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(error: any, result: any) => {
if (!error && result && result.event === 'success') {
console.debug('Done! Here is the image info: ', result.info)
onImageUploadedAction(result.info.secure_url)
}
(error: any, result: any) => {
if (!error && result && result.event === 'success') {
console.debug('Done! Here is the image info: ', result.info)
onImageUploadedAction(result.info.secure_url)
} else if (error) {
console.error('Error during upload:', error)
// Consider adding user-facing error feedback here
}
},

},
)
widgetRef.current.open()
}}
Comment on lines +40 to +56
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Enhance error handling for Cloudinary widget initialization.

The code assumes that cloudinaryRef.current will always be defined when the button is clicked, but this could lead to runtime errors if the script hasn't loaded properly.

onClick={() => {
+	if (!cloudinaryRef.current) {
+		console.error('Cloudinary script not loaded')
+		return
+	}
	widgetRef.current = cloudinaryRef.current.createUploadWidget(
		{
			cloudName: env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
			uploadPreset: env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET,
			// inline_container: '#cloudinary-upload-widget-container',
			folder: `${dir}/${id}`,
		},
		(error: any, result: any) => {
			if (!error && result && result.event === 'success') {
				console.debug('Done! Here is the image info: ', result.info)
				onImageUploadedAction(result.info.secure_url)
+			} else if (error) {
+				console.error('Error uploading image:', error)
			}
		},
	)
	widgetRef.current.open()
}}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onClick={() => {
widgetRef.current = cloudinaryRef.current.createUploadWidget(
{
cloudName: env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
uploadPreset: env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET,
// inline_container: '#cloudinary-upload-widget-container',
folder: `${dir}/${id}`,
},
(error: any, result: any) => {
if (!error && result && result.event === 'success') {
console.debug('Done! Here is the image info: ', result.info)
onImageUploadedAction(result.info.secure_url)
}
},
)
widgetRef.current.open()
}}
onClick={() => {
if (!cloudinaryRef.current) {
console.error('Cloudinary script not loaded')
return
}
widgetRef.current = cloudinaryRef.current.createUploadWidget(
{
cloudName: env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
uploadPreset: env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET,
// inline_container: '#cloudinary-upload-widget-container',
folder: `${dir}/${id}`,
},
(error: any, result: any) => {
if (!error && result && result.event === 'success') {
console.debug('Done! Here is the image info: ', result.info)
onImageUploadedAction(result.info.secure_url)
} else if (error) {
console.error('Error uploading image:', error)
}
},
)
widgetRef.current.open()
}}

>
Upload Profile Image
</Button>
</div>
<div ref={containerRef} id="cloudinary-upload-widget-container" />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
'use client'

import { useState } from 'react'
import Image from 'next/image'

import { Button, Input, Label, Textarea, useToast } from '@coursebuilder/ui'

import { createInstructorProfile } from '../actions'
import { CloudinaryUploadButton } from './cloudinary-profile-uploader'

interface InstructorOnboardingFormProps {
inviteId: string
acceptedEmail: string
}

export function InstructorOnboardingForm({
inviteId,
acceptedEmail,
}: InstructorOnboardingFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageUrl, setImageUrl] = useState(
'https://res.cloudinary.com/dg3gyk0gu/image/upload/v1566948117/transcript-images/Eggo_Notext.png',
)
const { toast } = useToast()

async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsSubmitting(true)

const formData = new FormData(e.currentTarget)

try {
await createInstructorProfile({
inviteId,
firstName: formData.get('firstName') as string,
lastName: formData.get('lastName') as string,
email: formData.get('email') as string,
twitter: formData.get('twitter') as string,
bluesky: formData.get('bluesky') as string,
website: formData.get('website') as string,
bio: formData.get('bio') as string,
profileImageUrl: imageUrl,
})
} catch (error) {
if ((error as Error).message === 'NEXT_REDIRECT') {
toast({
title: 'Invitation accepted!',
description: 'Your instructor profile has been created successfully.',
})
} else {
toast({
title: 'Error',
description: 'Failed to accept invitation. Please try again.',
variant: 'destructive',
})
}
} finally {
Comment on lines +45 to +57
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider alternative approach for handling redirects

The current error handling catches NEXT_REDIRECT errors, which is a side effect of Next.js's redirect function. This approach is brittle as it relies on implementation details of Next.js.

Consider returning a result object from the server action instead of relying on redirect errors:

// In actions.ts
export async function createInstructorProfile({...}) {
+  try {
    await inngest.send({...})
+    return { success: true }
+  } catch (error) {
+    console.error('Failed to create instructor profile:', error)
+    return { success: false, error: (error as Error).message }
+  }
-  redirect(`/invites/${inviteId}/onboarding/completed`)
}

// In your component
try {
-  await createInstructorProfile({...})
+  const result = await createInstructorProfile({...})
+  if (result.success) {
+    toast({
+      title: 'Invitation accepted!',
+      description: 'Your instructor profile has been created successfully.',
+    })
+    // Use router.push instead of redirect for client-side navigation
+    router.push(`/invites/${inviteId}/onboarding/completed`)
+  } else {
+    throw new Error(result.error || 'Unknown error')
+  }
} catch (error) {
-  if ((error as Error).message === 'NEXT_REDIRECT') {
-    toast({...}) // Success toast
-  } else {
    toast({...}) // Error toast
-  }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ((error as Error).message === 'NEXT_REDIRECT') {
toast({
title: 'Invitation accepted!',
description: 'Your instructor profile has been created successfully.',
})
} else {
toast({
title: 'Error',
description: 'Failed to accept invitation. Please try again.',
variant: 'destructive',
})
}
} finally {
export async function createInstructorProfile({...}) {
+ try {
await inngest.send({...})
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to create instructor profile:', error)
+ return { success: false, error: (error as Error).message }
+ }
- redirect(`/invites/${inviteId}/onboarding/completed`)
}
Suggested change
if ((error as Error).message === 'NEXT_REDIRECT') {
toast({
title: 'Invitation accepted!',
description: 'Your instructor profile has been created successfully.',
})
} else {
toast({
title: 'Error',
description: 'Failed to accept invitation. Please try again.',
variant: 'destructive',
})
}
} finally {
try {
- await createInstructorProfile({...})
+ const result = await createInstructorProfile({...})
+ if (result.success) {
+ toast({
+ title: 'Invitation accepted!',
+ description: 'Your instructor profile has been created successfully.',
+ })
+ // Use router.push instead of redirect for client-side navigation
+ router.push(`/invites/${inviteId}/onboarding/completed`)
+ } else {
+ throw new Error(result.error || 'Unknown error')
+ }
} catch (error) {
- if ((error as Error).message === 'NEXT_REDIRECT') {
- toast({
- title: 'Invitation accepted!',
- description: 'Your instructor profile has been created successfully.',
- })
- } else {
- toast({
- title: 'Error',
- description: 'Failed to accept invitation. Please try again.',
- variant: 'destructive',
- })
- }
+ toast({
+ title: 'Error',
+ description: 'Failed to accept invitation. Please try again.',
+ variant: 'destructive',
+ })
} finally {
// any finalization code remains unchanged
}

setIsSubmitting(false)
}
}

return (
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First name *</Label>
<Input
id="firstName"
name="firstName"
required
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last name *</Label>
<Input
id="lastName"
name="lastName"
required
disabled={isSubmitting}
/>
</div>
</div>

<div className="space-y-2">
<Label htmlFor="email">egghead.io User email *</Label>
<Input
id="email"
name="email"
type="email"
defaultValue={acceptedEmail}
required
disabled={true}
/>
</div>

<div className="space-y-2">
<Label htmlFor="twitter">Twitter</Label>
<Input id="twitter" name="twitter" disabled={isSubmitting} />
</div>

<div className="space-y-2">
<Label htmlFor="bluesky">BlueSky</Label>
<Input id="bluesky" name="bluesky" disabled={isSubmitting} />
</div>

<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input
id="website"
name="website"
type="url"
disabled={isSubmitting}
/>
</div>

<div className="space-y-2">
<Label htmlFor="bio">Bio short</Label>
<Textarea
id="bio"
name="bio"
placeholder="Please tell us a bit about yourself. What do you like to do?"
className="h-32"
disabled={isSubmitting}
/>
</div>

<div className="flex w-full items-center gap-4">
<Image
src={imageUrl}
alt="Instructor profile image"
width={75}
height={75}
/>

<CloudinaryUploadButton
dir="instructor-images"
id={inviteId}
onImageUploadedAction={setImageUrl}
/>
</div>
</div>

<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating Profile...' : 'Create Profile'}
</Button>
</form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/db'
import { eggheadPgQuery } from '@/db/eggheadPostgres'
import { accounts, invites, profiles, users } from '@/db/schema'
import { INSTRUCTOR_INVITE_COMPLETED_EVENT } from '@/inngest/events/instructor-invite-completed'
import { inngest } from '@/inngest/inngest.server'
import { addRoleToUser } from '@/lib/users'
import { eq } from 'drizzle-orm'

interface CreateInstructorProfileParams {
inviteId: string
firstName: string
lastName: string
email: string
twitter?: string
website?: string
bio?: string
bluesky?: string
profileImageUrl?: string
}

export async function createInstructorProfile({
inviteId,
firstName,
lastName,
email,
twitter,
website,
bluesky,
bio,
profileImageUrl,
}: CreateInstructorProfileParams) {
await inngest.send({
name: INSTRUCTOR_INVITE_COMPLETED_EVENT,
data: {
inviteId,
firstName,
lastName,
email,
twitter,
website,
bio,
bluesky,
profileImageUrl,
},
})
Comment on lines +36 to +49
Copy link
Contributor Author

Choose a reason for hiding this comment

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

server action kicks off inngest flow


redirect(`/invites/${inviteId}/onboarding/completed`)
}
Loading
Loading