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): core instructor invite flow #435

Merged
merged 19 commits into from
Apr 8, 2025
Merged

Conversation

zacjones93
Copy link
Contributor

@zacjones93 zacjones93 commented Mar 18, 2025

This implements the core instructor invite flow letting a person accept an invite with their email and fill out profile information

Step 0: receive email invite

image

Step 1: Accept invite with desired email /invites/:inviteId

egghead Post Builder

Step 2: fill out instructor profile information /invites/:inviteId/onboarding

egghead Post Builder

Step 3: profile is created via inngest flow /invites/:inviteId/onboarding/completed

egghead Post Builder

Step 4: prompted to login /invites/:inviteId/onboarding/completed

egghead Post Builder

gif

Summary by CodeRabbit

  • New Features
    • Expanded image support to enable displaying photos from a popular third-party hosting service.
    • Introduced a new invite acceptance flow and a dedicated onboarding process for instructors, including an improved form to create instructor profiles.
    • Added a seamless profile photo upload experience integrated with an external image service.
    • Enhanced invitation communications with a refreshed email template for instructor invites.
    • Added functionality for tracking who invited a user through new data fields.

- Added `InvitePage` component to handle displaying and accepting instructor invitations.
- Created `AcceptInviteForm` component for users to submit their email and accept the invitation.
- Implemented `acceptInstructorInvite` action to validate and update the invite state in the database.
- Updated email template to provide a clickable link for accepting the invitation.
- Enhanced invite creation process to ensure proper email handling and state management.
- Added server actions for accepting instructor invites and creating instructor profiles.
- Created onboarding page and form components for new instructors to complete their profiles.
- Implemented logic to validate invite states and handle user creation in the database.
- Enhanced email template for instructor invites to streamline the onboarding process.
- Introduced a completed onboarding page to confirm successful profile creation.
- Updated `InvitePage` to redirect verified invites to onboarding.
- Modified `AcceptInviteForm` to handle toast notifications based on invite acceptance.
- Implemented `createInstructorProfile` action to send instructor invite completion events.
- Added `instructorInviteCompleted` function to handle user creation and role assignment.
- Enhanced onboarding completion page with status checks and loading indicators.
- Introduced `invitedById` to track who sent the invite in the database schema.
Copy link

vercel bot commented Mar 18, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
ai-hero ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 8, 2025 8:15pm
astro-party ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 8, 2025 8:15pm
course-builder-egghead ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 8, 2025 8:15pm
course-builder-poc ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 8, 2025 8:15pm
epic-react-builder ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 8, 2025 8:15pm
epicdev-ai ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 8, 2025 8:15pm
go-local-first ✅ Ready (Inspect) Visit Preview 💬 Add feedback Apr 8, 2025 8:15pm

Copy link
Contributor

coderabbitai bot commented Mar 18, 2025

Walkthrough

This pull request introduces multiple enhancements to the instructor invitation and onboarding flows. It expands the remote image configuration for Cloudinary, adds several new React components, pages, and server-side actions to handle invitation acceptance and instructor onboarding, updates the database schema to include an inviter identifier, and revises event handling with new Inngest functions and types. Additional improvements include updates to email templates and Egghead API integrations for user management.

Changes

File(s) Change Summary
apps/egghead/next.config.mjs Added a Cloudinary remote pattern to the Next.js image configuration.
apps/egghead/src/app/(user)/invites/[inviteId]/... (accept-invite-form.tsx, actions.ts, onboarding/_components/cloudinary-profile-uploader.tsx, onboarding/_components/instructor-onboarding-form.tsx, onboarding/actions.ts, onboarding/completed/page.tsx, onboarding/page.tsx, page.tsx) Introduced new React components and pages for accepting invites and managing instructor onboarding, along with associated server-side actions for invite acceptance and instructor profile creation.
apps/egghead/src/app/admin/instructors/invite/actions.ts Updated invite processing to include a session check and the invitedById field in the invite data.
apps/egghead/src/db/schemas/invites.ts Enhanced the invite schema by adding an invitedById field to track who initiated the invite.
apps/egghead/src/emails/instructor-invite-email.tsx Created a new email template component for inviting instructors.
apps/egghead/src/inngest/... (events/instructor-invite-completed.ts, events/instructor-invite-created.ts, functions/instructor-invite-completed.ts, functions/instructor-invite-created.ts, inngest.config.ts, inngest.server.ts) Added new event definitions, functions, and configuration updates to handle instructor invite completion events along with updates to instructor invite creation (e.g., inclusion of invitedById).
apps/egghead/src/lib/egghead/... (auth.ts, index.ts, instructor.ts) Added new functions for Egghead API integration (retrieving/creating users) and instructor management (assigning roles, creating instructor profiles, and adding revenue splits), and re-exported instructor functionalities.
packages/utils-email/src/send-an-email.ts Modified the email sending function to await the asynchronous render call.

Suggested reviewers

  • vojtaholik

Poem

I’m a rabbit with a hopping heart,
Celebrating changes, a fresh new start!
Invites accepted with a gentle nudge,
Onboarding flows, no need to judge.
Code bounces like a happy hare, 🐰
In the digital garden, change fills the air!
Hoppy coding to one and all!

Warning

There were issues while running some tools. Please review the errors and either fix the tool’s configuration or disable the tool if it’s a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

Scope: all 3 workspace projects
 WARN  Ignoring not compatible lockfile at /tmp/eslint/pnpm-lock.yaml
Progress: resolved 1, reused 0, downloaded 0, added 0
/tmp/eslint/packages/config/eslint-config:
 ERR_PNPM_WORKSPACE_PKG_NOT_FOUND  In packages/config/eslint-config: "@coursebuilder/eslint-plugin@workspace:*" is in the dependencies but no package named "@coursebuilder/eslint-plugin" is present in the workspace

This error happened while installing a direct dependency of /tmp/eslint/packages/config/eslint-config

Packages found in the workspace:

Tip

