feat: add ability to link courses to products

This commit is contained in:
swve 2024-10-31 18:48:53 +01:00
parent 3f96f1ec9f
commit f0aeb4605c
8 changed files with 446 additions and 2 deletions

View file

@ -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
)

View file

@ -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

View file

@ -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(

View file

@ -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() {
)}
</button>
</div>
<ProductLinkedCourses productId={product.id} />
<div className="mt-2 flex items-center justify-between bg-gray-100 rounded-md p-2">
<span className="text-sm text-gray-600">Price:</span>
<span className="font-semibold text-lg">

View file

@ -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 (
<div className="flex gap-4 p-4 bg-white rounded-lg border border-gray-100 hover:border-gray-200 transition-colors">
{/* Thumbnail */}
<div
className="flex-shrink-0 w-[120px] h-[68px] rounded-md bg-cover bg-center ring-1 ring-inset ring-black/10"
style={{ backgroundImage: `url(${thumbnailImage})` }}
/>
{/* Content */}
<div className="flex-grow space-y-1">
<h3 className="font-medium text-gray-900 line-clamp-1">
{course.name}
</h3>
<p className="text-sm text-gray-500 line-clamp-2">
{course.description}
</p>
</div>
{/* Action Button */}
<div className="flex-shrink-0 flex items-center">
{isLinked ? (
<Button
variant="outline"
size="sm"
disabled
className="text-gray-500"
>
Already Linked
</Button>
) : (
<Button
onClick={() => onLink(course.id)}
size="sm"
>
Link Course
</Button>
)}
</div>
</div>
);
};
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 (
<div className="space-y-4">
{/* Course List */}
<div className="max-h-[400px] overflow-y-auto space-y-2 px-3">
{courses?.map((course: any) => (
<CoursePreview
key={course.course_uuid}
course={course}
orgslug={org.slug}
onLink={handleLinkCourse}
isLinked={isLinked(course.id)}
/>
))}
{/* Empty State */}
{(!courses || courses.length === 0) && (
<div className="text-center py-6 text-gray-500">
No courses found
</div>
)}
</div>
</div>
);
}

View file

@ -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<any[]>([]);
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 (
<div className="mt-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-semibold text-gray-700">Linked Courses</h3>
<Modal
isDialogOpen={isLinkModalOpen}
onOpenChange={setIsLinkModalOpen}
dialogTitle="Link Course to Product"
dialogDescription="Select a course to link to this product"
dialogContent={
<LinkCourseModal
productId={productId}
onSuccess={() => {
setIsLinkModalOpen(false);
fetchLinkedCourses();
}}
/>
}
dialogTrigger={
<Button variant="outline" size="sm" className="flex items-center gap-2">
<Plus size={16} />
<span>Link Course</span>
</Button>
}
/>
</div>
<div className="space-y-2">
{linkedCourses.length === 0 ? (
<div className="text-sm text-gray-500 flex items-center gap-2">
<BookOpen size={16} />
<span>No courses linked yet</span>
</div>
) : (
linkedCourses.map((course) => (
<div
key={course.id}
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
>
<span className="text-sm font-medium">{course.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleUnlinkCourse(course.id)}
className="text-red-500 hover:text-red-700"
>
<Trash2 size={16} />
</Button>
</div>
))
)}
</div>
</div>
);
}

View file

@ -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;
}