From 68080de39b067a2d29381016bb7ba6fa792583f0 Mon Sep 17 00:00:00 2001 From: Sam Rizvi Date: Fri, 10 Nov 2023 03:01:04 -0800 Subject: [PATCH] chore: migrate lib and api files - making em work is another thing Closes #38 and closes #35 --- .../attachments/[attachmentId]/route.ts | 40 +++++ .../courses/[courseId]/attachments/route.ts | 42 +++++ .../chapters/[chapterId]/progress/route.ts | 40 +++++ .../chapters/[chapterId]/publish/route.ts | 66 +++++++ .../[courseId]/chapters/[chapterId]/route.ts | 161 ++++++++++++++++++ .../chapters/[chapterId]/started/route.ts | 40 +++++ .../chapters/[chapterId]/unpublish/route.ts | 61 +++++++ .../[courseId]/chapters/reorder/route.ts | 42 +++++ app/api/courses/[courseId]/chapters/route.ts | 53 ++++++ app/api/courses/[courseId]/checkout/route.ts | 97 +++++++++++ app/api/courses/[courseId]/publish/route.ts | 64 +++++++ app/api/courses/[courseId]/route.ts | 87 ++++++++++ app/api/courses/[courseId]/unpublish/route.ts | 42 +++++ app/api/courses/route.ts | 29 ++++ app/api/uploadthing/core.ts | 29 ++++ app/api/uploadthing/route.ts | 8 + app/lib/actions/check-subscription.ts | 42 +++++ app/lib/actions/get-analytics.ts | 57 +++++++ app/lib/actions/get-chapter.ts | 111 ++++++++++++ app/lib/actions/get-courses.ts | 90 ++++++++++ app/lib/actions/get-dashboard-courses.ts | 87 ++++++++++ app/lib/actions/get-progress.ts | 54 ++++++ app/lib/api-limit.ts | 75 ++++++++ app/lib/auth.ts | 3 +- app/lib/course-pricing.ts | 17 ++ app/lib/format.ts | 8 + app/lib/types/index.d.ts | 95 ++++++++++- app/lib/types/next-auth.d.ts | 2 + app/lib/uploadthing.ts | 6 + app/lib/utils.ts | 2 + constants.ts | 3 + 31 files changed, 1547 insertions(+), 6 deletions(-) create mode 100644 app/api/courses/[courseId]/attachments/[attachmentId]/route.ts create mode 100644 app/api/courses/[courseId]/attachments/route.ts create mode 100644 app/api/courses/[courseId]/chapters/[chapterId]/progress/route.ts create mode 100644 app/api/courses/[courseId]/chapters/[chapterId]/publish/route.ts create mode 100644 app/api/courses/[courseId]/chapters/[chapterId]/route.ts create mode 100644 app/api/courses/[courseId]/chapters/[chapterId]/started/route.ts create mode 100644 app/api/courses/[courseId]/chapters/[chapterId]/unpublish/route.ts create mode 100644 app/api/courses/[courseId]/chapters/reorder/route.ts create mode 100644 app/api/courses/[courseId]/chapters/route.ts create mode 100644 app/api/courses/[courseId]/checkout/route.ts create mode 100644 app/api/courses/[courseId]/publish/route.ts create mode 100644 app/api/courses/[courseId]/route.ts create mode 100644 app/api/courses/[courseId]/unpublish/route.ts create mode 100644 app/api/courses/route.ts create mode 100644 app/api/uploadthing/core.ts create mode 100644 app/api/uploadthing/route.ts create mode 100644 app/lib/actions/check-subscription.ts create mode 100644 app/lib/actions/get-analytics.ts create mode 100644 app/lib/actions/get-chapter.ts create mode 100644 app/lib/actions/get-courses.ts create mode 100644 app/lib/actions/get-dashboard-courses.ts create mode 100644 app/lib/actions/get-progress.ts create mode 100644 app/lib/api-limit.ts create mode 100644 app/lib/course-pricing.ts create mode 100644 app/lib/format.ts create mode 100644 app/lib/uploadthing.ts create mode 100644 constants.ts diff --git a/app/api/courses/[courseId]/attachments/[attachmentId]/route.ts b/app/api/courses/[courseId]/attachments/[attachmentId]/route.ts new file mode 100644 index 00000000..a12c702c --- /dev/null +++ b/app/api/courses/[courseId]/attachments/[attachmentId]/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function DELETE( + req: Request, + { params }: { params: { courseId: string; attachmentId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const attachment = await db.attachment.delete({ + where: { + courseId: params.courseId, + id: params.attachmentId, + }, + }); + + return NextResponse.json(attachment); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/attachments/route.ts b/app/api/courses/[courseId]/attachments/route.ts new file mode 100644 index 00000000..5461bb60 --- /dev/null +++ b/app/api/courses/[courseId]/attachments/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function POST( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + const { url } = await req.json(); + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const attachment = await db.attachment.create({ + data: { + url, + name: url.split('/').pop(), + courseId: params.courseId, + }, + }); + + return NextResponse.json(attachment); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/chapters/[chapterId]/progress/route.ts b/app/api/courses/[courseId]/chapters/[chapterId]/progress/route.ts new file mode 100644 index 00000000..c39cbea6 --- /dev/null +++ b/app/api/courses/[courseId]/chapters/[chapterId]/progress/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; + +export async function PUT( + req: Request, + { params }: { params: { courseId: string; chapterId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + const { isCompleted } = await req.json(); + + if (!userId) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const userProgress = await db.userProgress.upsert({ + where: { + userId_chapterId: { + userId, + chapterId: params.chapterId, + }, + }, + update: { + isCompleted, + }, + create: { + userId, + chapterId: params.chapterId, + isCompleted, + }, + }); + + return NextResponse.json(userProgress); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/chapters/[chapterId]/publish/route.ts b/app/api/courses/[courseId]/chapters/[chapterId]/publish/route.ts new file mode 100644 index 00000000..6054694b --- /dev/null +++ b/app/api/courses/[courseId]/chapters/[chapterId]/publish/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function PATCH( + req: Request, + { params }: { params: { courseId: string; chapterId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const chapter = await db.chapter.findUnique({ + where: { + id: params.chapterId, + courseId: params.courseId, + }, + }); + + const muxData = await db.muxData.findUnique({ + where: { + chapterId: params.chapterId, + }, + }); + + if ( + !chapter || + !muxData || + !chapter.title || + !chapter.description || + !chapter.videoUrl + ) { + return new NextResponse('Missing required fields', { status: 400 }); + } + + const publishedChapter = await db.chapter.update({ + where: { + id: params.chapterId, + courseId: params.courseId, + }, + data: { + isPublished: true, + }, + }); + + return NextResponse.json(publishedChapter); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/chapters/[chapterId]/route.ts b/app/api/courses/[courseId]/chapters/[chapterId]/route.ts new file mode 100644 index 00000000..100bebb4 --- /dev/null +++ b/app/api/courses/[courseId]/chapters/[chapterId]/route.ts @@ -0,0 +1,161 @@ +import { NextResponse } from 'next/server'; +import Mux from '@mux/mux-node'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +const { Video } = new Mux( + process.env.MUX_TOKEN_ID!, + process.env.MUX_TOKEN_SECRET!, +); + +export async function DELETE( + req: Request, + { params }: { params: { courseId: string; chapterId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const chapter = await db.chapter.findUnique({ + where: { + id: params.chapterId, + courseId: params.courseId, + }, + }); + + if (!chapter) { + return new NextResponse('Not Found', { status: 404 }); + } + + if (chapter.videoUrl) { + const existingMuxData = await db.muxData.findFirst({ + where: { + chapterId: params.chapterId, + }, + }); + + if (existingMuxData) { + await Video.Assets.del(existingMuxData.assetId); + await db.muxData.delete({ + where: { + id: existingMuxData.id, + }, + }); + } + } + + const deletedChapter = await db.chapter.delete({ + where: { + id: params.chapterId, + }, + }); + + const publishedChaptersInCourse = await db.chapter.findMany({ + where: { + courseId: params.courseId, + isPublished: true, + }, + }); + + if (!publishedChaptersInCourse.length) { + await db.course.update({ + where: { + id: params.courseId, + }, + data: { + isPublished: false, + }, + }); + } + + return NextResponse.json(deletedChapter); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} + +export async function PATCH( + req: Request, + { params }: { params: { courseId: string; chapterId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + const { isPublished, ...values } = await req.json(); + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const chapter = await db.chapter.update({ + where: { + id: params.chapterId, + courseId: params.courseId, + }, + data: { + ...values, + }, + }); + + if (values.videoUrl) { + const existingMuxData = await db.muxData.findFirst({ + where: { + chapterId: params.chapterId, + }, + }); + + if (existingMuxData) { + await Video.Assets.del(existingMuxData.assetId); + await db.muxData.delete({ + where: { + id: existingMuxData.id, + }, + }); + } + + const asset = await Video.Assets.create({ + input: values.videoUrl, + playback_policy: 'public', + test: false, + }); + + await db.muxData.create({ + data: { + chapterId: params.chapterId, + assetId: asset.id, + playbackId: asset.playback_ids?.[0]?.id, + }, + }); + } + + return NextResponse.json(chapter); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/chapters/[chapterId]/started/route.ts b/app/api/courses/[courseId]/chapters/[chapterId]/started/route.ts new file mode 100644 index 00000000..0fb0e67c --- /dev/null +++ b/app/api/courses/[courseId]/chapters/[chapterId]/started/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; + +export async function PUT( + req: Request, + { params }: { params: { courseId: string; chapterId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + const { isStarted } = await req.json(); + + if (!userId) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const userProgress = await db.userProgress.upsert({ + where: { + userId_chapterId: { + userId, + chapterId: params.chapterId, + }, + }, + update: { + isStarted, + }, + create: { + userId, + chapterId: params.chapterId, + isStarted, + }, + }); + + return NextResponse.json(userProgress); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/chapters/[chapterId]/unpublish/route.ts b/app/api/courses/[courseId]/chapters/[chapterId]/unpublish/route.ts new file mode 100644 index 00000000..e9d1cd58 --- /dev/null +++ b/app/api/courses/[courseId]/chapters/[chapterId]/unpublish/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function PATCH( + req: Request, + { params }: { params: { courseId: string; chapterId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const unpublishedChapter = await db.chapter.update({ + where: { + id: params.chapterId, + courseId: params.courseId, + }, + data: { + isPublished: false, + }, + }); + + const publishedChaptersInCourse = await db.chapter.findMany({ + where: { + courseId: params.courseId, + isPublished: true, + }, + }); + + if (!publishedChaptersInCourse.length) { + await db.course.update({ + where: { + id: params.courseId, + }, + data: { + isPublished: false, + }, + }); + } + + return NextResponse.json(unpublishedChapter); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/chapters/reorder/route.ts b/app/api/courses/[courseId]/chapters/reorder/route.ts new file mode 100644 index 00000000..6a61b79a --- /dev/null +++ b/app/api/courses/[courseId]/chapters/reorder/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function PUT( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const { list } = await req.json(); + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + for (const item of list) { + await db.chapter.update({ + where: { id: item.id }, + data: { position: item.position }, + }); + } + + return new NextResponse('Success', { status: 200 }); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/chapters/route.ts b/app/api/courses/[courseId]/chapters/route.ts new file mode 100644 index 00000000..8339df33 --- /dev/null +++ b/app/api/courses/[courseId]/chapters/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function POST( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + const { title } = await req.json(); + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const lastChapter = await db.chapter.findFirst({ + where: { + courseId: params.courseId, + }, + orderBy: { + position: 'desc', + }, + }); + + const newPosition = lastChapter ? lastChapter.position + 1 : 1; + + const chapter = await db.chapter.create({ + data: { + title, + courseId: params.courseId, + position: newPosition, + }, + }); + + return NextResponse.json(chapter); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/checkout/route.ts b/app/api/courses/[courseId]/checkout/route.ts new file mode 100644 index 00000000..579ce45f --- /dev/null +++ b/app/api/courses/[courseId]/checkout/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; +import Stripe from 'stripe'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { stripe } from '@/app/lib/stripe'; + +export async function POST( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!user || !userId || !user.email) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + isPublished: true, + }, + }); + + const purchase = await db.purchase.findUnique({ + where: { + userId_courseId: { + userId: user.id, + courseId: params.courseId, + }, + }, + }); + + if (purchase) { + return new NextResponse('Already purchased', { status: 400 }); + } + + if (!course) { + return new NextResponse('Not found', { status: 404 }); + } + + // eslint-disable-next-line camelcase + const line_items: Stripe.Checkout.SessionCreateParams.LineItem[] = [ + { + quantity: 1, + price_data: { + currency: 'USD', + product_data: { + name: course.title, + description: course.description!, + }, + unit_amount: Math.round(course.price! * 100), + }, + }, + ]; + + let stripeCustomer = await db.stripeCustomer.findUnique({ + where: { + userId: user.id, + }, + select: { + stripeCustomerId: true, + }, + }); + + if (!stripeCustomer) { + const customer = await stripe.customers.create({ + email: user.email, + }); + + stripeCustomer = await db.stripeCustomer.create({ + data: { + userId: user.id, + stripeCustomerId: customer.id, + }, + }); + } + + const session = await stripe.checkout.sessions.create({ + customer: stripeCustomer.stripeCustomerId, + line_items, + mode: 'payment', + success_url: `${process.env.NEXT_PUBLIC_APP_URL}/courses/${course.id}?success=1`, + cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/courses/${course.id}?canceled=1`, + metadata: { + courseId: course.id, + userId: user.id, + }, + }); + + return NextResponse.json({ url: session.url }); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/publish/route.ts b/app/api/courses/[courseId]/publish/route.ts new file mode 100644 index 00000000..ccfaea3e --- /dev/null +++ b/app/api/courses/[courseId]/publish/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function PATCH( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + include: { + chapters: { + include: { + muxData: true, + }, + }, + }, + }); + + if (!course) { + return new NextResponse('Not found', { status: 404 }); + } + + const hasPublishedChapter = course.chapters.some( + (chapter) => chapter.isPublished, + ); + + if ( + !course.title || + !course.preview || + !course.description || + !course.imageUrl || + !course.categoryId || + !hasPublishedChapter + ) { + return new NextResponse('Missing required fields', { status: 401 }); + } + + const publishedCourse = await db.course.update({ + where: { + id: params.courseId, + }, + data: { + isPublished: true, + }, + }); + + return NextResponse.json(publishedCourse); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/route.ts b/app/api/courses/[courseId]/route.ts new file mode 100644 index 00000000..1a3709d5 --- /dev/null +++ b/app/api/courses/[courseId]/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import Mux from '@mux/mux-node'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +const { Video } = new Mux( + process.env.MUX_TOKEN_ID!, + process.env.MUX_TOKEN_SECRET!, +); + +export async function DELETE( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + include: { + chapters: { + include: { + muxData: true, + }, + }, + }, + }); + + if (!course) { + return new NextResponse('Not found', { status: 404 }); + } + + for (const chapter of course.chapters) { + if (chapter.muxData?.assetId) { + await Video.Assets.del(chapter.muxData.assetId); + } + } + + const deletedCourse = await db.course.delete({ + where: { + id: params.courseId, + }, + }); + + return NextResponse.json(deletedCourse); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} + +export async function PATCH( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + const { courseId } = params; + const values = await req.json(); + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.update({ + where: { + id: courseId, + }, + data: { + ...values, + }, + }); + + return NextResponse.json(course); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/[courseId]/unpublish/route.ts b/app/api/courses/[courseId]/unpublish/route.ts new file mode 100644 index 00000000..76bb9cbf --- /dev/null +++ b/app/api/courses/[courseId]/unpublish/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function PATCH( + req: Request, + { params }: { params: { courseId: string } }, +) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.findUnique({ + where: { + id: params.courseId, + }, + }); + + if (!course) { + return new NextResponse('Not found', { status: 404 }); + } + + const unpublishedCourse = await db.course.update({ + where: { + id: params.courseId, + }, + data: { + isPublished: false, + }, + }); + + return NextResponse.json(unpublishedCourse); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/courses/route.ts b/app/api/courses/route.ts new file mode 100644 index 00000000..0d1f03e1 --- /dev/null +++ b/app/api/courses/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +export async function POST(req: Request) { + try { + const user = await getCurrentUser(); + const userId = user?.id; + const { title, price } = await req.json(); + + if (!userId || !isTeacher(userId)) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + const course = await db.course.create({ + data: { + userId, + title, + price, + }, + }); + + return NextResponse.json(course); + } catch (error) { + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts new file mode 100644 index 00000000..158182b4 --- /dev/null +++ b/app/api/uploadthing/core.ts @@ -0,0 +1,29 @@ +import { createUploadthing, type FileRouter } from 'uploadthing/next'; + +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +const f = createUploadthing(); + +const handleAuth = async () => { + const user = await getCurrentUser(); + const userId = user?.id; + const isAuthorized = isTeacher(userId); + + if (!userId || !isAuthorized) throw new Error('Unauthorized'); + return { userId }; +}; + +export const ourFileRouter = { + courseImage: f({ image: { maxFileSize: '2MB', maxFileCount: 1 } }) + .middleware(() => handleAuth()) + .onUploadComplete(() => {}), + courseAttachment: f(['text', 'image', 'video', 'audio', 'pdf']) + .middleware(() => handleAuth()) + .onUploadComplete(() => {}), + chapterVideo: f({ video: { maxFileCount: 1, maxFileSize: '512GB' } }) + .middleware(() => handleAuth()) + .onUploadComplete(() => {}), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; diff --git a/app/api/uploadthing/route.ts b/app/api/uploadthing/route.ts new file mode 100644 index 00000000..56cb5164 --- /dev/null +++ b/app/api/uploadthing/route.ts @@ -0,0 +1,8 @@ +import { createNextRouteHandler } from 'uploadthing/next'; + +import { ourFileRouter } from './core'; + +// Export routes for Next App Router +export const { GET, POST } = createNextRouteHandler({ + router: ourFileRouter, +}); diff --git a/app/lib/actions/check-subscription.ts b/app/lib/actions/check-subscription.ts new file mode 100644 index 00000000..b09aee93 --- /dev/null +++ b/app/lib/actions/check-subscription.ts @@ -0,0 +1,42 @@ +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; +import { isTeacher } from '@/app/lib/teacher'; + +const DAY_IN_MS = 86_400_000; + +export const checkSubscription = async (): Promise => { + const user = await getCurrentUser(); + const userId = user?.id; + + if (isTeacher(userId)) { + return true; + } + + if (!userId) { + return false; + } + + const userSubscription = await db.userSubscription.findUnique({ + where: { + userId: userId, + }, + select: { + stripeSubscriptionId: true, + stripeCurrentPeriodEnd: true, + stripeCustomerId: true, + stripePriceId: true, + }, + }); + + if (!userSubscription) { + return false; + } + + const isValid = + userSubscription.stripePriceId && + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + userSubscription.stripeCurrentPeriodEnd?.getTime()! + DAY_IN_MS > + Date.now(); + + return !!isValid; +}; diff --git a/app/lib/actions/get-analytics.ts b/app/lib/actions/get-analytics.ts new file mode 100644 index 00000000..615a43f6 --- /dev/null +++ b/app/lib/actions/get-analytics.ts @@ -0,0 +1,57 @@ +'use server'; + +import { Course, Purchase } from '@prisma/client'; + +import { db } from '@/app/lib/db'; + +type PurchaseWithCourse = Purchase & { + course: Course; +}; + +const groupByCourse = (purchases: PurchaseWithCourse[]) => { + const grouped: { [courseTitle: string]: number } = {}; + + purchases.forEach((purchase) => { + const courseTitle = purchase.course.title; + if (!grouped[courseTitle]) { + grouped[courseTitle] = 0; + } + grouped[courseTitle] += purchase.course.price!; + }); + + return grouped; +}; + +export const getAnalytics = async () => { + try { + const purchases = await db.purchase.findMany({ + where: {}, + include: { + course: true, + }, + }); + + const groupedEarnings = groupByCourse(purchases); + const data = Object.entries(groupedEarnings).map( + ([courseTitle, total]) => ({ + name: courseTitle, + total: total, + }), + ); + + const totalRevenue = data.reduce((acc, curr) => acc + curr.total, 0); + const totalSales = purchases.length; + + return { + data, + totalRevenue, + totalSales, + }; + } catch (error) { + return { + data: [], + totalRevenue: 0, + totalSales: 0, + }; + } +}; diff --git a/app/lib/actions/get-chapter.ts b/app/lib/actions/get-chapter.ts new file mode 100644 index 00000000..e1c9204f --- /dev/null +++ b/app/lib/actions/get-chapter.ts @@ -0,0 +1,111 @@ +import { Attachment, Chapter } from '@prisma/client'; + +import { checkSubscription } from '@/app/lib/actions/check-subscription'; +import { db } from '@/app/lib/db'; + +interface GetChapterProps { + userId: string; + courseId: string; + chapterId: string; +} + +export const getChapter = async ({ + userId, + courseId, + chapterId, +}: GetChapterProps) => { + try { + const purchase = await db.purchase.findUnique({ + where: { + userId_courseId: { + userId, + courseId, + }, + }, + }); + + const course = await db.course.findUnique({ + where: { + isPublished: true, + id: courseId, + }, + }); + + const chapter = await db.chapter.findUnique({ + where: { + id: chapterId, + isPublished: true, + }, + }); + + if (!chapter || !course) { + throw new Error('Play or playbook not found'); + } + + const isPaidMember = await checkSubscription(); + + let muxData = null; + let attachments: Attachment[] = []; + let nextChapter: Chapter | null = null; + + if (course.price === 0 || purchase || isPaidMember) { + attachments = await db.attachment.findMany({ + where: { + courseId: courseId, + }, + }); + } + + if (course.price === 0 || purchase || isPaidMember) { + muxData = await db.muxData.findUnique({ + where: { + chapterId: chapterId, + }, + }); + + nextChapter = await db.chapter.findFirst({ + where: { + courseId: courseId, + isPublished: true, + position: { + gt: chapter?.position, + }, + }, + orderBy: { + position: 'asc', + }, + }); + } + + const userProgress = await db.userProgress.findUnique({ + where: { + userId_chapterId: { + userId, + chapterId, + }, + }, + }); + + return { + chapter, + course, + muxData, + attachments, + nextChapter, + userProgress, + purchase, + isPaidMember, + }; + } catch (error) { + return { + chapter: null, + course: null, + muxData: null, + attachments: [], + nextChapter: null, + userProgress: null, + purchase: null, + isPaidMember: false, + }; + } +}; diff --git a/app/lib/actions/get-courses.ts b/app/lib/actions/get-courses.ts new file mode 100644 index 00000000..b80b8901 --- /dev/null +++ b/app/lib/actions/get-courses.ts @@ -0,0 +1,90 @@ +import { Category, Course } from '@prisma/client'; + +import { checkSubscription } from '@/app/lib/actions/check-subscription'; +import { getProgress } from '@/app/lib/actions/get-progress'; +import { db } from '@/app/lib/db'; + +type CourseWithProgressWithCategory = Course & { + category: Category | null; + chapters: { id: string }[]; + progress: number | null; + price: number; + isPaidMember: boolean; + purchased: boolean; +}; + +type GetCourses = { + userId: string; + title?: string; + categoryId?: string; + isPaidMember: boolean; +}; + +export const getCourses = async ({ + userId, + title, + categoryId, +}: GetCourses): Promise => { + try { + const courses = await db.course.findMany({ + where: { + isPublished: true, + title: { + contains: title, + }, + categoryId, + }, + include: { + category: true, + chapters: { + where: { + isPublished: true, + }, + select: { + id: true, + }, + }, + purchases: { + where: { + userId, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + const isPaidMember = await checkSubscription(); + + const coursesWithProgress: CourseWithProgressWithCategory[] = + await Promise.all( + courses.map(async (course) => { + const purchased = course.purchases.length > 0; + if (isPaidMember || course.price === 0 || purchased) { + const progressPercentage = await getProgress(userId, course.id); + + return { + ...course, + progress: progressPercentage, + isPaidMember, + purchased, + price: course.price || 0, + }; + } + + return { + ...course, + progress: null, + isPaidMember, + purchased, + price: course.price || 0, + }; + }), + ); + + return coursesWithProgress; + } catch (error) { + return []; + } +}; diff --git a/app/lib/actions/get-dashboard-courses.ts b/app/lib/actions/get-dashboard-courses.ts new file mode 100644 index 00000000..d3b56f91 --- /dev/null +++ b/app/lib/actions/get-dashboard-courses.ts @@ -0,0 +1,87 @@ +import { Category, Chapter, Course } from '@prisma/client'; + +import { checkSubscription } from '@/app/lib/actions/check-subscription'; +import { getProgress } from '@/app/lib/actions/get-progress'; +import { db } from '@/app/lib/db'; + +type DashboardCourseWithProgressWithCategory = Course & { + category: Category; + chapters: Chapter[]; + progress: number | null; + isPaidMember: boolean; + purchased: boolean; +}; + +type DashboardCourses = { + completedCourses: DashboardCourseWithProgressWithCategory[]; + coursesInProgress: DashboardCourseWithProgressWithCategory[]; +}; + +export const getDashboardCourses = async ( + userId: string, +): Promise => { + try { + const userProgress = await db.userProgress.findMany({ + where: { userId }, + select: { chapterId: true, isCompleted: true, isStarted: true }, + }); + + const chapterIdsWithProgress = userProgress.map( + (progress) => progress.chapterId, + ); + + const courses = await db.course.findMany({ + where: { + chapters: { + some: { + id: { + in: chapterIdsWithProgress, + }, + }, + }, + }, + include: { + category: true, + chapters: { + where: { + isPublished: true, + }, + }, + purchases: true, + }, + }); + + const coursesWithProgress = await Promise.all( + courses.map(async (course) => { + const progress = await getProgress(userId, course.id); + const purchased = course.purchases.some( + (purchase) => purchase.userId === userId, + ); + const isPaidMember = await checkSubscription(); + return { + ...course, + progress, + purchased, + isPaidMember, + } as DashboardCourseWithProgressWithCategory; + }), + ); + + const completedCourses = coursesWithProgress.filter( + (course) => course.progress === 100, + ); + const coursesInProgress = coursesWithProgress.filter( + (course) => (course.progress ?? 0) < 100, + ); + + return { + completedCourses, + coursesInProgress, + }; + } catch (error) { + return { + completedCourses: [], + coursesInProgress: [], + }; + } +}; diff --git a/app/lib/actions/get-progress.ts b/app/lib/actions/get-progress.ts new file mode 100644 index 00000000..dea5074a --- /dev/null +++ b/app/lib/actions/get-progress.ts @@ -0,0 +1,54 @@ +import { db } from '@/app/lib/db'; + +export const getProgress = async ( + userId: string, + courseId: string, +): Promise => { + try { + const publishedChapters = await db.chapter.findMany({ + where: { + courseId: courseId, + isPublished: true, + }, + select: { + id: true, + }, + }); + + const publishedChapterIds = publishedChapters.map((chapter) => chapter.id); + + const firstChapterProgress = await db.userProgress.findFirst({ + where: { + userId: userId, + chapterId: publishedChapterIds[0], + }, + }); + + const isStarted = firstChapterProgress?.isStarted || false; + + const validCompletedChapters = await db.userProgress.count({ + where: { + userId: userId, + chapterId: { + in: publishedChapterIds, + }, + isCompleted: true, + }, + }); + + let progressPercentage = 0; + + if (isStarted && validCompletedChapters === 0) { + progressPercentage = 25; + } else if (isStarted && validCompletedChapters > 0) { + progressPercentage = + (validCompletedChapters / publishedChapterIds.length) * 100; + } else if (!isStarted) { + progressPercentage = 0; + } + + return progressPercentage; + } catch (error) { + return 0; + } +}; diff --git a/app/lib/api-limit.ts b/app/lib/api-limit.ts new file mode 100644 index 00000000..6c8c1dff --- /dev/null +++ b/app/lib/api-limit.ts @@ -0,0 +1,75 @@ +import { MAX_FREE_TOKENS } from '@/constants'; + +import { db } from '@/app/lib/db'; +import { getCurrentUser } from '@/app/lib/session'; + +export const increaseApiLimit = async () => { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!userId) { + return; + } + + const userApiLimit = await db.userApiLimit.findUnique({ + where: { + userId, + }, + }); + + if (userApiLimit) { + await db.userApiLimit.update({ + where: { userId: userId }, + data: { count: userApiLimit.count + 1 }, + }); + } else { + await db.userApiLimit.create({ + data: { + userId: userId, + count: 1, + }, + }); + } +}; + +export const checkApiLimit = async () => { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!userId) { + return false; + } + + const userApiLimit = await db.userApiLimit.findUnique({ + where: { + userId: userId, + }, + }); + + if (!userApiLimit || userApiLimit.count < MAX_FREE_TOKENS) { + return true; + } else { + return false; + } +}; + +export const getApiLimitCount = async () => { + const user = await getCurrentUser(); + const userId = user?.id; + + if (!userId) { + return 0; + } + + const userApiLimit = await db.userApiLimit.findUnique({ + where: { + userId, + }, + }); + + if (!userApiLimit) { + return 0; + } + + return userApiLimit.count; +}; diff --git a/app/lib/auth.ts b/app/lib/auth.ts index da585a26..e1aa50db 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -7,8 +7,7 @@ import { env } from '@/env.mjs'; import { siteConfig } from '@/app/config/site'; import MagicLinkEmail from '@/app/emails/magic-link-email'; import { db } from '@/app/lib/db'; - -import { resend } from './email'; +import { resend } from '@/app/lib/email'; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db), diff --git a/app/lib/course-pricing.ts b/app/lib/course-pricing.ts new file mode 100644 index 00000000..6ce05efe --- /dev/null +++ b/app/lib/course-pricing.ts @@ -0,0 +1,17 @@ +import { formatPrice } from '@/app/lib/format'; + +export const getCoursePrice = ( + price: number, + isPaidMember: boolean, + purchased: boolean, +): string => { + if (price === 0) { + return 'Free'; + } else if (isPaidMember) { + return 'Included'; + } else if (purchased) { + return 'Purchased'; + } else { + return formatPrice(price); + } +}; diff --git a/app/lib/format.ts b/app/lib/format.ts new file mode 100644 index 00000000..3d353e16 --- /dev/null +++ b/app/lib/format.ts @@ -0,0 +1,8 @@ +export const formatPrice = (price: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price); +}; diff --git a/app/lib/types/index.d.ts b/app/lib/types/index.d.ts index c644a3c4..5017528b 100644 --- a/app/lib/types/index.d.ts +++ b/app/lib/types/index.d.ts @@ -1,6 +1,25 @@ import { Icons } from '@/app/components/shared/icons'; +// :: Iconography Types :: + +export type SocialIcon = keyof typeof Icons; + +export type SocialItem = { + name: string; + href: string; + icon: SocialIcon; +}; + +export type SocialConfig = { + social: SocialItem[]; +}; + +export type IconKey = keyof typeof Icons; + +// :: Navigation Types :: + export type NavItem = { + id?: string; title: string; href: string; disabled?: boolean; @@ -12,6 +31,7 @@ export type NavItem = { export type MainNavItem = NavItem; export type SidebarNavItem = { + id?: string; title: string; disabled?: boolean; external?: boolean; @@ -27,6 +47,65 @@ export type SidebarNavItem = { } ); +export interface SidebarItemProps { + icon: IconName; + label: string; + href: string; +} + +export interface SidebarProps { + apiLimitCount: number; + isPaidMember: boolean; +} + +export interface MobileSidebarProps { + apiLimitCount: number; + isPaidMember: boolean; +} + +export interface CourseSidebarItemProps { + label: string; + id: string; + isCompleted: boolean; + courseId: string; + isLocked: boolean; +} + +export interface CourseSidebarProps { + course: Course & { + chapters: (Chapter & { + userProgress: UserProgress[] | null; + })[]; + }; + progressCount: number; + apiLimitCount: number; + isPaidMember: boolean; +} + +export interface CourseMobileSidebarProps { + course: Course & { + chapters: (Chapter & { + userProgress: UserProgress[] | null; + })[]; + }; + progressCount: number; + isPaidMember: boolean; + apiLimitCount: number; +} + +export interface CourseNavbarProps { + course: Course & { + chapters: (Chapter & { + userProgress: UserProgress[] | null; + })[]; + }; + progressCount: number; + apiLimitCount: number; + isPaidMember: boolean; +} + +// :: Config Types :: + export type SiteConfig = { company: string; name: string; @@ -40,20 +119,26 @@ export type SiteConfig = { }; }; -export type DocsConfig = { +export type DashboardConfig = { mainNav: MainNavItem[]; sidebarNav: SidebarNavItem[]; }; -export type MarketingConfig = { +export type DocsConfig = { mainNav: MainNavItem[]; + sidebarNav: SidebarNavItem[]; }; -export type DashboardConfig = { +export type FooterConfig = { + footerNav: MainNavItem[]; +}; + +export type MarketingConfig = { mainNav: MainNavItem[]; - sidebarNav: SidebarNavItem[]; }; +// :: Stripe and Subscription Types :: + export type SubscriptionPlan = { title: string; description: string; @@ -79,6 +164,8 @@ export type UserSubscriptionPlan = SubscriptionPlan & { isCanceled?: boolean; }; +// :: Miscellaneous Types :: + export type MarketingBenefitsProps = { id: string; title: string; diff --git a/app/lib/types/next-auth.d.ts b/app/lib/types/next-auth.d.ts index f71983c1..89e6aadf 100644 --- a/app/lib/types/next-auth.d.ts +++ b/app/lib/types/next-auth.d.ts @@ -1,3 +1,5 @@ +// :: Authentication Module Types :: + import { User } from 'next-auth'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { JWT } from 'next-auth/jwt'; diff --git a/app/lib/uploadthing.ts b/app/lib/uploadthing.ts new file mode 100644 index 00000000..145941fe --- /dev/null +++ b/app/lib/uploadthing.ts @@ -0,0 +1,6 @@ +import { generateComponents } from '@uploadthing/react'; + +import type { OurFileRouter } from '@/app/api/uploadthing/core'; + +export const { UploadButton, UploadDropzone, Uploader } = + generateComponents(); diff --git a/app/lib/utils.ts b/app/lib/utils.ts index d5c94028..f1ababe0 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -4,6 +4,8 @@ import { twMerge } from 'tailwind-merge'; import { env } from '@/env.mjs'; +export const SITE_URL: string = 'https://labs.andvoila.gg'; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/constants.ts b/constants.ts new file mode 100644 index 00000000..dcb0281f --- /dev/null +++ b/constants.ts @@ -0,0 +1,3 @@ +export const MAX_FREE_TOKENS = 30; + +export const COURSE_DEFAULT_PRICE = 12;