⚡🧪 Multi-step agentic review comment chat (experimental)
  • We're introducing multi-step agentic chat in review comments. This experimental feature enhances review discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments.
    - To enable this feature, set early_access to true under in the settings.

📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 936b1cd and 91c82d1.

📒 Files selected for processing (1)
  • apps/egghead/src/inngest/functions/instructor-invite-completed.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/egghead/src/inngest/functions/instructor-invite-completed.ts

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (26)
apps/egghead/src/app/(user)/invites/[inviteId]/page.tsx (3)

8-8: Consider simplifying the Params type

The Params type is defined as a Promise, which is unusual for route parameters. In Next.js App Router, params are typically plain objects, not promises.

-type Params = Promise<{ inviteId: string }>
+type Params = { inviteId: string }

12-18: Consider adding try/catch for database query

While the component handles cases where the invite doesn't exist, it doesn't handle potential database query errors. Adding error handling would make the component more robust.

-	const invite = await db.query.invites.findFirst({
-		where: eq(invites.id, inviteId),
-		columns: {
-			inviteEmail: true,
-			inviteState: true,
-		},
-	})
+	let invite
+	try {
+		invite = await db.query.invites.findFirst({
+			where: eq(invites.id, inviteId),
+			columns: {
+				inviteEmail: true,
+				inviteState: true,
+			},
+		})
+	} catch (error) {
+		console.error('Error fetching invite:', error)
+		return <div className="container mx-auto max-w-2xl py-16">
+			<h1 className="mb-8 text-3xl font-bold">Error</h1>
+			<p>There was an error processing your invitation. Please try again later.</p>
+		</div>
+	}

20-26: Consider adding loading state

Since this is an async component that fetches data from the database, it would be beneficial to show a loading state or skeleton UI while data is being fetched, especially for users on slower connections.

You could implement this with React Suspense or a simple loading indicator component. Here's a basic example of how you might approach this:

// In a separate Loading component
export function InvitePageLoading() {
  return (
    <div className="container mx-auto max-w-2xl py-16">
      <div className="h-8 w-64 bg-gray-200 rounded animate-pulse mb-8"></div>
      <div className="space-y-4">
        <div className="h-4 w-20 bg-gray-200 rounded animate-pulse"></div>
        <div className="h-10 bg-gray-200 rounded animate-pulse"></div>
        <div className="h-10 w-32 bg-gray-200 rounded animate-pulse"></div>
      </div>
    </div>
  )
}

// Then use with Suspense in the parent component or route
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/page.tsx (2)

10-22: Add error handling for database operations

The code properly handles the case when an invite is not found or not in the correct state, but it doesn't handle potential exceptions from the database query. Consider adding a try/catch block around the database operation to gracefully handle any unexpected errors.

