diff --git a/fix-existing-consulting-orders.sql b/fix-existing-consulting-orders.sql new file mode 100644 index 0000000..c9c87cd --- /dev/null +++ b/fix-existing-consulting-orders.sql @@ -0,0 +1,38 @@ +-- SQL Script to manually fix existing paid consulting orders +-- This updates consulting_slots status and can be run in Supabase SQL Editor + +-- Step 1: Check how many consulting orders are affected +SELECT + o.id as order_id, + o.payment_status, + COUNT(cs.id) as slot_count, + SUM(CASE WHEN cs.status = 'pending_payment' THEN 1 ELSE 0 END) as pending_slots +FROM orders o +INNER JOIN consulting_slots cs ON cs.order_id = o.id +WHERE o.payment_status = 'paid' +GROUP BY o.id, o.payment_status +HAVING SUM(CASE WHEN cs.status = 'pending_payment' THEN 1 ELSE 0 END) > 0; + +-- Step 2: Update all pending_payment slots for paid orders to 'confirmed' +UPDATE consulting_slots +SET status = 'confirmed' +WHERE order_id IN ( + SELECT o.id + FROM orders o + WHERE o.payment_status = 'paid' +) +AND status = 'pending_payment'; + +-- Step 3: Verify the update +SELECT + o.id as order_id, + o.payment_status, + cs.status as slot_status, + cs.date, + cs.start_time, + cs.end_time, + cs.meet_link +FROM orders o +INNER JOIN consulting_slots cs ON cs.order_id = o.id +WHERE o.payment_status = 'paid' +ORDER BY o.created_at DESC; diff --git a/fix-trigger-settings.sql b/fix-trigger-settings.sql new file mode 100644 index 0000000..79a3aa0 --- /dev/null +++ b/fix-trigger-settings.sql @@ -0,0 +1,50 @@ +-- Fixed version of handle_paid_order with hardcoded URL +-- Run this in Supabase SQL Editor + +CREATE OR REPLACE FUNCTION handle_paid_order() +RETURNS TRIGGER AS $$ +DECLARE + edge_function_url TEXT; + order_data JSON; +BEGIN + -- Only proceed if payment_status changed to 'paid' + IF (NEW.payment_status != 'paid' OR OLD.payment_status = 'paid') THEN + RETURN NEW; + END IF; + + -- Log the payment event + RAISE NOTICE 'Order % payment status changed to paid', NEW.id; + + -- Hardcoded edge function URL + edge_function_url := 'https://lovable.backoffice.biz.id/functions/v1/handle-order-paid'; + + -- Prepare order data + order_data := json_build_object( + 'order_id', NEW.id, + 'user_id', NEW.user_id, + 'total_amount', NEW.total_amount, + 'payment_method', NEW.payment_method, + 'payment_provider', NEW.payment_provider + ); + + -- Call the edge function asynchronously via pg_net + PERFORM net.http_post( + url := edge_function_url, + headers := json_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || current_setting('app.service_role_key', true) + ), + body := order_data, + timeout_milliseconds := 10000 + ); + + RAISE NOTICE 'Called handle-order-paid for order %', NEW.id; + + RETURN NEW; +EXCEPTION + WHEN OTHERS THEN + -- Log error but don't fail the transaction + RAISE WARNING 'Failed to call handle-order-paid for order %: %', NEW.id, SQLERRM; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/src/components/reviews/ConsultingHistory.tsx b/src/components/reviews/ConsultingHistory.tsx index 39d0906..cb2def8 100644 --- a/src/components/reviews/ConsultingHistory.tsx +++ b/src/components/reviews/ConsultingHistory.tsx @@ -20,20 +20,26 @@ interface ConsultingSlot { order_id: string | null; } +interface GroupedOrder { + orderId: string | null; + slots: ConsultingSlot[]; + firstDate: string; + meetLink: string | null; +} + interface ConsultingHistoryProps { userId: string; } export function ConsultingHistory({ userId }: ConsultingHistoryProps) { const [slots, setSlots] = useState([]); - const [reviewedSlotIds, setReviewedSlotIds] = useState>(new Set()); + const [reviewedOrderIds, setReviewedOrderIds] = useState>(new Set()); const [loading, setLoading] = useState(true); const [reviewModal, setReviewModal] = useState<{ open: boolean; - slotId: string; orderId: string | null; label: string; - }>({ open: false, slotId: '', orderId: null, label: '' }); + }>({ open: false, orderId: null, label: '' }); useEffect(() => { fetchData(); @@ -50,9 +56,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { if (slotsData) { setSlots(slotsData); - // Check which slots have been reviewed - // We use a combination approach: check for consulting reviews by this user - // For consulting, we'll track by order_id since that's how we link them + // Check which orders have been reviewed const orderIds = slotsData .filter(s => s.order_id) .map(s => s.order_id as string); @@ -66,14 +70,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { .in('order_id', orderIds); if (reviewsData) { - const reviewedOrderIds = new Set(reviewsData.map(r => r.order_id)); - // Map order_id back to slot_id - const reviewedIds = new Set( - slotsData - .filter(s => s.order_id && reviewedOrderIds.has(s.order_id)) - .map(s => s.id) - ); - setReviewedSlotIds(reviewedIds); + setReviewedOrderIds(new Set(reviewsData.map(r => r.order_id))); } } } @@ -81,6 +78,26 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { setLoading(false); }; + // Group slots by order_id + const groupedOrders: GroupedOrder[] = (() => { + const groups = new Map(); + + slots.forEach(slot => { + const orderId = slot.order_id || 'no-order'; + if (!groups.has(orderId)) { + groups.set(orderId, []); + } + groups.get(orderId)!.push(slot); + }); + + return Array.from(groups.entries()).map(([orderId, slots]) => ({ + orderId: orderId === 'no-order' ? null : orderId, + slots, + firstDate: slots[0].date, + meetLink: slots[0].meet_link, // Use meet_link from first slot + })).sort((a, b) => new Date(b.firstDate).getTime() - new Date(a.firstDate).getTime()); + })(); + const getStatusBadge = (status: string) => { switch (status) { case 'done': @@ -96,24 +113,27 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { } }; - const openReviewModal = (slot: ConsultingSlot) => { - const dateLabel = format(new Date(slot.date), 'd MMMM yyyy', { locale: id }); - const timeLabel = `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)}`; + const openReviewModal = (order: GroupedOrder) => { + const firstSlot = order.slots[0]; + const lastSlot = order.slots[order.slots.length - 1]; + const dateLabel = format(new Date(firstSlot.date), 'd MMMM yyyy', { locale: id }); + const timeLabel = `${firstSlot.start_time.substring(0, 5)} - ${lastSlot.end_time.substring(0, 5)}`; setReviewModal({ open: true, - slotId: slot.id, - orderId: slot.order_id, + orderId: order.orderId, label: `Sesi konsultasi ${dateLabel}, ${timeLabel}`, }); }; const handleReviewSuccess = () => { - // Mark this slot as reviewed - setReviewedSlotIds(prev => new Set([...prev, reviewModal.slotId])); + // Mark this order as reviewed + if (reviewModal.orderId) { + setReviewedOrderIds(prev => new Set([...prev, reviewModal.orderId!])); + } }; - const doneSlots = slots.filter(s => s.status === 'done'); - const upcomingSlots = slots.filter(s => s.status === 'confirmed'); + const doneOrders = groupedOrders.filter(o => o.slots.every(s => s.status === 'done')); + const upcomingOrders = groupedOrders.filter(o => o.slots.some(s => s.status === 'confirmed')); if (loading) { return ( @@ -147,62 +167,68 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { {/* Upcoming sessions */} - {upcomingSlots.length > 0 && ( + {upcomingOrders.length > 0 && (

