Add calendar event lifecycle management and "Add to Calendar" feature

- Migrate consulting_slots to consulting_sessions structure
- Add calendar_event_id to track Google Calendar events
- Create delete-calendar-event edge function for auto-cleanup
- Add "Tambah ke Kalender" button for members (OrderDetail, ConsultingHistory)
- Update create-google-meet-event to store calendar event ID
- Update handle-order-paid to use consulting_sessions table
- Remove deprecated create-meet-link function
- Add comprehensive documentation (CALENDAR_INTEGRATION.md, MIGRATION_GUIDE.md)

🤖 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 13:54:16 +07:00
parent 952bb209cf
commit 5ab4e6b974
11 changed files with 1303 additions and 554 deletions

View File

@@ -311,27 +311,15 @@ serve(async (req: Request): Promise<Response> => {
if (meetLink) {
log(`Meet link found: ${meetLink}`);
// If this is part of a multi-slot order, update all slots with the same order_id
// First, check if this slot has an order_id
const { data: slotData } = await supabase
.from("consulting_slots")
.select("order_id")
.eq("id", body.slot_id)
.single();
if (slotData?.order_id) {
log(`Updating all slots in order ${slotData.order_id} with meet_link`);
await supabase
.from("consulting_slots")
.update({ meet_link: meetLink })
.eq("order_id", slotData.order_id);
} else {
log(`No order_id found, updating only slot ${body.slot_id}`);
await supabase
.from("consulting_slots")
.update({ meet_link: meetLink })
.eq("id", body.slot_id);
}
// Update consulting_sessions with meet_link and event_id
log(`Updating session ${body.slot_id} with meet_link and calendar_event_id`);
await supabase
.from("consulting_sessions")
.update({
meet_link: meetLink,
calendar_event_id: eventDataResult.id
})
.eq("id", body.slot_id);
log("Successfully completed");
return new Response(
@@ -351,26 +339,15 @@ serve(async (req: Request): Promise<Response> => {
if (eventDataResult.hangoutLink) {
log(`Using hangoutLink: ${eventDataResult.hangoutLink}`);
// If this is part of a multi-slot order, update all slots with the same order_id
const { data: slotData } = await supabase
.from("consulting_slots")
.select("order_id")
.eq("id", body.slot_id)
.single();
if (slotData?.order_id) {
log(`Updating all slots in order ${slotData.order_id} with meet_link`);
await supabase
.from("consulting_slots")
.update({ meet_link: eventDataResult.hangoutLink })
.eq("order_id", slotData.order_id);
} else {
log(`No order_id found, updating only slot ${body.slot_id}`);
await supabase
.from("consulting_slots")
.update({ meet_link: eventDataResult.hangoutLink })
.eq("id", body.slot_id);
}
// Update consulting_sessions with meet_link and event_id
log(`Updating session ${body.slot_id} with meet_link and calendar_event_id`);
await supabase
.from("consulting_sessions")
.update({
meet_link: eventDataResult.hangoutLink,
calendar_event_id: eventDataResult.id
})
.eq("id", body.slot_id);
log("Successfully completed");
return new Response(

View File

@@ -1,132 +0,0 @@
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",
};
interface CreateMeetRequest {
slot_id: string;
date: string;
start_time: string;
end_time: string;
client_name: string;
client_email: string;
topic: string;
notes?: string;
}
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);
const body: CreateMeetRequest = await req.json();
console.log("Creating meet link for slot:", body.slot_id);
// Get platform settings for Google Calendar ID
const { data: settings } = await supabase
.from("platform_settings")
.select("integration_google_calendar_id, brand_name")
.single();
const calendarId = settings?.integration_google_calendar_id;
const brandName = settings?.brand_name || "LearnHub";
if (!calendarId) {
return new Response(
JSON.stringify({
success: false,
message: "Google Calendar ID belum dikonfigurasi di Pengaturan > Integrasi"
}),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// For now, this is a placeholder that returns a message
// In production, you would integrate with Google Calendar API via OAuth or service account
// Or call an n8n webhook to handle the calendar creation
const { data: integrationSettings } = await supabase
.from("platform_settings")
.select("integration_n8n_base_url, integration_n8n_test_mode")
.single();
if (integrationSettings?.integration_n8n_base_url) {
// Check if we're in test mode (controlled by the integration_n8n_test_mode setting)
const isTestMode = integrationSettings.integration_n8n_test_mode || false;
const webhookPath = isTestMode ? "/webhook-test/" : "/webhook/";
const n8nUrl = `${integrationSettings.integration_n8n_base_url}${webhookPath}create-meet`;
console.log(`Calling n8n webhook: ${n8nUrl} (Test mode: ${isTestMode})`);
try {
const n8nResponse = await fetch(n8nUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slot_id: body.slot_id,
date: body.date,
start_time: body.start_time,
end_time: body.end_time,
client_name: body.client_name,
client_email: body.client_email,
topic: body.topic,
notes: body.notes,
calendar_id: calendarId,
brand_name: brandName,
test_mode: isTestMode, // Add test_mode flag for n8n to use
}),
});
if (n8nResponse.ok) {
const result = await n8nResponse.json();
if (result.meet_link) {
// Update the slot with the meet link
await supabase
.from("consulting_slots")
.update({ meet_link: result.meet_link })
.eq("id", body.slot_id);
return new Response(
JSON.stringify({ success: true, meet_link: result.meet_link }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
} catch (n8nError) {
console.error("n8n webhook error:", n8nError);
}
}
// Fallback: Return instructions for manual setup
return new Response(
JSON.stringify({
success: false,
message: "Integrasi otomatis belum tersedia. Silakan buat link Meet secara manual atau konfigurasi n8n webhook di Pengaturan > Integrasi.",
manual_instructions: {
calendar_id: calendarId,
event_title: `Konsultasi: ${body.topic} - ${body.client_name}`,
event_date: body.date,
event_time: `${body.start_time} - ${body.end_time}`,
}
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error creating meet link:", error);
return new Response(
JSON.stringify({ success: false, message: error.message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});

View File

@@ -0,0 +1,193 @@
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",
};
interface GoogleOAuthConfig {
client_id: string;
client_secret: string;
refresh_token: string;
access_token?: string;
expires_at?: number;
}
interface DeleteEventRequest {
session_id: string;
}
// Function to get access token from refresh token (OAuth2)
async function getGoogleAccessToken(oauthConfig: GoogleOAuthConfig): Promise<{ access_token: string; expires_in: number }> {
try {
console.log("Refreshing access token for calendar event deletion...");
const tokenRequest = {
client_id: oauthConfig.client_id,
client_secret: oauthConfig.client_secret,
refresh_token: oauthConfig.refresh_token,
grant_type: "refresh_token",
};
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(tokenRequest),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = await response.json();
if (!data.access_token) {
throw new Error("No access token in response");
}
return {
access_token: data.access_token,
expires_in: data.expires_in || 3600
};
} catch (error: any) {
console.error("Error getting Google access token:", error);
throw error;
}
}
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const body: DeleteEventRequest = await req.json();
console.log("[DELETE-CALENDAR-EVENT] Deleting event for session:", body.session_id);
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get session data with calendar_event_id
const { data: session, error: sessionError } = await supabase
.from("consulting_sessions")
.select("id, calendar_event_id, user_id")
.eq("id", body.session_id)
.single();
if (sessionError || !session) {
console.error("[DELETE-CALENDAR-EVENT] Session not found:", sessionError);
return new Response(
JSON.stringify({ success: false, error: "Session not found" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!session.calendar_event_id) {
console.log("[DELETE-CALENDAR-EVENT] No calendar_event_id found, skipping deletion");
return new Response(
JSON.stringify({ success: true, message: "No calendar event to delete" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get OAuth config
const { data: settings } = await supabase
.from("platform_settings")
.select("integration_google_calendar_id, google_oauth_config")
.single();
const calendarId = settings?.integration_google_calendar_id;
const oauthConfigJson = settings?.google_oauth_config;
if (!calendarId || !oauthConfigJson) {
console.log("[DELETE-CALENDAR-EVENT] Calendar not configured, skipping deletion");
return new Response(
JSON.stringify({ success: true, message: "Calendar not configured" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Parse OAuth config
let oauthConfig: GoogleOAuthConfig;
try {
oauthConfig = JSON.parse(oauthConfigJson);
} catch (error) {
console.error("[DELETE-CALENDAR-EVENT] Failed to parse OAuth config");
return new Response(
JSON.stringify({ success: false, error: "Invalid OAuth config" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get access token
let accessToken: string;
const now = Math.floor(Date.now() / 1000);
if (oauthConfig.access_token && oauthConfig.expires_at && oauthConfig.expires_at > now + 60) {
accessToken = oauthConfig.access_token;
} else {
const tokenData = await getGoogleAccessToken(oauthConfig);
accessToken = tokenData.access_token;
// Update cached token
const newExpiresAt = now + tokenData.expires_in;
const updatedConfig = {
...oauthConfig,
access_token: accessToken,
expires_at: newExpiresAt
};
await supabase
.from("platform_settings")
.update({ google_oauth_config: JSON.stringify(updatedConfig) })
.eq("id", settings.id);
}
// Delete event from Google Calendar
console.log(`[DELETE-CALENDAR-EVENT] Deleting event ${session.calendar_event_id} from calendar ${calendarId}`);
const deleteResponse = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(session.calendar_event_id)}`,
{
method: "DELETE",
headers: {
"Authorization": `Bearer ${accessToken}`,
},
}
);
if (!deleteResponse.ok) {
if (deleteResponse.status === 410) {
// Event already deleted (Gone)
console.log("[DELETE-CALENDAR-EVENT] Event already deleted (410)");
} else {
const errorText = await deleteResponse.text();
console.error("[DELETE-CALENDAR-EVENT] Failed to delete event:", errorText);
// Don't fail the operation, just log it
}
} else {
console.log("[DELETE-CALENDAR-EVENT] Event deleted successfully");
}
// Clear calendar_event_id from session
await supabase
.from("consulting_sessions")
.update({ calendar_event_id: null })
.eq("id", body.session_id);
return new Response(
JSON.stringify({ success: true, message: "Calendar event deleted" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("[DELETE-CALENDAR-EVENT] Error:", error);
return new Response(
JSON.stringify({ success: false, error: error.message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});

View File

@@ -30,7 +30,7 @@ serve(async (req: Request): Promise<Response> => {
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get full order details with items AND consulting slots
// Get full order details with items AND consulting sessions
// Use maybeSingle() in case there are no related records
const { data: order, error: orderError } = await supabase
.from("orders")
@@ -41,12 +41,13 @@ serve(async (req: Request): Promise<Response> => {
product_id,
product:products (title, type)
),
consulting_slots (
consulting_sessions (
id,
date,
session_date,
start_time,
end_time,
status
status,
topic_category
)
`)
.eq("id", order_id)
@@ -72,8 +73,8 @@ serve(async (req: Request): Promise<Response> => {
id: order.id,
payment_status: order.payment_status,
order_items_count: order.order_items?.length || 0,
consulting_slots_count: order.consulting_slots?.length || 0,
consulting_slots: order.consulting_slots
consulting_sessions_count: order.consulting_sessions?.length || 0,
consulting_sessions: order.consulting_sessions
}));
const userEmail = order.profiles?.email || "";
@@ -83,49 +84,45 @@ serve(async (req: Request): Promise<Response> => {
product: { title: string; type: string };
}>;
// Check if this is a consulting order by checking consulting_slots
const consultingSlots = order.consulting_slots as Array<{
// Check if this is a consulting order by checking consulting_sessions
const consultingSessions = order.consulting_sessions as Array<{
id: string;
date: string;
session_date: string;
start_time: string;
end_time: string;
status: string;
topic_category?: string;
meet_link?: string;
}>;
const isConsultingOrder = consultingSlots && consultingSlots.length > 0;
const isConsultingOrder = consultingSessions && consultingSessions.length > 0;
console.log("[HANDLE-PAID] isConsultingOrder:", isConsultingOrder, "consultingSlots:", consultingSlots);
console.log("[HANDLE-PAID] isConsultingOrder:", isConsultingOrder, "consultingSessions:", consultingSessions);
if (isConsultingOrder) {
console.log("[HANDLE-PAID] Consulting order detected, processing slots");
console.log("[HANDLE-PAID] Consulting order detected, processing sessions");
// Sort slots by start_time to ensure correct ordering
consultingSlots.sort((a, b) => a.start_time.localeCompare(b.start_time));
// Update consulting slots status from pending_payment to confirmed
// Update consulting sessions status from pending_payment to confirmed
const { error: updateError } = await supabase
.from("consulting_slots")
.from("consulting_sessions")
.update({ status: "confirmed" })
.eq("order_id", order_id)
.in("status", ["pending_payment"]);
console.log("[HANDLE-PAID] Slot update result:", { updateError, order_id });
console.log("[HANDLE-PAID] Session update result:", { updateError, order_id });
if (updateError) {
console.error("[HANDLE-PAID] Failed to update slots:", updateError);
console.error("[HANDLE-PAID] Failed to update sessions:", updateError);
}
if (consultingSlots && consultingSlots.length > 0) {
if (consultingSessions && consultingSessions.length > 0) {
try {
console.log("[HANDLE-PAID] Creating Google Meet for order:", order_id);
// Group slots by order - use first slot's start time and last slot's end time
const firstSlot = consultingSlots[0];
const lastSlot = consultingSlots[consultingSlots.length - 1];
const topic = "Konsultasi 1-on-1";
// Use the first session for Meet creation
const session = consultingSessions[0];
const topic = session.topic_category || "Konsultasi 1-on-1";
console.log("[HANDLE-PAID] Time slots:", consultingSlots.map(s => `${s.start_time}-${s.end_time}`).join(', '));
console.log("[HANDLE-PAID] Event will be:", `${firstSlot.start_time} - ${lastSlot.end_time}`);
console.log("[HANDLE-PAID] Session time:", `${session.start_time} - ${session.end_time}`);
const meetResponse = await fetch(
`${supabaseUrl}/functions/v1/create-google-meet-event`,
@@ -136,14 +133,14 @@ serve(async (req: Request): Promise<Response> => {
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
},
body: JSON.stringify({
slot_id: firstSlot.id, // Use first slot ID
date: firstSlot.date,
start_time: firstSlot.start_time,
end_time: lastSlot.end_time, // Use last slot's end time for continuous block
slot_id: session.id,
date: session.session_date,
start_time: session.start_time,
end_time: session.end_time,
client_name: userName,
client_email: userEmail,
topic: topic,
notes: `${consultingSlots.length} sesi: ${consultingSlots.map(s => s.start_time.substring(0, 5)).join(', ')}`,
notes: `Session ID: ${session.id}`,
}),
}
);
@@ -157,16 +154,16 @@ serve(async (req: Request): Promise<Response> => {
if (meetData.success) {
console.log("[HANDLE-PAID] Meet created:", meetData.meet_link);
// Update all slots with the same meet link
// Update session with meet link
const { error: updateError } = await supabase
.from("consulting_slots")
.from("consulting_sessions")
.update({ meet_link: meetData.meet_link })
.eq("order_id", order_id);
if (updateError) {
console.error("[HANDLE-PAID] Failed to update meet_link:", updateError);
} else {
console.log("[HANDLE-PAID] Meet link updated for all slots in order:", order_id);
console.log("[HANDLE-PAID] Meet link updated for session:", order_id);
}
} else {
console.error("[HANDLE-PAID] Meet creation returned success=false:", meetData);
@@ -182,7 +179,7 @@ serve(async (req: Request): Promise<Response> => {
}
}
// Send consulting notification with the consultingSlots data
// Send consulting notification with the consultingSessions data
await sendNotification(supabase, "consulting_scheduled", {
nama: userName,
email: userEmail,
@@ -190,14 +187,14 @@ serve(async (req: Request): Promise<Response> => {
tanggal_pesanan: new Date().toLocaleDateString("id-ID"),
total: `Rp ${order.total_amount.toLocaleString("id-ID")}`,
metode_pembayaran: order.payment_method || "Unknown",
tanggal_konsultasi: consultingSlots[0]?.date || "",
jam_konsultasi: consultingSlots.map(s => s.start_time.substring(0, 5)).join(", "),
link_meet: consultingSlots[0]?.meet_link || "Akan dikirim terpisah",
tanggal_konsultasi: consultingSessions[0]?.session_date || "",
jam_konsultasi: consultingSessions.map(s => `${s.start_time.substring(0, 5)} - ${s.end_time.substring(0, 5)}`).join(", "),
link_meet: consultingSessions[0]?.meet_link || "Akan dikirim terpisah",
event: "consulting_scheduled",
order_id,
user_id: order.user_id,
user_name: userName,
slots: consultingSlots,
slots: consultingSessions,
});
} else {
// Regular product order - grant access