export default async function OnboardingPage(props: { params: Params }) {
	const { inviteId } = await props.params
+	try {
		const invite = await db.query.invites.findFirst({
			where: eq(invites.id, inviteId),
			columns: {
				acceptedEmail: true,
				inviteState: true,
			},
		})

		if (!invite || invite.inviteState !== 'VERIFIED') {
			notFound()
		}
+	} catch (error) {
+		console.error('Error fetching invite:', error)
+		notFound()
+	}

29-29: Avoid non-null assertion on acceptedEmail

The code uses a non-null assertion (!) on invite.acceptedEmail. While this might be safe if the database schema guarantees that a 'VERIFIED' invite always has an acceptedEmail, it's better to handle potential null/undefined values explicitly for type safety.

<InstructorOnboardingForm
	inviteId={inviteId}
-	acceptedEmail={invite.acceptedEmail!}
+	acceptedEmail={invite.acceptedEmail || ''}
/>
apps/egghead/src/app/(user)/invites/[inviteId]/actions.ts (2)

17-32: Add explicit email validation before proceeding

While form validation might happen client-side, it's good practice to also validate the email format server-side before using it for critical operations. Consider adding email format validation to ensure the provided email is valid.

export async function acceptInstructorInvite({
	inviteId,
	email,
}: AcceptInstructorInviteParams) {
+	// Basic email validation
+	const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+	if (!emailRegex.test(email)) {
+		throw new Error('Invalid email format')
+	}

	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')
	}

33-41: Add error handling for database update operation

The code doesn't handle potential exceptions from the database update operation. Consider adding a try/catch block to gracefully handle any unexpected errors during the update.

+	try {
		await db
			.update(invites)
			.set({
				inviteState: 'VERIFIED',
				acceptedEmail: email,
				confirmedAt: new Date(),
			})
			.where(eq(invites.id, inviteId))
+	} catch (error) {
+		console.error('Error updating invite:', error)
+		throw new Error('Failed to verify invite')
+	}
apps/egghead/src/inngest/functions/instructor-invite-created.ts (2)

22-33: Add error handling for database operations

The function doesn't handle potential exceptions when inserting the invite record. Consider adding error handling to ensure the function doesn't fail silently if there's a database issue.

const newInviteId = await step.run('create invite', async () => {
	const inviteId = nanoid()
+	try {
		await db.insert(invites).values({
			id: inviteId,
			inviteState: 'INITIATED',
			inviteEmail: event.data.email,
			invitedById: event.data.invitedById,
			createdAt: new Date(),
		})
+	} catch (error) {
+		console.error('Error creating invite:', error)
+		throw new NonRetriableError(`Failed to create invite: ${error.message}`)
+	}

	return inviteId
})

37-49: Add validation checks for environment variables

The code uses environment variables for constructing the invite URL and setting email addresses, but doesn't validate their existence. Consider adding validation to ensure these critical environment variables are defined.

+if (!process.env.COURSEBUILDER_URL) {
+	throw new NonRetriableError('COURSEBUILDER_URL environment variable is not defined')
+}
const inviteUrl = `${process.env.COURSEBUILDER_URL}/invites/${newInviteId}`

const sendResponse = await step.run('send the invite email', async () => {
+	if (!process.env.NEXT_PUBLIC_SUPPORT_EMAIL) {
+		throw new NonRetriableError('NEXT_PUBLIC_SUPPORT_EMAIL environment variable is not defined')
+	}
	return await sendAnEmail({
		Component: InstructorInviteEmail,
		componentProps: {
			inviteUrl,
		},
		Subject: 'You have been invited to join egghead as an instructor',
		To: event.data.email,
		ReplyTo: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,
		From: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,
		type: 'transactional',
	})
})
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/actions.ts (2)

36-50: Add error handling for the Inngest event sending

The function doesn't handle potential exceptions when sending the Inngest event. Consider adding a try/catch block to gracefully handle any unexpected errors.

export async function createInstructorProfile({
	inviteId,
	firstName,
	lastName,
	email,
	twitter,
	website,
	bluesky,
	bio,
	profileImageUrl,
}: CreateInstructorProfileParams) {
+	try {
		await inngest.send({
			name: INSTRUCTOR_INVITE_COMPLETED_EVENT,
			data: {
				inviteId,
				firstName,
				lastName,
				email,
				twitter,
				website,
				bio,
				bluesky,
				profileImageUrl,
			},
		})
+	} catch (error) {
+		console.error('Error sending instructor invite completed event:', error)
+		throw new Error('Failed to create instructor profile')
+	}

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

13-23: Add input validation for required fields

The function accepts several parameters but doesn't validate them before sending the event. Consider adding validation for required fields to ensure data integrity.

export async function createInstructorProfile({
	inviteId,
	firstName,
	lastName,
	email,
	twitter,
	website,
	bluesky,
	bio,
	profileImageUrl,
}: CreateInstructorProfileParams) {
+	// Validate required fields
+	if (!inviteId || !firstName || !lastName || !email) {
+		throw new Error('Missing required fields')
+	}
+
+	// Validate email format
+	const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+	if (!emailRegex.test(email)) {
+		throw new Error('Invalid email format')
+	}
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/_components/cloudinary-profile-uploader.tsx (2)

14-19: Consider adding cleanup for Cloudinary references.

The component correctly initializes the Cloudinary reference, but it's good practice to clear references in the cleanup function of useEffect to prevent memory leaks.

React.useEffect(() => {
	cloudinaryRef.current = (window as any).cloudinary
+	return () => {
+		// Clean up references when component unmounts
+		cloudinaryRef.current = null
+		widgetRef.current = null
+	}
}, [])

45-45: Remove commented code.

There's a commented line referring to an inline container that's not being used.

-// inline_container: '#cloudinary-upload-widget-container',
apps/egghead/src/lib/egghead/auth.ts (3)

86-111: Remove debug console logs before production release.

The function includes console logs that should be removed or converted to proper logging before going to production.

return await fetch(
	`https://app.egghead.io/api/v1/users/${email}?by_email=true&support=true`,
	{
		headers: {
			Authorization: `Bearer ${process.env.EGGHEAD_ADMIN_TOKEN}`,
			Accept: 'application/json',
			'Content-Type': 'application/json',
		},
	},
).then(async (res) => {
	if (!res.ok) {
-		console.error('Full response:', {
-			status: res.status,
-			statusText: res.statusText,
-			headers: Object.fromEntries(res.headers.entries()),
-		})
		throw new Error(
			`Failed to get egghead user: ${res.status} ${res.statusText}`,
		)
	}
	const data = await res.json()
-	console.log('egghead user data', data)
	return data
})

87-110: Consider using more specific error types.

The function currently throws generic errors. Consider extending the EggheadApiError class that's already imported and used elsewhere in the file.

return await fetch(
	`https://app.egghead.io/api/v1/users/${email}?by_email=true&support=true`,
	{
		headers: {
			Authorization: `Bearer ${process.env.EGGHEAD_ADMIN_TOKEN}`,
			Accept: 'application/json',
			'Content-Type': 'application/json',
		},
	},
).then(async (res) => {
	if (!res.ok) {
		console.error('Full response:', {
			status: res.status,
			statusText: res.statusText,
			headers: Object.fromEntries(res.headers.entries()),
		})
-		throw new Error(
-			`Failed to get egghead user: ${res.status} ${res.statusText}`,
-		)
+		throw new EggheadApiError(res.statusText, res.status)
	}
	const data = await res.json()
	console.log('egghead user data', data)
	return data
})

118-140: Consider adding rate limiting or retry logic.

API calls to external services should typically include retry logic for transient failures, especially for critical user flows like registration.

You could implement a simple retry mechanism:

export async function createEggheadUser(email: string) {
+	const maxRetries = 3;
+	let retries = 0;
+	
+	async function attemptRequest() {
	return await fetch('https://app.egghead.io/api/v1/users/send_token', {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			Accept: 'application/json',
		},
		body: JSON.stringify({
			email,
		}),
	}).then(async (res) => {
		if (!res.ok) {
+			if (res.status >= 500 && retries < maxRetries) {
+				retries++;
+				// Exponential backoff
+				const delay = 1000 * Math.pow(2, retries);
+				await new Promise(resolve => setTimeout(resolve, delay));
+				return attemptRequest();
+			}
			throw new Error(
				`Failed to create egghead user: ${res.status} ${res.statusText}`,
			)
		}
		const data = await res.json()
		if (!data) {
			throw new Error('No data returned from egghead API')
		}
		return data
	})
+	}
+	
+	return attemptRequest();
}
apps/egghead/src/app/(user)/invites/[inviteId]/_components/accept-invite-form.tsx (2)

9-13: Ensure alignment between prop names and usage.
The prop inviteEmail is directly stored in local state as email. If additional context is needed—such as clarifying that this email may differ from the original invite—consider renaming or adding inline documentation.


46-67: UI/UX improvement suggestion.
Disable the entire form (including labels and hints) when isSubmitting to clearly indicate that submission is ongoing and prevent accidental edits.

apps/egghead/src/inngest/functions/instructor-invite-completed.ts (1)

28-154: Consider using transactional or compensating operations for partial failures.
Currently, if one of the steps mid-process fails, the initial data insertions won’t be reverted, leaving the data in a partially updated state. A transaction or distinct rollback mechanism could ensure consistency if any error occurs.

apps/egghead/src/emails/instructor-invite-email.tsx (1)

31-35: Refine the invitation link to a styled button.
You already import Button from @react-email/components, but you’re using a plain <a> tag. Switching to the Button component may enhance accessibility and styling consistency.

apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/completed/page.tsx (3)

13-13: Consider using more descriptive naming for the Params type

The Params type is defined as a Promise containing an inviteId, which is a bit unusual. Typically, route parameters in Next.js aren't Promise types. Consider renaming to something more descriptive like RouteParams and removing the Promise wrapper if not needed.

-type Params = Promise<{ inviteId: string }>
+type RouteParams = { inviteId: string }

16-64: Good implementation of InviteStatus component, consider adding enum for invite states

The component effectively handles different states of the invitation process and provides appropriate UI feedback. To improve code maintainability:

  1. Consider using an enum for invite states instead of string literals
  2. Add explicit error handling for database query failures
+ // At the top of the file or in a separate types file
+ enum InviteState {
+   PENDING = 'PENDING',
+   ACCEPTED = 'ACCEPTED',
+   VERIFIED = 'VERIFIED',
+   COMPLETED = 'COMPLETED'
+ }

async function InviteStatus({
  // ... existing parameters
}) {
  // ... existing code

  try {
    const invite = await db.query.invites.findFirst({
      // ... existing query
    })
    
    // ... existing checks and rendering
-   if (invite.inviteState === 'VERIFIED') {
+   if (invite.inviteState === InviteState.VERIFIED) {
      // ... existing rendering
    }
    
    // ... rest of the component
+  } catch (error) {
+    console.error('Failed to fetch invite status:', error);
+    return (
+      <div className="text-destructive">
+        An error occurred while checking your invite status. Please try again.
+      </div>
+    );
+  }
}

66-93: Validate inviteId early to prevent unnecessary downstream processing

The component nicely fetches providers and CSRF token, but consider validating the inviteId parameter early to prevent unnecessary processing if it's invalid.

export default async function OnboardingCompletedPage(props: {
	params: Params
}) {
	const { inviteId } = await props.params
+	
+	// Early validation of inviteId format
+	if (!inviteId || typeof inviteId !== 'string' || inviteId.length === 0) {
+		notFound()
+	}
	
	const providers = await getProviders()
	const csrfToken = await getCsrf()

	// Rest of the component remains the same
}
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/_components/instructor-onboarding-form.tsx (2)

21-23: Externalize default image URL to configuration

The default profile image URL is hardcoded. Consider moving this to a configuration constant or environment variable to make it easier to update across the application.

+ // At the top of the file or in a constants file
+ const DEFAULT_PROFILE_IMAGE = 'https://res.cloudinary.com/dg3gyk0gu/image/upload/v1566948117/transcript-images/Eggo_Notext.png';

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',
+		DEFAULT_PROFILE_IMAGE,
	)

32-43: Add form data validation before submission

The form currently relies on HTML's basic validation (required attributes). Consider adding more robust validation for fields like Twitter, BlueSky, and website format before submission.

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

	const formData = new FormData(e.currentTarget)
+	const firstName = formData.get('firstName') as string
+	const lastName = formData.get('lastName') as string
+	const email = formData.get('email') as string
+	const twitter = formData.get('twitter') as string
+	const bluesky = formData.get('bluesky') as string
+	const website = formData.get('website') as string
+	const bio = formData.get('bio') as string
+
+	// Simple validation
+	if (!firstName || !lastName || !email) {
+		toast({
+			title: 'Missing required fields',
+			description: 'Please fill out all required fields.',
+			variant: 'destructive',
+		})
+		setIsSubmitting(false)
+		return
+	}
+
+	// Validate Twitter format if provided
+	if (twitter && !twitter.match(/^@?[\w\d]+$/)) {
+		toast({
+			title: 'Invalid Twitter handle',
+			description: 'Please enter a valid Twitter handle.',
+			variant: 'destructive',
+		})
+		setIsSubmitting(false)
+		return
+	}

	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,
+			firstName,
+			lastName,
+			email,
+			twitter,
+			bluesky,
+			website,
+			bio,
			profileImageUrl: imageUrl,
		})