Sesi Mendatang

- {upcomingSlots.map((slot) => ( -
-
- -
-

- {format(new Date(slot.date), 'd MMM yyyy', { locale: id })} -

-

- - {slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} - {slot.topic_category && ` • ${slot.topic_category}`} -

-
-
-
- {getStatusBadge(slot.status)} - {slot.meet_link && ( - - )} -
-
- ))} -
- )} - - {/* Completed sessions */} - {doneSlots.length > 0 && ( -
-

Sesi Selesai

- {doneSlots.map((slot) => { - const hasReviewed = reviewedSlotIds.has(slot.id); + {upcomingOrders.map((order) => { + const firstSlot = order.slots[0]; + const lastSlot = order.slots[order.slots.length - 1]; return ( -
+

- {format(new Date(slot.date), 'd MMM yyyy', { locale: id })} + {format(new Date(firstSlot.date), 'd MMM yyyy', { locale: id })}

- {slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} - {slot.topic_category && ` • ${slot.topic_category}`} + {firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)} + {firstSlot.topic_category && ` • ${firstSlot.topic_category}`}

- {getStatusBadge(slot.status)} + {getStatusBadge(firstSlot.status)} + {order.meetLink && ( + + )} +
+
+ ); + })} +
+ )} + + {/* Completed sessions */} + {doneOrders.length > 0 && ( +
+

Sesi Selesai

+ {doneOrders.map((order) => { + const firstSlot = order.slots[0]; + const lastSlot = order.slots[order.slots.length - 1]; + const hasReviewed = order.orderId ? reviewedOrderIds.has(order.orderId) : false; + return ( +
+
+ +
+

+ {format(new Date(firstSlot.date), 'd MMM yyyy', { locale: id })} +

+

+ + {firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)} + {firstSlot.topic_category && ` • ${firstSlot.topic_category}`} +

+
+
+
+ {getStatusBadge(firstSlot.status)} {hasReviewed ? ( @@ -212,7 +238,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
)} - {access.length === 0 ? ( + {/* Consulting Sessions with Recordings */} + {consultingSessions.length > 0 && ( +
+

+

+
+ {consultingSessions.map((session) => ( + + +
+
+ + 1-on-1: {session.topic_category || 'Konsultasi'} + + Konsultasi +
+ Selesai +
+
+ +
+ + {format(new Date(session.date), 'd MMMM yyyy', { locale: id })} + + {session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)} +
+ {session.recording_url ? ( + + ) : ( + Rekaman segera tersedia + )} +
+
+ ))} +
+
+ )} + + {/* Products Section */} + {access.length > 0 && ( +
+

Produk & Kursus

+
+ )} + + {access.length === 0 && consultingSessions.length === 0 ? (

Anda belum memiliki akses ke produk apapun

@@ -258,7 +332,7 @@ export default function MemberAccess() {
- ) : ( + ) : access.length === 0 ? null : (
{filteredAccess.length === 0 && access.length > 0 && (
diff --git a/supabase/functions/handle-order-paid/index.ts b/supabase/functions/handle-order-paid/index.ts index 2e811ac..0044588 100644 --- a/supabase/functions/handle-order-paid/index.ts +++ b/supabase/functions/handle-order-paid/index.ts @@ -24,17 +24,19 @@ serve(async (req: Request): Promise => { const { order_id } = body; console.log("[HANDLE-PAID] Processing paid order:", order_id); + console.log("[HANDLE-PAID] Request body:", JSON.stringify(body)); const supabaseUrl = Deno.env.get("SUPABASE_URL")!; const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const supabase = createClient(supabaseUrl, supabaseServiceKey); // Get full order details with items AND consulting slots + // Use maybeSingle() in case there are no related records const { data: order, error: orderError } = await supabase .from("orders") .select(` *, - profiles(email, full_name), + profiles(email, name), order_items ( product_id, product:products (title, type) @@ -48,12 +50,20 @@ serve(async (req: Request): Promise => { ) `) .eq("id", order_id) - .single(); + .maybeSingle(); - if (orderError || !order) { - console.error("[HANDLE-PAID] Order not found:", order_id, orderError); + if (orderError) { + console.error("[HANDLE-PAID] Database error:", orderError); return new Response( - JSON.stringify({ success: false, error: "Order not found" }), + JSON.stringify({ success: false, error: "Database error", details: orderError.message }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + if (!order) { + console.error("[HANDLE-PAID] Order not found:", order_id); + return new Response( + JSON.stringify({ success: false, error: "Order not found", order_id }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } @@ -67,7 +77,7 @@ serve(async (req: Request): Promise => { })); const userEmail = order.profiles?.email || ""; - const userName = order.profiles?.full_name || "Pelanggan"; + const userName = order.profiles?.name || userEmail.split('@')[0] || "Pelanggan"; const orderItems = order.order_items as Array<{ product_id: string; product: { title: string; type: string }; @@ -140,12 +150,13 @@ serve(async (req: Request): Promise => { // Don't fail the entire process } } + } // Send consulting notification with the consultingSlots data await sendNotification(supabase, "consulting_scheduled", { nama: userName, email: userEmail, - order_id: order_id.substring(0, 8), + order_id_short: order_id.substring(0, 8), tanggal_pesanan: new Date().toLocaleDateString("id-ID"), total: `Rp ${order.total_amount.toLocaleString("id-ID")}`, metode_pembayaran: order.payment_method || "Unknown", @@ -188,7 +199,7 @@ serve(async (req: Request): Promise => { await sendNotification(supabase, "payment_success", { nama: userName, email: userEmail, - order_id: order_id.substring(0, 8), + order_id_short: order_id.substring(0, 8), tanggal_pesanan: new Date().toLocaleDateString("id-ID"), total: `Rp ${order.total_amount.toLocaleString("id-ID")}`, metode_pembayaran: order.payment_method || "Unknown", diff --git a/supabase/functions/pakasir-webhook/index.ts b/supabase/functions/pakasir-webhook/index.ts index 7277891..c34fbac 100644 --- a/supabase/functions/pakasir-webhook/index.ts +++ b/supabase/functions/pakasir-webhook/index.ts @@ -65,7 +65,7 @@ serve(async (req) => { // Find the order by payment_reference or id const { data: order, error: orderError } = await supabase .from("orders") - .select("id, payment_status") + .select("id, payment_status, user_id, total_amount") .or(`payment_reference.eq.${payload.order_id},id.eq.${payload.order_id}`) .single(); diff --git a/supabase/migrations/20251226_configure_trigger_settings.sql b/supabase/migrations/20251226_configure_trigger_settings.sql new file mode 100644 index 0000000..ed7c8da --- /dev/null +++ b/supabase/migrations/20251226_configure_trigger_settings.sql @@ -0,0 +1,16 @@ +-- Configure database settings for handle-order-paid trigger +-- These settings are required for the payment trigger to work + +-- Set base URL (change if different) +DELETE FROM pg_settings WHERE name = 'app.base_url'; +ALTER DATABASE postgres SET "app.base_url" = 'https://lovable.backoffice.biz.id'; + +-- Set service role key (you need to replace this with your actual service role key) +-- Get it from: Supabase Dashboard > Project Settings > API > service_role (confidential) +-- Uncomment and set the actual key: +-- ALTER DATABASE postgres SET "app.service_role_key" = 'YOUR_SERVICE_ROLE_KEY_HERE'; + +-- Verify settings +SELECT + current_setting('app.base_url', true) as base_url, + current_setting('app.service_role_key', true) as service_role_key; diff --git a/supabase/migrations/20251227_add_consulting_slots_user_id_fk.sql b/supabase/migrations/20251227_add_consulting_slots_user_id_fk.sql new file mode 100644 index 0000000..ef09537 --- /dev/null +++ b/supabase/migrations/20251227_add_consulting_slots_user_id_fk.sql @@ -0,0 +1,37 @@ +-- Add foreign key relationship between consulting_slots and profiles (user_id) +-- This enables the relationship query in ConsultingHistory component + +-- First, check if the foreign key already exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'consulting_slots' + AND constraint_name = 'consulting_slots_user_id_fkey' + ) THEN + -- Add foreign key constraint if it doesn't exist + ALTER TABLE consulting_slots + ADD CONSTRAINT consulting_slots_user_id_fkey + FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; + + RAISE NOTICE 'Foreign key constraint added for user_id'; + ELSE + RAISE NOTICE 'Foreign key constraint for user_id already exists'; + END IF; +END $$; + +-- Verify the relationship +SELECT + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name +FROM information_schema.table_constraints AS tc +JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name +WHERE tc.constraint_type = 'FOREIGN KEY' +AND tc.table_name = 'consulting_slots' +AND kcu.column_name = 'user_id'; diff --git a/supabase/migrations/20251227_add_orders_foreign_key.sql b/supabase/migrations/20251227_add_orders_foreign_key.sql new file mode 100644 index 0000000..04d0346 --- /dev/null +++ b/supabase/migrations/20251227_add_orders_foreign_key.sql @@ -0,0 +1,36 @@ +-- Add foreign key relationship between consulting_slots and orders +-- This enables the relationship query in handle-order-paid edge function + +-- First, check if the column exists +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE table_name = 'consulting_slots' + AND constraint_name = 'consulting_slots_order_id_fkey' + ) THEN + -- Add foreign key constraint if it doesn't exist + ALTER TABLE consulting_slots + ADD CONSTRAINT consulting_slots_order_id_fkey + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE; + + RAISE NOTICE 'Foreign key constraint added successfully'; + ELSE + RAISE NOTICE 'Foreign key constraint already exists'; + END IF; +END $$; + +-- Verify the relationship +SELECT + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name +FROM information_schema.table_constraints AS tc +JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name +WHERE tc.constraint_type = 'FOREIGN KEY' +AND tc.table_name = 'consulting_slots'; diff --git a/test-manual-trigger.js b/test-manual-trigger.js new file mode 100644 index 0000000..eb71f97 --- /dev/null +++ b/test-manual-trigger.js @@ -0,0 +1,37 @@ +// Test script to manually trigger handle-order-paid for existing consulting orders +// Run with: node test-manual-trigger.js + +const orderId = process.argv[2]; + +if (!orderId) { + console.error('Usage: node test-manual-trigger.js '); + process.exit(1); +} + +const SUPABASE_URL = process.env.SUPABASE_URL || 'https://lovable.backoffice.biz.id'; +const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY || 'your-anon-key'; + +async function triggerManual() { + try { + const response = await fetch(`${SUPABASE_URL}/functions/v1/handle-order-paid`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${SUPABASE_ANON_KEY}`, + }, + body: JSON.stringify({ + order_id: orderId, + user_id: 'test', + total_amount: 0, + }), + }); + + const result = await response.json(); + console.log('Response:', result); + console.log('Status:', response.status); + } catch (error) { + console.error('Error:', error); + } +} + +triggerManual();