diff --git a/apps/api/src/routers/ee/payments.py b/apps/api/src/routers/ee/payments.py index 43d04038..aaec689c 100644 --- a/apps/api/src/routers/ee/payments.py +++ b/apps/api/src/routers/ee/payments.py @@ -17,6 +17,7 @@ from src.services.payments.payments_courses import ( unlink_course_from_product, get_courses_by_product, ) +from src.services.payments.payments_users import get_owned_courses from src.services.payments.payments_webhook import handle_stripe_webhook from src.services.payments.stripe import create_checkout_session from src.services.payments.payments_access import check_course_paid_access @@ -218,3 +219,12 @@ async def api_get_customers( Get list of customers and their subscriptions for an organization """ return await get_customers(request, org_id, current_user, db_session) + +@router.get("/{org_id}/courses/owned") +async def api_get_owned_courses( + request: Request, + org_id: int, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + return await get_owned_courses(request, current_user, db_session) \ No newline at end of file diff --git a/apps/api/src/services/payments/payments_customers.py b/apps/api/src/services/payments/payments_customers.py index d87892c0..3922bb0e 100644 --- a/apps/api/src/services/payments/payments_customers.py +++ b/apps/api/src/services/payments/payments_customers.py @@ -1,9 +1,8 @@ from fastapi import HTTPException, Request from sqlmodel import Session, select from src.db.organizations import Organization -from src.db.users import PublicUser, AnonymousUser, User +from src.db.users import PublicUser, AnonymousUser from src.db.payments.payments_users import PaymentsUser -from src.db.payments.payments_products import PaymentsProduct from src.services.orgs.orgs import rbac_check from src.services.payments.payments_products import get_payments_product from src.services.users.users import read_user_by_id diff --git a/apps/api/src/services/payments/payments_users.py b/apps/api/src/services/payments/payments_users.py index 5b34e8c6..077ab713 100644 --- a/apps/api/src/services/payments/payments_users.py +++ b/apps/api/src/services/payments/payments_users.py @@ -1,9 +1,12 @@ from fastapi import HTTPException, Request from sqlmodel import Session, select from typing import Any +from src.db.courses.courses import Course, CourseRead +from src.db.payments.payments_courses import PaymentsCourse from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData from src.db.payments.payments_products import PaymentsProduct -from src.db.users import InternalUser, PublicUser, AnonymousUser +from src.db.resource_authors import ResourceAuthor +from src.db.users import InternalUser, PublicUser, AnonymousUser, User, UserRead from src.db.organizations import Organization from src.services.orgs.orgs import rbac_check from datetime import datetime @@ -185,3 +188,59 @@ async def delete_payment_user( # Delete payment user db_session.delete(payment_user) db_session.commit() + + +async def get_owned_courses( + request: Request, + current_user: PublicUser | AnonymousUser, + db_session: Session, +) -> list[CourseRead]: + # Anonymous users don't own any courses + if isinstance(current_user, AnonymousUser): + return [] + + # Get all active/completed payment users for the current user + statement = select(PaymentsUser).where( + PaymentsUser.user_id == current_user.id, + PaymentsUser.status.in_([PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]) # type: ignore + ) + payment_users = db_session.exec(statement).all() + + # Get all product IDs from payment users + product_ids = [pu.payment_product_id for pu in payment_users] + + # Get all courses linked to these products + courses = [] + for product_id in product_ids: + # Get courses linked to this product through PaymentsCourse + statement = ( + select(Course) + .join(PaymentsCourse, Course.id == PaymentsCourse.course_id) # type: ignore + .where(PaymentsCourse.payment_product_id == product_id) + ) + product_courses = db_session.exec(statement).all() + courses.extend(product_courses) + + # Remove duplicates by converting to set and back to list + unique_courses = list({course.id: course for course in courses}.values()) + + # Get authors for each course and convert to CourseRead + course_reads = [] + for course in unique_courses: + # Get course authors + authors_statement = ( + select(User) + .join(ResourceAuthor) + .where(ResourceAuthor.resource_uuid == course.course_uuid) + ) + authors = db_session.exec(authors_statement).all() + + # Convert authors to UserRead + author_reads = [UserRead.model_validate(author) for author in authors] + + # Create CourseRead object + course_read = CourseRead(**course.model_dump(), authors=author_reads) + course_reads.append(course_read) + + return course_reads + diff --git a/apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx b/apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx new file mode 100644 index 00000000..f2b83c14 --- /dev/null +++ b/apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx @@ -0,0 +1,59 @@ +'use client' + +import React from 'react' +import { useOrg } from '@components/Contexts/OrgContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import useSWR from 'swr' +import { getOwnedCourses } from '@services/payments/payments' +import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail' +import PageLoading from '@components/Objects/Loaders/PageLoading' +import { BookOpen } from 'lucide-react' + +function OwnedCoursesPage() { + const org = useOrg() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + + const { data: ownedCourses, error, isLoading } = useSWR( + org ? [`/payments/${org.id}/courses/owned`, access_token] : null, + ([url, token]) => getOwnedCourses(org.id, token) + ) + + if (isLoading) return + if (error) return
Error loading owned courses
+ + return ( +
+
+

My Courses

+

Courses you have purchased or subscribed to

+
+ +
+ {ownedCourses?.map((course: any) => ( +
+ +
+ ))} + + {(!ownedCourses || ownedCourses.length === 0) && ( +
+
+
+ +
+

+ No purchased courses +

+

+ You haven't purchased any courses yet +

+
+
+ )} +
+
+ ) +} + +export default OwnedCoursesPage diff --git a/apps/web/components/Dashboard/UI/DashLeftMenu.tsx b/apps/web/components/Dashboard/UI/DashLeftMenu.tsx index 09eb96e8..f4c5458d 100644 --- a/apps/web/components/Dashboard/UI/DashLeftMenu.tsx +++ b/apps/web/components/Dashboard/UI/DashLeftMenu.tsx @@ -3,7 +3,7 @@ import { useOrg } from '@components/Contexts/OrgContext' import { signOut } from 'next-auth/react' import ToolTip from '@components/StyledElements/Tooltip/Tooltip' import LearnHouseDashboardLogo from '@public/dashLogo.png' -import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react' +import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, Package2, School, Settings, Users, Vault } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import React, { useEffect } from 'react' @@ -147,23 +147,41 @@ function DashLeftMenu() { -
- +
+ + + + + + - - + + +
{ const session = useLHSession() as any - const isUserAdmin = useAdminStatus() + const isUserAdmin = useAdminStatus() const org = useOrg() as any useEffect(() => { } @@ -39,12 +40,30 @@ export const HeaderProfileBox = () => {

{session.data.user.username}

{isUserAdmin.isAdmin &&
ADMIN
}
+ +
+ + + + + + + + + + +
- - - )} diff --git a/apps/web/services/payments/payments.ts b/apps/web/services/payments/payments.ts index 2b4e0bcb..501d8492 100644 --- a/apps/web/services/payments/payments.ts +++ b/apps/web/services/payments/payments.ts @@ -53,4 +53,13 @@ export async function getOrgCustomers(orgId: number, access_token: string) { ); const res = await errorHandling(result); return res; +} + +export async function getOwnedCourses(orgId: number, access_token: string) { + const result = await fetch( + `${getAPIUrl()}payments/${orgId}/courses/owned`, + RequestBodyWithAuthHeader('GET', null, null, access_token) + ); + const res = await errorHandling(result); + return res; } \ No newline at end of file