apps/egghead/src/lib/egghead/instructor.ts (1)

97-122: Make revenue split percentage configurable

The revenue split percentage (0.2 or 20%) is hardcoded. Consider making it configurable.

export async function addRevenueSplitToEggheadInstructor({
	eggheadInstructorId,
+	percentage = 0.2, // Default to 20%
}: {
	eggheadInstructorId: string
+	percentage?: number
}) {
	const revenueSplitQuery = `
    INSERT INTO instructor_revenue_splits (
      instructor_id,
      credit_to_instructor_id,
      percentage,
      from_date
    ) VALUES (
      $1,
      $2,
      $3,
      $4
    );
  `

+	// Validate percentage is between 0 and 1
+	const validPercentage = Math.max(0, Math.min(1, percentage))

	await eggheadPgQuery(revenueSplitQuery, [
		eggheadInstructorId,
		null,
-		0.2,
+		validPercentage,
		new Date(),
	])
}
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between efc3c89 and c776d7d.

📒 Files selected for processing (22)
  • apps/egghead/next.config.mjs (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/_components/accept-invite-form.tsx (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/actions.ts (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/_components/cloudinary-profile-uploader.tsx (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/_components/instructor-onboarding-form.tsx (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/actions.ts (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/completed/page.tsx (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/page.tsx (1 hunks)
  • apps/egghead/src/app/(user)/invites/[inviteId]/page.tsx (1 hunks)
  • apps/egghead/src/app/admin/instructors/invite/actions.ts (1 hunks)
  • apps/egghead/src/db/schemas/invites.ts (2 hunks)
  • apps/egghead/src/emails/instructor-invite-email.tsx (1 hunks)
  • apps/egghead/src/inngest/events/instructor-invite-completed.ts (1 hunks)
  • apps/egghead/src/inngest/events/instructor-invite-created.ts (1 hunks)
  • apps/egghead/src/inngest/functions/instructor-invite-completed.ts (1 hunks)
  • apps/egghead/src/inngest/functions/instructor-invite-created.ts (2 hunks)
  • apps/egghead/src/inngest/inngest.config.ts (2 hunks)
  • apps/egghead/src/inngest/inngest.server.ts (2 hunks)
  • apps/egghead/src/lib/egghead/auth.ts (1 hunks)
  • apps/egghead/src/lib/egghead/index.ts (1 hunks)
  • apps/egghead/src/lib/egghead/instructor.ts (1 hunks)
  • packages/utils-email/src/send-an-email.ts (1 hunks)
🧰 Additional context used
🧬 Code Definitions (10)
apps/egghead/src/app/admin/instructors/invite/actions.ts (2)
apps/egghead/src/inngest/inngest.server.ts (1) (1)
  • inngest (90:94)
apps/egghead/src/inngest/events/instructor-invite-created.ts (1) (1)
  • INSTRUCTOR_INVITE_CREATED_EVENT (1:1)
apps/egghead/src/inngest/functions/instructor-invite-created.ts (1)
apps/egghead/src/emails/instructor-invite-email.tsx (1) (1)
  • InstructorInviteEmail (19:43)
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/actions.ts (2)
apps/egghead/src/inngest/inngest.server.ts (1) (1)
  • inngest (90:94)
apps/egghead/src/inngest/events/instructor-invite-completed.ts (1) (1)
  • INSTRUCTOR_INVITE_COMPLETED_EVENT (1:1)
apps/egghead/src/inngest/inngest.server.ts (1)
apps/egghead/src/inngest/events/instructor-invite-completed.ts (2) (2)
  • INSTRUCTOR_INVITE_COMPLETED_EVENT (1:1)
  • InstructorInviteCompleted (2:15)
apps/egghead/src/app/(user)/invites/[inviteId]/page.tsx (1)
apps/egghead/src/app/(user)/invites/[inviteId]/_components/accept-invite-form.tsx (1) (1)
  • AcceptInviteForm (14:69)
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/page.tsx (1)
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/_components/instructor-onboarding-form.tsx (1) (1)
  • InstructorOnboardingForm (16:150)
apps/egghead/src/inngest/functions/instructor-invite-completed.ts (3)
apps/egghead/src/inngest/events/instructor-invite-completed.ts (1) (1)
  • INSTRUCTOR_INVITE_COMPLETED_EVENT (1:1)
apps/egghead/src/lib/egghead/auth.ts (1) (1)
  • getEggheadUserByEmail (86:111)
apps/egghead/src/lib/egghead/instructor.ts (3) (3)
  • createEggheadInstructor (34:91)
  • addInstructorRoleToEggheadUser (9:20)
  • addRevenueSplitToEggheadInstructor (97:122)
apps/egghead/src/app/(user)/invites/[inviteId]/_components/accept-invite-form.tsx (1)
apps/egghead/src/app/(user)/invites/[inviteId]/actions.ts (1) (1)
  • acceptInstructorInvite (13:43)
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/_components/instructor-onboarding-form.tsx (2)
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/actions.ts (1) (1)
  • createInstructorProfile (25:52)
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/_components/cloudinary-profile-uploader.tsx (1) (1)
  • CloudinaryUploadButton (9:64)
apps/egghead/src/inngest/inngest.config.ts (1)
apps/egghead/src/inngest/functions/instructor-invite-completed.ts (1) (1)
  • instructorInviteCompleted (20:155)
🔇 Additional comments (14)
apps/egghead/src/lib/egghead/index.ts (1)

26-26: Export addition looks good!

Adding the instructor module export integrates well with the existing pattern in this file. This makes all instructor-related functionality available through the egghead library's API, which is essential for the instructor invitation flow being implemented.

apps/egghead/src/inngest/inngest.config.ts (1)

6-6: Appropriate integration of the instructor invite completion handler

The addition of the instructorInviteCompleted function to the Inngest configuration is well-structured and follows the existing pattern. This function is crucial for processing completed instructor invitations as part of the invitation flow.

Also applies to: 31-31

apps/egghead/src/db/schemas/invites.ts (1)

25-25: Schema enhancement looks good

Adding the invitedById field to track who initiated an invitation is valuable for analytics and accountability. The field is properly typed and sized in both the Zod schema and the Drizzle table definition.

Also applies to: 49-49

apps/egghead/src/app/(user)/invites/[inviteId]/page.tsx (1)

10-34: Well-structured invite page component

The page component correctly handles different invite states, with appropriate redirects and error handling. The flow is logical:

  1. Fetch the invite using the ID
  2. Redirect to onboarding if already verified
  3. Show 404 if not found or not in the proper state
  4. Present the acceptance form if valid
apps/egghead/src/app/(user)/invites/[inviteId]/onboarding/actions.ts (1)

51-51: Consider providing feedback on success before redirecting

The function immediately redirects after sending the event without waiting for confirmation that the profile was successfully created. Consider providing feedback to the user or ensuring the profile creation is completed before redirecting.

-	redirect(`/invites/${inviteId}/onboarding/completed`)
+	// Add a small delay to ensure the event is processed
+	await new Promise(resolve => setTimeout(resolve, 500))
+	
+	// Check if the profile was created successfully
+	// This is a simplified example - you might need to implement a different approach
+	const profileCreated = await checkProfileCreated(email)
+	
+	if (profileCreated) {
+		redirect(`/invites/${inviteId}/onboarding/completed`)
+	} else {
+		throw new Error('Profile creation in progress. Please try again in a moment.')
+	}

Do you have an existing mechanism to verify that the profile was created successfully before redirecting? If not, this suggestion might require additional implementation work.

apps/egghead/src/inngest/events/instructor-invite-completed.ts (1)

1-15: Well-structured event type for instructor onboarding.

The event structure provides a clear contract for the instructor invite completion event with appropriate required fields (id, name, email) and optional profile data.

apps/egghead/src/inngest/inngest.server.ts (2)

31-34: Proper import of new instructor invite event.

The import is correctly structured following the established pattern in the file.


64-64: Correctly registered new event in the Events type.

The new instructor invite completed event has been properly integrated into the event system.

apps/egghead/src/app/(user)/invites/[inviteId]/_components/accept-invite-form.tsx (1)

22-44: Consider validating user-provided email before submission.
If users change the pre-filled email field, the server-side logic could reject their request if the back end is strict about matching the invite's email. You might add client-side form validation and an appropriate error message to guide users.

apps/egghead/src/inngest/events/instructor-invite-created.ts (1)

6-6: Tracking who initiated an instructor invite

The addition of the invitedById field to the InstructorInviteCreated event type enables proper tracking of which user initiated an instructor invitation. This enhancement supports audit trails and adds context to the invitation process.

apps/egghead/next.config.mjs (1)

40-44: Added Cloudinary to remote image patterns

The addition of Cloudinary's domain to the remote patterns list properly enables the application to load and display images from Cloudinary. This configuration is necessary for the instructor profile image upload functionality in the onboarding flow.

apps/egghead/src/app/admin/instructors/invite/actions.ts (3)

5-5: Added server authentication import

Appropriate addition of the authentication utility to verify the current user's session.


8-10: Added session validation

Good implementation of session validation before proceeding with the invite process.


14-17: Capturing inviter ID with instructor invite

This change ensures that each instructor invitation is linked to the admin who created it, enhancing audit capabilities and data integrity in the system.

@@ -52,7 +52,7 @@ export async function sendAnEmail<ComponentPropsType = any>({
const { render } = await import('@react-email/render')

// The email html needs to be rendered from the React component
const emailHtml = render(Component(componentProps))
const emailHtml = await render(Component(componentProps))
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Critical fix for asynchronous rendering!

The change correctly updates the render function call to use await, ensuring proper handling of the asynchronous rendering process. Without this fix, the email HTML would contain a stringified Promise instead of the actual rendered content, which would result in broken emails.

Comment on lines +48 to +52
(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)
}
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
}
},

Comment on lines +40 to +56
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()
}}
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()
}}

Comment on lines +118 to +140
export async function createEggheadUser(email: string) {
return await fetch('https://app.egghead.io/api/v1/users/send_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
email,
}),
}).then(async (res) => {
if (!res.ok) {
throw new Error(
`Failed to create egghead user: ${res.status} ${res.statusText}`,
)
}
const data = await res.json()
if (!data) {
throw new Error('No data returned from egghead API')
}
return data
})
}
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

