mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add ability to link courses to products
This commit is contained in:
parent
3f96f1ec9f
commit
f0aeb4605c
8 changed files with 446 additions and 2 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
124
apps/api/src/services/payments/payments_courses.py
Normal file
124
apps/api/src/services/payments/payments_courses.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue