22
33import { createClient as createServerClient } from '@/lib/supabase/server' ;
44import { revalidatePath } from 'next/cache' ;
5- import { getCurrentUserId } from '@/lib/supabase/tenant'
5+ import { getCurrentUserId , getCurrentTenantId } from '@/lib/supabase/tenant'
66
77/**
8- * Mock enrollment for testing checkout flow
9- * In production, this should be called by Stripe webhook after successful payment
10- *
11- * This action:
12- * 1. Creates a transaction record
13- * 2. Calls enroll_user RPC (for products) or handle_new_subscription (for plans)
14- * 3. Enrolls user in associated courses
8+ * Mock checkout — creates a successful transaction.
9+ *
10+ * Enrollment is handled by the `after_transaction_insert` DB trigger
11+ * (`trigger_manage_transactions`), which calls `enroll_user` /
12+ * `handle_new_subscription` — the same single path the Stripe webhook uses.
13+ * In production this action is replaced by the Stripe webhook.
1514 */
1615export async function enrollUser ( courseId ?: string , planId ?: string , paymentMethod : string = 'mock_test' ) {
17- // 1. Authenticate user
16+ // 1. Authenticate user + resolve tenant
1817 const supabase = await createServerClient ( ) ;
1918 const userId = await getCurrentUserId ( )
2019 if ( ! userId ) {
2120 throw new Error ( "You must be logged in to enroll." ) ;
2221 }
22+ const tenantId = await getCurrentTenantId ( ) ;
2323
2424 try {
2525 if ( courseId ) {
26- // Get product for this course (pick first match)
26+ // Get product for this course (pick first match, scoped to tenant )
2727 const { data : productCourses } = await supabase
2828 . from ( 'product_courses' )
2929 . select ( 'product_id, product:products(*)' )
3030 . eq ( 'course_id' , parseInt ( courseId ) )
31+ . eq ( 'tenant_id' , tenantId )
3132 . limit ( 1 ) ;
3233
3334 if ( ! productCourses || productCourses . length === 0 ) {
3435 throw new Error ( "No product found for this course." ) ;
3536 }
3637
37- const product = productCourses [ 0 ] . product as any ;
38+ const product = productCourses [ 0 ] . product as unknown as {
39+ product_id : number ;
40+ price : number | string ;
41+ currency : string ;
42+ } ;
3843
39- // Create transaction record (mock payment)
44+ // Create the transaction. The after_transaction_insert trigger
45+ // enrolls the user (entitlements + enrollment record) — no explicit
46+ // RPC call here, the trigger is the single enrollment path.
4047 const { data : transaction , error : txError } = await supabase
4148 . from ( 'transactions' )
4249 . insert ( {
4350 user_id : userId ,
51+ tenant_id : tenantId ,
4452 product_id : product . product_id ,
4553 amount : product . price ,
4654 currency : product . currency ,
@@ -55,38 +63,30 @@ export async function enrollUser(courseId?: string, planId?: string, paymentMeth
5563 throw new Error ( "Failed to create transaction: " + txError . message ) ;
5664 }
5765
58- // Call enroll_user RPC function
59- // This function checks for transaction and enrolls in linked courses
60- const { error : enrollError } = await supabase . rpc ( 'enroll_user' , {
61- _user_id : userId ,
62- _product_id : product . product_id
63- } ) ;
64-
65- if ( enrollError ) {
66- console . error ( "Enrollment error:" , enrollError ) ;
67- throw new Error ( "Failed to enroll: " + enrollError . message ) ;
68- }
69-
7066 revalidatePath ( '/dashboard/student' ) ;
7167 return { success : true , transaction_id : transaction . transaction_id } ;
7268
7369 } else if ( planId ) {
74- // Get plan details
70+ // Get plan details (scoped to tenant)
7571 const { data : plan , error : planError } = await supabase
7672 . from ( 'plans' )
7773 . select ( '*' )
7874 . eq ( 'plan_id' , parseInt ( planId ) )
75+ . eq ( 'tenant_id' , tenantId )
7976 . single ( ) ;
8077
8178 if ( planError || ! plan ) {
8279 throw new Error ( "Plan not found." ) ;
8380 }
8481
85- // Create transaction for plan
82+ // Create the transaction. The after_transaction_insert trigger
83+ // creates the subscription + entitlements (or extends an existing
84+ // subscription on renewal).
8685 const { data : transaction , error : txError } = await supabase
8786 . from ( 'transactions' )
8887 . insert ( {
8988 user_id : userId ,
89+ tenant_id : tenantId ,
9090 plan_id : plan . plan_id ,
9191 amount : plan . price ,
9292 currency : plan . currency ,
@@ -101,18 +101,6 @@ export async function enrollUser(courseId?: string, planId?: string, paymentMeth
101101 throw new Error ( "Failed to create transaction: " + txError . message ) ;
102102 }
103103
104- // Call handle_new_subscription RPC function
105- const { error : subError } = await supabase . rpc ( 'handle_new_subscription' , {
106- _user_id : userId ,
107- _plan_id : plan . plan_id ,
108- _transaction_id : transaction . transaction_id
109- } ) ;
110-
111- if ( subError ) {
112- console . error ( "Subscription error:" , subError ) ;
113- throw new Error ( "Failed to create subscription: " + subError . message ) ;
114- }
115-
116104 revalidatePath ( '/dashboard/student' ) ;
117105 return { success : true , transaction_id : transaction . transaction_id } ;
118106 }
@@ -126,85 +114,72 @@ export async function enrollUser(courseId?: string, planId?: string, paymentMeth
126114}
127115
128116/**
129- * Enroll user in a free course or plan
117+ * Enroll the current user in a free course.
118+ *
119+ * Grants a `free` entitlement via the grant_free_entitlement RPC
120+ * (SECURITY DEFINER, idempotent). No product/transaction is fabricated.
121+ * See docs/ENTITLEMENTS_MIGRATION_PLAN.md.
130122 */
131- export async function enrollFree ( courseId ?: string , planId ?: string ) {
123+ export async function enrollFree ( courseId ?: string ) {
132124 const supabase = await createServerClient ( ) ;
133125 const userId = await getCurrentUserId ( )
134126 if ( ! userId ) {
135127 throw new Error ( "You must be logged in to enroll." ) ;
136128 }
129+ const tenantId = await getCurrentTenantId ( ) ;
137130
138131 try {
139- if ( courseId ) {
140- // Check if the course is actually free
141- const { data : productCourses } = await supabase
142- . from ( 'product_courses' )
143- . select ( 'product:products(product_id, price)' )
144- . eq ( 'course_id' , parseInt ( courseId ) )
145- . limit ( 1 ) ;
132+ if ( ! courseId ) {
133+ throw new Error ( "Free enrollment only supported for courses currently." ) ;
134+ }
146135
147- const courseProduct = productCourses ?. [ 0 ] ?. product as any ;
148- const isFree = courseProduct ? parseFloat ( courseProduct . price ) === 0 : true ;
136+ const numericCourseId = parseInt ( courseId ) ;
149137
150- if ( ! isFree ) {
151- throw new Error ( "This course is not free. Please use the paid enrollment flow." ) ;
152- }
138+ // 1. Validate the course: it must belong to this tenant and be published.
139+ // Prevents enrolling in another tenant's course or an unpublished draft
140+ // via a crafted checkout URL.
141+ const { data : course , error : courseError } = await supabase
142+ . from ( 'courses' )
143+ . select ( 'course_id, status, tenant_id' )
144+ . eq ( 'course_id' , numericCourseId )
145+ . eq ( 'tenant_id' , tenantId )
146+ . single ( ) ;
153147
154- // For free courses, we need a product_id to satisfy the constraint
155- let productId : number ;
156-
157- if ( courseProduct ?. product_id ) {
158- productId = courseProduct . product_id ;
159- } else {
160- // Create a free product if one doesn't exist
161- const { data : freeProduct , error : createError } = await supabase
162- . from ( 'products' )
163- . insert ( {
164- name : `Free Access - ${ courseId } ` ,
165- price : 0 ,
166- currency : 'usd' ,
167- status : 'active' ,
168- payment_provider : 'stripe'
169- } )
170- . select ( 'product_id' )
171- . single ( ) ;
172-
173- if ( createError ) throw createError ;
174-
175- // Link product to course
176- await supabase
177- . from ( 'product_courses' )
178- . insert ( {
179- product_id : freeProduct . product_id ,
180- course_id : parseInt ( courseId )
181- } ) ;
182-
183- productId = freeProduct . product_id ;
184- }
148+ if ( courseError || ! course ) {
149+ throw new Error ( "Course not found." ) ;
150+ }
151+ if ( course . status !== 'published' ) {
152+ throw new Error ( "This course is not available for enrollment." ) ;
153+ }
185154
186- // Create enrollment with product_id
187- const { error } = await supabase
188- . from ( 'enrollments' )
189- . insert ( {
190- user_id : userId ,
191- course_id : parseInt ( courseId ) ,
192- product_id : productId ,
193- status : 'active' ,
194- enrollment_date : new Date ( ) . toISOString ( )
195- } ) ;
196-
197- if ( error ) {
198- // If already enrolled, that's fine
199- if ( error . code === '23505' ) return { success : true , alreadyEnrolled : true } ;
200- throw error ;
201- }
155+ // 2. Guard: reject if a paid product is linked to this course.
156+ const { data : productCourses } = await supabase
157+ . from ( 'product_courses' )
158+ . select ( 'product:products(price)' )
159+ . eq ( 'course_id' , numericCourseId )
160+ . eq ( 'tenant_id' , tenantId )
161+ . limit ( 1 ) ;
202162
203- revalidatePath ( '/dashboard/student' ) ;
204- return { success : true } ;
163+ const linkedProduct = productCourses ?. [ 0 ] ?. product as unknown as {
164+ price : number | string ;
165+ } | undefined ;
166+
167+ if ( linkedProduct && Number ( linkedProduct . price ) !== 0 ) {
168+ throw new Error ( "This course is not free. Please use the paid enrollment flow." ) ;
169+ }
170+
171+ // 3. Grant a free entitlement (entitlements model). Idempotent.
172+ const { error : grantError } = await supabase . rpc ( 'grant_free_entitlement' , {
173+ _user_id : userId ,
174+ _course_id : numericCourseId ,
175+ } ) ;
176+
177+ if ( grantError ) {
178+ throw new Error ( "Failed to enroll: " + grantError . message ) ;
205179 }
206-
207- throw new Error ( "Free enrollment only supported for courses currently." ) ;
180+
181+ revalidatePath ( '/dashboard/student' ) ;
182+ return { success : true } ;
208183 } catch ( error ) {
209184 console . error ( "Free enrollment failed:" , error ) ;
210185 throw error ;
0 commit comments