Add validation for the returned user data.

The function should validate the structure of the returned data, similar to how getEggheadUserProfile uses a schema to validate the response.

+ import { z } from "zod";
+
+ const EggheadUserSchema = z.object({
+   // Define expected fields here based on API response
+   id: z.string(),
+   email: z.string().email(),
+   // Add other fields as needed
+ });

export async function createEggheadUser(email: string) {
	return await fetch('https://app.egghead.io/api/v1/users/send_token', {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			Accept: 'application/json',
		},
		body: JSON.stringify({
			email,
		}),
	}).then(async (res) => {
		if (!res.ok) {
			throw new Error(
				`Failed to create egghead user: ${res.status} ${res.statusText}`,
			)
		}
		const data = await res.json()
		if (!data) {
			throw new Error('No data returned from egghead API')
		}
+		const parsedData = EggheadUserSchema.safeParse(data);
+		if (!parsedData.success) {
+			throw new Error('Invalid user data returned from egghead API', {
+				cause: parsedData.error.flatten().fieldErrors,
+			});
+		}
-		return data
+		return parsedData.data
	})
}
📝 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
export async function createEggheadUser(email: string) {
return await fetch('https://app.egghead.io/api/v1/users/send_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
email,
}),
}).then(async (res) => {
if (!res.ok) {
throw new Error(
`Failed to create egghead user: ${res.status} ${res.statusText}`,
)
}
const data = await res.json()
if (!data) {
throw new Error('No data returned from egghead API')
}
return data
})
}
import { z } from "zod";
const EggheadUserSchema = z.object({
// Define expected fields here based on API response
id: z.string(),
email: z.string().email(),
// Add other fields as needed
});
export async function createEggheadUser(email: string) {
return await fetch('https://app.egghead.io/api/v1/users/send_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
email,
}),
}).then(async (res) => {
if (!res.ok) {
throw new Error(
`Failed to create egghead user: ${res.status} ${res.statusText}`,
)
}
const data = await res.json()
if (!data) {
throw new Error('No data returned from egghead API')
}
const parsedData = EggheadUserSchema.safeParse(data);
if (!parsedData.success) {
throw new Error('Invalid user data returned from egghead API', {
cause: parsedData.error.flatten().fieldErrors,
});
}
return parsedData.data
})
}

