Auto-cancel expired consulting orders and prefill re-booking

**Features Implemented:**

1. **Auto-Cancel Expired Consulting Orders:**
   - New edge function: cancel-expired-consulting-orders
   - Changes order status from 'pending' → 'cancelled'
   - Cancels all associated consulting_sessions
   - Deletes calendar events via delete-calendar-event
   - Releases consulting_time_slots (deletes booked slots)
   - Properly cleans up all resources

2. **Smart Re-Booking with Pre-filled Data:**
   - OrderDetail.tsx stores expired order data in sessionStorage:
     - fromExpiredOrder flag
     - Original orderId
     - topicCategory
     - notes
   - ConsultingBooking.tsx retrieves and pre-fills form on mount
   - Auto-clears sessionStorage after use

3. **Improved UX for Expired Orders:**
   - Clear message: "Order ini telah dibatalkan secara otomatis"
   - Helpful hint: "Kategori dan catatan akan terisi otomatis"
   - One-click re-booking with pre-filled data
   - Member only needs to select new time slot

**How It Works:**

Flow:
1. QRIS expires → Order shows expired message
2. Member clicks "Buat Booking Baru"
3. Data stored in sessionStorage (category, notes)
4. Navigates to /consulting
5. Form auto-fills with previous data
6. Member selects new time → Books new session

**Edge Function Details:**
- Finds orders where: payment_status='pending' AND qr_expires_at < NOW()
- Cancels order status
- Cancels consulting_sessions
- Deletes consulting_time_slots
- Invokes delete-calendar-event for each session
- Returns count of processed orders

**To Deploy:**
1. Deploy cancel-expired-consulting-orders edge function
2. Set up cron job to run every 5-15 minutes:
   `curl -X POST https://your-domain/functions/v1/cancel-expired-consulting-orders`

**Benefits:**
 Orders properly cancelled when QR expires
 Time slots released for other users
 Calendar events cleaned up
 Easy re-booking without re-typing data
 Better UX for expired payment situations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-28 18:13:20 +07:00
parent b88e308b84
commit 3eb53406c9
3 changed files with 180 additions and 2 deletions

View File

@@ -0,0 +1,136 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
console.log("[CANCEL-EXPIRED] Starting check for expired consulting orders");
// Find expired pending consulting orders
const now = new Date().toISOString();
// Get orders with consulting_sessions that are pending payment and QR is expired
const { data: expiredOrders, error: queryError } = await supabase
.from("orders")
.select(`
id,
payment_status,
qr_expires_at,
consulting_sessions (
id,
topic_category,
notes,
session_date,
start_time,
end_time
)
`)
.eq("payment_status", "pending")
.lt("qr_expires_at", now)
.not("consulting_sessions", "is", null);
if (queryError) {
console.error("[CANCEL-EXPIRED] Query error:", queryError);
throw queryError;
}
if (!expiredOrders || expiredOrders.length === 0) {
console.log("[CANCEL-EXPIRED] No expired orders found");
return new Response(
JSON.stringify({
success: true,
message: "No expired orders to process",
processed: 0
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
console.log(`[CANCEL-EXPIRED] Found ${expiredOrders.length} expired orders`);
let processedCount = 0;
// Process each expired order
for (const order of expiredOrders) {
console.log(`[CANCEL-EXPIRED] Processing order: ${order.id}`);
// Update order status to cancelled
const { error: updateError } = await supabase
.from("orders")
.update({ status: "cancelled" })
.eq("id", order.id);
if (updateError) {
console.error(`[CANCEL-EXPIRED] Failed to update order ${order.id}:`, updateError);
continue;
}
// Cancel all consulting sessions for this order
if (order.consulting_sessions && order.consulting_sessions.length > 0) {
for (const session of order.consulting_sessions) {
// Delete calendar event if exists
if (session.calendar_event_id) {
try {
await supabase.functions.invoke('delete-calendar-event', {
body: { session_id: session.id }
});
console.log(`[CANCEL-EXPIRED] Deleted calendar event for session: ${session.id}`);
} catch (err) {
console.log(`[CANCEL-EXPIRED] Failed to delete calendar event: ${err}`);
// Continue anyway
}
}
// Update session status to cancelled
await supabase
.from("consulting_sessions")
.update({ status: "cancelled" })
.eq("id", session.id);
// Delete or release time slots
await supabase
.from("consulting_time_slots")
.delete()
.eq("session_id", session.id);
console.log(`[CANCEL-EXPIRED] Cancelled session: ${session.id}`);
}
}
processedCount++;
}
console.log(`[CANCEL-EXPIRED] Successfully processed ${processedCount} orders`);
return new Response(
JSON.stringify({
success: true,
message: `Successfully cancelled ${processedCount} expired consulting orders`,
processed: processedCount
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("[CANCEL-EXPIRED] Error:", error);
return new Response(
JSON.stringify({
success: false,
error: error.message || "Internal server error"
}),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});