From f0aeb4605cbdf78c362ab683c19b87d3f44ba57b Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 31 Oct 2024 18:48:53 +0100 Subject: [PATCH] feat: add ability to link courses to products --- apps/api/src/routers/ee/payments.py | 45 +++++- .../{payments.py => payments_config.py} | 0 .../src/services/payments/payments_courses.py | 124 +++++++++++++++ apps/api/src/services/payments/stripe.py | 2 +- .../Payments/PaymentsProductPage.tsx | 2 + .../SubComponents/LinkCourseModal.tsx | 143 ++++++++++++++++++ .../SubComponents/ProductLinkedCourses.tsx | 106 +++++++++++++ apps/web/services/payments/products.ts | 26 ++++ 8 files changed, 446 insertions(+), 2 deletions(-) rename apps/api/src/services/payments/{payments.py => payments_config.py} (100%) create mode 100644 apps/api/src/services/payments/payments_courses.py create mode 100644 apps/web/components/Dashboard/Payments/SubComponents/LinkCourseModal.tsx create mode 100644 apps/web/components/Dashboard/Payments/SubComponents/ProductLinkedCourses.tsx diff --git a/apps/api/src/routers/ee/payments.py b/apps/api/src/routers/ee/payments.py index 6cd8c3ce..526e4311 100644 --- a/apps/api/src/routers/ee/payments.py +++ b/apps/api/src/routers/ee/payments.py @@ -4,7 +4,7 @@ from src.core.events.database import get_db_session from src.db.payments.payments import PaymentsConfig, PaymentsConfigCreate, PaymentsConfigRead, PaymentsConfigUpdate from src.db.users import PublicUser from src.security.auth import get_current_user -from src.services.payments.payments import ( +from src.services.payments.payments_config import ( create_payments_config, get_payments_config, update_payments_config, @@ -12,6 +12,11 @@ from src.services.payments.payments import ( ) from src.db.payments.payments_products import PaymentsProductCreate, PaymentsProductRead, PaymentsProductUpdate from src.services.payments.payments_products import create_payments_product, delete_payments_product, get_payments_product, list_payments_products, update_payments_product +from src.services.payments.payments_courses import ( + link_course_to_product, + unlink_course_from_product, + get_courses_by_product +) router = APIRouter() @@ -105,3 +110,41 @@ async def api_delete_payments_product( ): await delete_payments_product(request, org_id, product_id, current_user, db_session) return {"message": "Payments product deleted successfully"} + +@router.post("/{org_id}/products/{product_id}/courses/{course_id}") +async def api_link_course_to_product( + request: Request, + org_id: int, + product_id: int, + course_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await link_course_to_product( + request, org_id, course_id, product_id, current_user, db_session + ) + +@router.delete("/{org_id}/products/{product_id}/courses/{course_id}") +async def api_unlink_course_from_product( + request: Request, + org_id: int, + product_id: int, + course_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await unlink_course_from_product( + request, org_id, course_id, current_user, db_session + ) + +@router.get("/{org_id}/products/{product_id}/courses") +async def api_get_courses_by_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await get_courses_by_product( + request, org_id, product_id, current_user, db_session + ) diff --git a/apps/api/src/services/payments/payments.py b/apps/api/src/services/payments/payments_config.py similarity index 100% rename from apps/api/src/services/payments/payments.py rename to apps/api/src/services/payments/payments_config.py diff --git a/apps/api/src/services/payments/payments_courses.py b/apps/api/src/services/payments/payments_courses.py new file mode 100644 index 00000000..e83aa10a --- /dev/null +++ b/apps/api/src/services/payments/payments_courses.py @@ -0,0 +1,124 @@ +from datetime import datetime +from fastapi import HTTPException, Request +from sqlmodel import Session, select +from src.db.payments.payments_courses import PaymentCourse +from src.db.payments.payments_products import PaymentsProduct +from src.db.courses.courses import Course +from src.db.users import PublicUser, AnonymousUser +from src.services.courses.courses import rbac_check + +async def link_course_to_product( + request: Request, + org_id: int, + course_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if course exists and user has permission + statement = select(Course).where(Course.id == course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Check if product exists + statement = select(PaymentsProduct).where( + PaymentsProduct.id == product_id, + PaymentsProduct.org_id == org_id + ) + product = db_session.exec(statement).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + # Check if course is already linked to another product + statement = select(PaymentCourse).where(PaymentCourse.course_id == course.id) + existing_link = db_session.exec(statement).first() + + if existing_link: + raise HTTPException( + status_code=400, + detail="Course is already linked to a product" + ) + + # Create new payment course link + payment_course = PaymentCourse( + course_id=course.id, # type: ignore + payment_product_id=product_id, + org_id=org_id, + ) + + db_session.add(payment_course) + db_session.commit() + + return {"message": "Course linked to product successfully"} + +async def unlink_course_from_product( + request: Request, + org_id: int, + course_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if course exists and user has permission + statement = select(Course).where(Course.id == course_id) + course = db_session.exec(statement).first() + + if not course: + raise HTTPException(status_code=404, detail="Course not found") + + # RBAC check + await rbac_check(request, course.course_uuid, current_user, "update", db_session) + + # Find and delete the payment course link + statement = select(PaymentCourse).where( + PaymentCourse.course_id == course.id, + PaymentCourse.org_id == org_id + ) + payment_course = db_session.exec(statement).first() + + if not payment_course: + raise HTTPException( + status_code=404, + detail="Course is not linked to any product" + ) + + db_session.delete(payment_course) + db_session.commit() + + return {"message": "Course unlinked from product successfully"} + +async def get_courses_by_product( + request: Request, + org_id: int, + product_id: int, + current_user: PublicUser | AnonymousUser, + db_session: Session, +): + # Check if product exists + statement = select(PaymentsProduct).where( + PaymentsProduct.id == product_id, + PaymentsProduct.org_id == org_id + ) + product = db_session.exec(statement).first() + + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + # Get all courses linked to this product with explicit join + statement = ( + select(Course) + .select_from(Course) + .join(PaymentCourse, Course.id == PaymentCourse.course_id) # type: ignore + .where( + PaymentCourse.payment_product_id == product_id, + PaymentCourse.org_id == org_id + ) + ) + courses = db_session.exec(statement).all() + + return courses diff --git a/apps/api/src/services/payments/stripe.py b/apps/api/src/services/payments/stripe.py index 052d2618..f351d0bc 100644 --- a/apps/api/src/services/payments/stripe.py +++ b/apps/api/src/services/payments/stripe.py @@ -3,7 +3,7 @@ from sqlmodel import Session import stripe from src.db.payments.payments_products import PaymentPriceTypeEnum, PaymentProductTypeEnum, PaymentsProduct from src.db.users import AnonymousUser, PublicUser -from src.services.payments.payments import get_payments_config +from src.services.payments.payments_config import get_payments_config async def get_stripe_credentials( diff --git a/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx b/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx index 667334d5..240fe73f 100644 --- a/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx +++ b/apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx @@ -19,6 +19,7 @@ import * as Yup from 'yup'; import { Label } from '@components/ui/label'; import { Badge } from '@components/ui/badge'; import { getPaymentConfigs } from '@services/payments/payments'; +import ProductLinkedCourses from './SubComponents/ProductLinkedCourses'; const validationSchema = Yup.object().shape({ name: Yup.string().required('Name is required'), @@ -162,6 +163,7 @@ function PaymentsProductPage() { )} +
Price: diff --git a/apps/web/components/Dashboard/Payments/SubComponents/LinkCourseModal.tsx b/apps/web/components/Dashboard/Payments/SubComponents/LinkCourseModal.tsx new file mode 100644 index 00000000..16c18d70 --- /dev/null +++ b/apps/web/components/Dashboard/Payments/SubComponents/LinkCourseModal.tsx @@ -0,0 +1,143 @@ +import React, { useState } from 'react'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { linkCourseToProduct } from '@services/payments/products'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Search } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { mutate } from 'swr'; +import useSWR from 'swr'; +import { getOrgCourses } from '@services/courses/courses'; +import { getCoursesLinkedToProduct } from '@services/payments/products'; +import Link from 'next/link'; +import { getCourseThumbnailMediaDirectory } from '@services/media/media'; +import { getUriWithOrg } from '@services/config/config'; + +interface LinkCourseModalProps { + productId: string; + onSuccess: () => void; +} + +interface CoursePreviewProps { + course: { + id: string; + name: string; + description: string; + thumbnail_image: string; + course_uuid: string; + }; + orgslug: string; + onLink: (courseId: string) => void; + isLinked: boolean; +} + +const CoursePreview = ({ course, orgslug, onLink, isLinked }: CoursePreviewProps) => { + const org = useOrg() as any; + + const thumbnailImage = course.thumbnail_image + ? getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image) + : '../empty_thumbnail.png'; + + return ( +
+ {/* Thumbnail */} +
+ + {/* Content */} +
+

+ {course.name} +

+

+ {course.description} +

+
+ + {/* Action Button */} +
+ {isLinked ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default function LinkCourseModal({ productId, onSuccess }: LinkCourseModalProps) { + const [searchTerm, setSearchTerm] = useState(''); + const org = useOrg() as any; + const session = useLHSession() as any; + + const { data: courses } = useSWR( + () => org && session ? [org.slug, searchTerm, session.data?.tokens?.access_token] : null, + ([orgSlug, search, token]) => getOrgCourses(orgSlug, null, token) + ); + + const { data: linkedCourses } = useSWR( + () => org && session ? [`/payments/${org.id}/products/${productId}/courses`, session.data?.tokens?.access_token] : null, + ([_, token]) => getCoursesLinkedToProduct(org.id, productId, token) + ); + + const handleLinkCourse = async (courseId: string) => { + try { + const response = await linkCourseToProduct(org.id, productId, courseId, session.data?.tokens?.access_token); + if (response.success) { + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + toast.success('Course linked successfully'); + onSuccess(); + } else { + toast.error(response.data?.detail || 'Failed to link course'); + } + } catch (error) { + toast.error('Failed to link course'); + } + }; + + const isLinked = (courseId: string) => { + return linkedCourses?.data?.some((course: any) => course.id === courseId); + }; + + return ( +
+ + + {/* Course List */} +
+ {courses?.map((course: any) => ( + + ))} + + {/* Empty State */} + {(!courses || courses.length === 0) && ( +
+ No courses found +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/Dashboard/Payments/SubComponents/ProductLinkedCourses.tsx b/apps/web/components/Dashboard/Payments/SubComponents/ProductLinkedCourses.tsx new file mode 100644 index 00000000..a1d2e7fc --- /dev/null +++ b/apps/web/components/Dashboard/Payments/SubComponents/ProductLinkedCourses.tsx @@ -0,0 +1,106 @@ +import React, { useEffect, useState } from 'react'; +import { getCoursesLinkedToProduct, unlinkCourseFromProduct } from '@services/payments/products'; +import { useLHSession } from '@components/Contexts/LHSessionContext'; +import { useOrg } from '@components/Contexts/OrgContext'; +import { Trash2, Plus, BookOpen } from 'lucide-react'; +import { Button } from "@/components/ui/button"; +import toast from 'react-hot-toast'; +import { mutate } from 'swr'; +import Modal from '@components/StyledElements/Modal/Modal'; +import LinkCourseModal from './LinkCourseModal'; + +interface ProductLinkedCoursesProps { + productId: string; +} + +export default function ProductLinkedCourses({ productId }: ProductLinkedCoursesProps) { + const [linkedCourses, setLinkedCourses] = useState([]); + const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); + const session = useLHSession() as any; + const org = useOrg() as any; + + const fetchLinkedCourses = async () => { + try { + const response = await getCoursesLinkedToProduct(org.id, productId, session.data?.tokens?.access_token); + setLinkedCourses(response.data || []); + } catch (error) { + toast.error('Failed to fetch linked courses'); + } + }; + + const handleUnlinkCourse = async (courseId: string) => { + try { + const response = await unlinkCourseFromProduct(org.id, productId, courseId, session.data?.tokens?.access_token); + if (response.success) { + await fetchLinkedCourses(); + mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]); + toast.success('Course unlinked successfully'); + } else { + toast.error(response.data?.detail || 'Failed to unlink course'); + } + } catch (error) { + toast.error('Failed to unlink course'); + } + }; + + useEffect(() => { + if (org && session && productId) { + fetchLinkedCourses(); + } + }, [org, session, productId]); + + return ( +
+
+

Linked Courses

+ { + setIsLinkModalOpen(false); + fetchLinkedCourses(); + }} + /> + } + dialogTrigger={ + + } + /> +
+ +
+ {linkedCourses.length === 0 ? ( +
+ + No courses linked yet +
+ ) : ( + linkedCourses.map((course) => ( +
+ {course.name} + +
+ )) + )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/services/payments/products.ts b/apps/web/services/payments/products.ts index 8d8d20c3..63f5b422 100644 --- a/apps/web/services/payments/products.ts +++ b/apps/web/services/payments/products.ts @@ -46,5 +46,31 @@ export async function getProductDetails(orgId: number, productId: string, access return res; } +export async function linkCourseToProduct(orgId: number, productId: string, courseId: string, access_token: string) { + const result = await fetch( + `${getAPIUrl()}payments/${orgId}/products/${productId}/courses/${courseId}`, + RequestBodyWithAuthHeader('POST', null, null, access_token) + ); + const res = await getResponseMetadata(result); + return res; +} + +export async function unlinkCourseFromProduct(orgId: number, productId: string, courseId: string, access_token: string) { + const result = await fetch( + `${getAPIUrl()}payments/${orgId}/products/${productId}/courses/${courseId}`, + RequestBodyWithAuthHeader('DELETE', null, null, access_token) + ); + const res = await getResponseMetadata(result); + return res; +} + +export async function getCoursesLinkedToProduct(orgId: number, productId: string, access_token: string) { + const result = await fetch( + `${getAPIUrl()}payments/${orgId}/products/${productId}/courses`, + RequestBodyWithAuthHeader('GET', null, null, access_token) + ); + const res = await getResponseMetadata(result); + return res; +}