Comment on lines +103 to +105
if (!eggheadUser && !eggheadUser.id) {
throw new NonRetriableError('Failed to find/create egghead user')
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix null check logic to avoid runtime error.
Using && in this condition could trigger a crash when eggheadUser is null because eggheadUser.id would be accessed. Switch to an OR condition to correctly handle both possibilities.

- if (!eggheadUser && !eggheadUser.id) {
+ if (!eggheadUser || !eggheadUser.id) {
    throw new NonRetriableError('Failed to find/create egghead user')
}
📝 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 (!eggheadUser && !eggheadUser.id) {
throw new NonRetriableError('Failed to find/create egghead user')
}
if (!eggheadUser || !eggheadUser.id) {
throw new NonRetriableError('Failed to find/create egghead user')
}

Comment on lines +45 to +57
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 {
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
}

Comment on lines +80 to +90
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ')

const query = `
INSERT INTO instructors (${columns.join(', ')})
VALUES (${placeholders})
RETURNING id
`

const eggheadInstructorResult = await eggheadPgQuery(query, values)

return eggheadInstructorResult.rows[0].id
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

Add error handling for database operations

The database query doesn't have error handling, which could lead to uncaught exceptions.

	const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ')

	const query = `
    INSERT INTO instructors (${columns.join(', ')})
    VALUES (${placeholders})
    RETURNING id
  `

+	try {
		const eggheadInstructorResult = await eggheadPgQuery(query, values)
+		
+		if (!eggheadInstructorResult.rows || eggheadInstructorResult.rows.length === 0) {
+			throw new Error('Failed to create instructor: No ID returned')
+		}
		
		return eggheadInstructorResult.rows[0].id
+	} catch (error) {
+		console.error('Error creating instructor in database:', error)
+		throw new Error(`Failed to create instructor: ${(error as Error).message}`)
+	}
📝 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
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ')
const query = `
INSERT INTO instructors (${columns.join(', ')})
VALUES (${placeholders})
RETURNING id
`
const eggheadInstructorResult = await eggheadPgQuery(query, values)
return eggheadInstructorResult.rows[0].id
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ')
const query = `
INSERT INTO instructors (${columns.join(', ')})
VALUES (${placeholders})
RETURNING id
`
try {
const eggheadInstructorResult = await eggheadPgQuery(query, values)
if (!eggheadInstructorResult.rows || eggheadInstructorResult.rows.length === 0) {
throw new Error('Failed to create instructor: No ID returned')
}
return eggheadInstructorResult.rows[0].id
} catch (error) {
console.error('Error creating instructor in database:', error)
throw new Error(`Failed to create instructor: ${(error as Error).message}`)
}

Comment on lines +67 to +70
const values = [
firstName,
lastName,
`${firstName}-${lastName}`,
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 slug generation to avoid collisions

The current slug generation is too simple and could easily lead to collisions for instructors with the same name.

+ import { slugify } from 'some-slug-library'; // Import a robust slugify function
+ // Or implement a more robust slug generation function
+ function generateUniqueSlug(firstName: string, lastName: string): string {
+   const base = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`;
+   const timestamp = Date.now().toString(36).slice(-4);
+   return `${base}-${timestamp}`;
+ }

const values = [
	firstName,
	lastName,
-	`${firstName}-${lastName}`,
+	generateUniqueSlug(firstName, lastName),
	userId,
📝 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
const values = [
firstName,
lastName,
`${firstName}-${lastName}`,
import { slugify } from 'some-slug-library'; // Import a robust slugify function
// Or implement a more robust slug generation function
function generateUniqueSlug(firstName: string, lastName: string): string {
const base = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`;
const timestamp = Date.now().toString(36).slice(-4);
return `${base}-${timestamp}`;
}
const values = [
firstName,
lastName,
generateUniqueSlug(firstName, lastName),
userId,
];

Comment on lines +29 to +34
if ((error as Error).message === 'NEXT_REDIRECT') {
toast({
title: 'Invitation accepted!',
description: 'You will be redirected to complete your profile.',
})
} else {
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

Comment on lines +33 to +40
await db
.update(invites)
.set({
inviteState: 'VERIFIED',
acceptedEmail: email,
confirmedAt: new Date(),
})
.where(eq(invites.id, inviteId))
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


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

Comment on lines +36 to +49
await inngest.send({
name: INSTRUCTOR_INVITE_COMPLETED_EVENT,
data: {
inviteId,
firstName,
lastName,
email,
twitter,
website,
bio,
bluesky,
profileImageUrl,
},
})
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

data: { email },
data: {
email,
invitedById: session.user.id,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

adds an invitedById to the invite so we know who invited who

Comment on lines +91 to +105
let eggheadUser = null
eggheadUser = await step.run('attempt to get egghead user', async () => {
return await getEggheadUserByEmail(acceptedEmail)
})

if (!eggheadUser || !eggheadUser.id) {
eggheadUser = await step.run('create egghead user', async () => {
await createEggheadUser(acceptedEmail)
return await getEggheadUserByEmail(acceptedEmail)
})
}

if (!eggheadUser && !eggheadUser.id) {
throw new NonRetriableError('Failed to find/create egghead user')
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

check for existing egghead user, if none then create one and get egghead user again

`https://app.egghead.io/api/v1/users/${email}?by_email=true&support=true`,
{
headers: {
Authorization: `Bearer ${process.env.EGGHEAD_ADMIN_TOKEN}`,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

getting egghead user in this flow requires admin token because there is no user to auth with

Comment on lines 9 to 20
export async function addInstructorRoleToEggheadUser({
userId,
}: {
userId: string
}) {
const instructorRoleQuery = `
INSERT INTO users_roles (user_id, role_id)
VALUES ($1, $2)
`

await eggheadPgQuery(instructorRoleQuery, [userId, 8])
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

instructor role is added to egghead user object through join table

Comment on lines +97 to +122
export async function addRevenueSplitToEggheadInstructor({
eggheadInstructorId,
}: {
eggheadInstructorId: string
}) {
const revenueSplitQuery = `
INSERT INTO instructor_revenue_splits (
instructor_id,
credit_to_instructor_id,
percentage,
from_date
) VALUES (
$1,
$2,
$3,
$4
);
`

await eggheadPgQuery(revenueSplitQuery, [
eggheadInstructorId,
null,
0.2,
new Date(),
])
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

revenue split is also created when an instructor object is initially created

const emailHtml = render(Component(componentProps))
const emailHtml = await render(Component(componentProps))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

sending emails in course builder wasn't working with the error below. The render was returning a promise so we need to await it to get the actual html string

image

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
apps/egghead/src/lib/egghead/instructor.ts (4)

11-22: Add error handling and duplicate prevention to role assignment

This function executes a database query without any error handling. Consider adding try/catch blocks and checking if the role already exists to prevent duplicate entries.

export async function addInstructorRoleToEggheadUser({
	userId,
}: {
	userId: string
}) {
+	try {
+		// Check if the role already exists for this user to prevent duplicates
+		const checkRoleQuery = `
+			SELECT id FROM users_roles WHERE user_id = $1 AND role_id = $2
+		`
+		const existingRole = await eggheadPgQuery(checkRoleQuery, [userId, INSTRUCTOR_ROLE_ID])
+		
+		if (existingRole.rows.length > 0) {
+			console.log(`User ${userId} already has instructor role`)
+			return
+		}
+		
		const instructorRoleQuery = `
    INSERT INTO users_roles (user_id, role_id)
    VALUES ($1, $2)
  `

		await eggheadPgQuery(instructorRoleQuery, [userId, INSTRUCTOR_ROLE_ID])
+	} catch (error) {
+		console.error(`Failed to add instructor role to user ${userId}:`, error)
+		throw new Error(`Failed to add instructor role: ${(error as Error).message}`)
+	}
}

99-124: Add error handling to revenue split creation

Similar to other functions, this function lacks error handling for database operations.

export async function addRevenueSplitToEggheadInstructor({
	eggheadInstructorId,
}: {
	eggheadInstructorId: string
}) {
+	try {
		const revenueSplitQuery = `
    INSERT INTO instructor_revenue_splits (
      instructor_id,
      credit_to_instructor_id,
      percentage,
      from_date
    ) VALUES (
      $1,
      $2,
      $3,
      $4
    );
  `

		await eggheadPgQuery(revenueSplitQuery, [
			eggheadInstructorId,
			null,
			0.2,
			new Date(),
		])
+	} catch (error) {
+		console.error(`Failed to add revenue split for instructor ${eggheadInstructorId}:`, error)
+		throw new Error(`Failed to add revenue split: ${(error as Error).message}`)
+	}
}

118-123: Consider making the revenue split percentage configurable

The revenue split percentage is hardcoded to 0.2 (20%). Consider making this configurable or at least defining it as a constant at the top of the file.

+ // Define constants at the top of the file
+ const DEFAULT_INSTRUCTOR_REVENUE_PERCENTAGE = 0.2 // 20%

// ...

export async function addRevenueSplitToEggheadInstructor({
	eggheadInstructorId,
+	percentage = DEFAULT_INSTRUCTOR_REVENUE_PERCENTAGE,
}: {
	eggheadInstructorId: string
+	percentage?: number
}) {
	// ...
	
	await eggheadPgQuery(revenueSplitQuery, [
		eggheadInstructorId,
		null,
-		0.2,
+		percentage,
		new Date(),
	])

36-54: Consider input validation for required fields

There's no validation for the input parameters, which could lead to unexpected behavior if some fields are empty or in incorrect formats.

export async function createEggheadInstructor({
	userId,
	email,
	firstName,
	lastName,
	twitter,
	website,
	bio,
	profileImageUrl,
}: {
	userId: string
	firstName: string
	lastName: string
	email: string
	twitter: string
	website: string
	bio: string
	profileImageUrl: string
}) {
+	// Basic validation for required fields
+	if (!userId || !email || !firstName || !lastName) {
+		throw new Error('Missing required fields: userId, email, firstName, and lastName are required')
+	}
+	
+	// Validate email format
+	const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+	if (!emailRegex.test(email)) {
+		throw new Error('Invalid email format')
+	}

	// add instructor to egghead user
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c776d7d and dd1096f.

📒 Files selected for processing (1)
  • apps/egghead/src/lib/egghead/instructor.ts (1 hunks)
🔇 Additional comments (2)
apps/egghead/src/lib/egghead/instructor.ts (2)

69-73: Improve slug generation to avoid collisions

The current slug generation is too simple and could easily lead to collisions for instructors with the same name.

+ import { nanoid } from 'nanoid'; // Or another UUID library
+ // Or implement a more robust slug generation function
+ function generateUniqueSlug(firstName: string, lastName: string): string {
+   const base = `${firstName.toLowerCase()}-${lastName.toLowerCase()}`;
+   const uniqueId = nanoid(6); // Generate a short unique ID
+   return `${base}-${uniqueId}`;
+ }

const values = [
	firstName,
	lastName,
-	`${firstName}-${lastName}`,
+	generateUniqueSlug(firstName, lastName),
	userId,

82-93: Add error handling for database operations

The database query doesn't have any error handling, which could lead to uncaught exceptions.

	const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ')

	const query = `
    INSERT INTO instructors (${columns.join(', ')})
    VALUES (${placeholders})
    RETURNING id
  `

+	try {
		const eggheadInstructorResult = await eggheadPgQuery(query, values)
+		
+		if (!eggheadInstructorResult.rows || eggheadInstructorResult.rows.length === 0) {
+			throw new Error('Failed to create instructor: No ID returned')
+		}
		
		return eggheadInstructorResult.rows[0].id
+	} catch (error) {
+		console.error('Error creating instructor in database:', error)
+		throw new Error(`Failed to create instructor: ${(error as Error).message}`)
+	}

Comment on lines +1 to +4
'use server'

import { eggheadPgQuery } from '@/db/eggheadPostgres'

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 using a database transaction for related operations

The instructor creation process involves multiple operations (creating instructor, adding role, adding revenue split) that should ideally be handled within a transaction to ensure data consistency.

Consider creating a higher-level function that uses transactions to handle the entire instructor creation process:

export async function createInstructorWithRoleAndRevenueSplit(params) {
  // Start a transaction
  const client = await eggheadPgPool.connect()
  try {
    await client.query('BEGIN')
    
    // Create instructor
    const instructorId = await createEggheadInstructor(params, client)
    
    // Add role
    await addInstructorRoleToEggheadUser({userId: params.userId}, client)
    
    // Add revenue split
    await addRevenueSplitToEggheadInstructor({eggheadInstructorId: instructorId}, client)
    
    await client.query('COMMIT')
    return instructorId
  } catch (error) {
    await client.query('ROLLBACK')
    throw error
  } finally {
    client.release()
  }
}

This would require modifying the existing functions to accept an optional client parameter for transaction support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants