From 5ab4e6b9742f0e149af7bd006a3dec1471974941 Mon Sep 17 00:00:00 2001 From: dwindown Date: Sun, 28 Dec 2025 13:54:16 +0700 Subject: [PATCH] Add calendar event lifecycle management and "Add to Calendar" feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CALENDAR_INTEGRATION.md | 444 ++++++++++++++++++ MIGRATION_GUIDE.md | 227 +++++++++ src/components/reviews/ConsultingHistory.tsx | 176 +++---- src/pages/ConsultingBooking.tsx | 59 ++- src/pages/admin/AdminConsulting.tsx | 388 +++++++-------- src/pages/member/OrderDetail.tsx | 92 +++- .../create-google-meet-event/index.ts | 59 +-- supabase/functions/create-meet-link/index.ts | 132 ------ .../functions/delete-calendar-event/index.ts | 193 ++++++++ supabase/functions/handle-order-paid/index.ts | 75 ++- .../20241228_add_calendar_event_id.sql | 12 + 11 files changed, 1303 insertions(+), 554 deletions(-) create mode 100644 CALENDAR_INTEGRATION.md create mode 100644 MIGRATION_GUIDE.md delete mode 100644 supabase/functions/create-meet-link/index.ts create mode 100644 supabase/functions/delete-calendar-event/index.ts create mode 100644 supabase/migrations/20241228_add_calendar_event_id.sql diff --git a/CALENDAR_INTEGRATION.md b/CALENDAR_INTEGRATION.md new file mode 100644 index 0000000..3a671c3 --- /dev/null +++ b/CALENDAR_INTEGRATION.md @@ -0,0 +1,444 @@ +# Calendar Event Management - Complete Implementation + +## Summary + +✅ **Google Calendar integration is now fully bidirectional:** +- ✅ Creates events when sessions are booked +- ✅ Stores Google Calendar event ID for tracking +- ✅ Deletes events when sessions are cancelled +- ✅ Members can add events to their own calendar with one click + +--- + +## What Was Fixed + +### 1. ✅ `create-google-meet-event` Updated to Use `consulting_sessions` +**File**: `supabase/functions/create-google-meet-event/index.ts` + +**Changes:** +- Removed old `consulting_slots` queries (lines 317-334, 355-373) +- Now updates `consulting_sessions` table instead +- Stores both `meet_link` AND `calendar_event_id` in the session +- Much simpler - just update one row per session + +**Before:** +```typescript +// Had to check order_id and update multiple slots +const { data: slotData } = await supabase + .from("consulting_slots") + .select("order_id") + .eq("id", body.slot_id) + .single(); + +if (slotData?.order_id) { + await supabase + .from("consulting_slots") + .update({ meet_link: meetLink }) + .eq("order_id", slotData.order_id); +} +``` + +**After:** +```typescript +// Just update the session directly +await supabase + .from("consulting_sessions") + .update({ + meet_link: meetLink, + calendar_event_id: eventDataResult.id // ← NEW: Store event ID! + }) + .eq("id", body.slot_id); +``` + +--- + +### 2. ✅ Database Migration - Add `calendar_event_id` Column +**File**: `supabase/migrations/20241228_add_calendar_event_id.sql` + +```sql +-- Add column to store Google Calendar event ID +ALTER TABLE consulting_sessions +ADD COLUMN calendar_event_id TEXT; + +-- Index for faster lookups +CREATE INDEX idx_consulting_sessions_calendar_event +ON consulting_sessions(calendar_event_id); + +COMMENT ON COLUMN consulting_sessions.calendar_event_id +IS 'Google Calendar event ID - used to delete events when sessions are cancelled/refunded'; +``` + +**What this does:** +- Stores the Google Calendar event ID for each consulting session +- Allows us to delete the event later when session is cancelled/refunded +- No more orphaned calendar events! + +--- + +### 3. ✅ New Edge Function: `delete-calendar-event` +**File**: `supabase/functions/delete-calendar-event/index.ts` + +**What it does:** +1. Takes a `session_id` as input +2. Retrieves the session's `calendar_event_id` +3. Uses Google Calendar API to DELETE the event +4. Clears the `calendar_event_id` from the database + +**API Usage:** +```typescript +await supabase.functions.invoke('delete-calendar-event', { + body: { session_id: 'session-uuid-here' } +}); +``` + +**Google Calendar API Call:** +```http +DELETE https://www.googleapis.com/calendar/v3/calendars/{calendarId}/events/{eventId} +Authorization: Bearer {access_token} +``` + +**Error Handling:** +- If event already deleted (410 Gone): Logs and continues +- If calendar not configured: Returns success (graceful degradation) +- If deletion fails: Logs error but doesn't block the operation + +--- + +### 4. ✅ Admin Panel Integration - Auto-Delete on Cancel +**File**: `src/pages/admin/AdminConsulting.tsx` + +**Changes:** +- Added `calendar_event_id` to `ConsultingSession` interface +- Updated `updateSessionStatus()` to call `delete-calendar-event` before cancelling +- Calendar events are automatically deleted when admin cancels a session + +**Code:** +```typescript +const updateSessionStatus = async (sessionId: string, newStatus: string) => { + // If cancelling and session has a calendar event, delete it first + if (newStatus === 'cancelled') { + const session = sessions.find(s => s.id === sessionId); + if (session?.calendar_event_id) { + try { + await supabase.functions.invoke('delete-calendar-event', { + body: { session_id: sessionId } + }); + } catch (err) { + console.log('Failed to delete calendar event:', err); + // Continue with status update even if calendar deletion fails + } + } + } + + // Update session status + const { error } = await supabase + .from('consulting_sessions') + .update({ status: newStatus }) + .eq('id', sessionId); + + if (!error) { + toast({ title: 'Berhasil', description: `Status diubah ke ${statusLabels[newStatus]?.label || newStatus}` }); + fetchSessions(); + } +}; +``` + +--- + +### 5. ✅ "Add to Calendar" Button for Members +**Files**: `src/pages/member/OrderDetail.tsx`, `src/components/reviews/ConsultingHistory.tsx` + +**What it does:** +- Allows members to add consulting sessions to their own Google Calendar +- Uses Google Calendar's public URL format (no OAuth required) +- One-click addition with event details pre-filled + +**How it works:** + +```typescript +// Generate Google Calendar link +const generateCalendarLink = (session: ConsultingSession) => { + if (!session.meet_link) return null; + + const startDate = new Date(`${session.session_date}T${session.start_time}`); + const endDate = new Date(`${session.session_date}T${session.end_time}`); + + // Format dates for Google Calendar (YYYYMMDDTHHmmssZ) + const formatDate = (date: Date) => { + return date.toISOString().replace(/-|:|\.\d\d\d/g, ''); + }; + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: `Konsultasi: ${session.topic_category || 'Sesi Konsultasi'}`, + dates: `${formatDate(startDate)}/${formatDate(endDate)}`, + details: `Link Meet: ${session.meet_link}`, + location: session.meet_link, + }); + + return `https://www.google.com/calendar/render?${params.toString()}`; +}; +``` + +**UI Implementation:** + +**OrderDetail.tsx** (after meet link): +```tsx +{consultingSlots[0]?.meet_link && ( +
+
+

Google Meet Link

+ + {consultingSlots[0].meet_link.substring(0, 40)}... + +
+ +
+)} +``` + +**ConsultingHistory.tsx** (upcoming sessions): +```tsx +{session.meet_link && ( + <> + + + +)} +``` + +**Google Calendar URL Format:** + +``` +https://www.google.com/calendar/render?action=TEMPLATE&text=Title&dates=StartDate/EndDate&details=Description&location=Location +``` + +**Benefits:** +- ✅ No OAuth required for users +- ✅ Works with any calendar app that supports Google Calendar links +- ✅ Pre-fills all event details (title, time, description, location) +- ✅ Opens in user's default calendar app +- ✅ One-click addition + +--- + +## Event Flow + +### Booking Flow (Create) +``` +User books consulting + ↓ +ConsultingBooking.tsx creates session in DB + ↓ +handle-order-paid edge function triggered + ↓ +Calls create-google-meet-event + ↓ +Creates event in Google Calendar + ↓ +Returns meet_link + event_id + ↓ +Updates consulting_sessions: + - meet_link = "https://meet.google.com/xxx-xxx" + - calendar_event_id = "event_id_from_google" +``` + +### Cancellation Flow (Delete) +``` +Admin cancels session in AdminConsulting.tsx + ↓ +Calls delete-calendar-event edge function + ↓ +Retrieves calendar_event_id from consulting_sessions + ↓ +Calls Google Calendar API to DELETE event + ↓ +Clears calendar_event_id from database + ↓ +Updates session status to 'cancelled' +``` + +--- + +## Google Calendar API Response + +When an event is created, Google returns: + +```json +{ + "id": "a1b2c3d4e5f6g7h8i9j0", // ← Calendar event ID + "status": "confirmed", + "htmlLink": "https://www.google.com/calendar/event?eid=a1b2c3d4...", + "created": "2024-12-28T10:00:00.000Z", + "updated": "2024-12-28T10:00:00.000Z", + "summary": "Konsultasi: Career Guidance - John Doe", + "description": "Client: john@example.com\n\nNotes: ...\n\nSlot ID: uuid-here", + "start": { + "dateTime": "2025-01-15T09:00:00+07:00", + "timeZone": "Asia/Jakarta" + }, + "end": { + "dateTime": "2025-01-15T12:00:00+07:00", + "timeZone": "Asia/Jakarta" + }, + "conferenceData": { + "entryPoints": [ + { + "entryPointType": "video", + "uri": "https://meet.google.com/abc-defg-hij", // ← Meet link + "label": "meet.google.com" + } + ] + } +} +``` + +**Important fields:** +- `id` - Event ID (stored in `calendar_event_id`) +- `conferenceData.entryPoints[0].uri` - Meet link (stored in `meet_link`) + +--- + +## Testing Checklist + +### ✅ Test Event Creation +- [ ] Book a consulting session +- [ ] Verify Google Calendar event is created +- [ ] Verify `meet_link` is saved to `consulting_sessions` +- [ ] Verify `calendar_event_id` is saved to `consulting_sessions` + +### ✅ Test Event Deletion +- [ ] Cancel a session in admin panel +- [ ] Verify Google Calendar event is deleted +- [ ] Verify `calendar_event_id` is cleared from database +- [ ] Verify session status is set to 'cancelled' + +### ✅ Test Edge Cases +- [ ] Cancel session without calendar event (should not fail) +- [ ] Cancel session when Google Calendar not configured (should not fail) +- [ ] Delete already-deleted event (410 Gone - should handle gracefully) + +--- + +## SQL Migration Steps + +Run this migration to add the `calendar_event_id` column: + +```bash +# Connect to your Supabase database +psql -h db.xxx.supabase.co -U postgres -d postgres + +# Or use Supabase Dashboard: +# SQL Editor → Paste and Run +``` + +```sql +-- Add calendar_event_id column +ALTER TABLE consulting_sessions +ADD COLUMN calendar_event_id TEXT; + +-- Create index +CREATE INDEX idx_consulting_sessions_calendar_event +ON consulting_sessions(calendar_event_id); + +-- Verify +SELECT + id, + session_date, + start_time, + end_time, + meet_link, + calendar_event_id +FROM consulting_sessions; +``` + +--- + +## Deploy Edge Functions + +```bash +# Deploy the updated create-google-meet-event function +supabase functions deploy create-google-meet-event + +# Deploy the new delete-calendar-event function +supabase functions deploy delete-calendar-event +``` + +Or use the Supabase Dashboard: +- Edge Functions → Select function → Deploy + +--- + +## Future Enhancements + +### Option 1: Auto-reschedule +If session date/time changes: +- Delete old event +- Create new event with updated time +- Update `calendar_event_id` in database + +### Option 2: Batch Delete +If multiple sessions are cancelled (e.g., order refund): +- Get all `calendar_event_id`s for the order +- Delete all events in batch +- Clear all `calendar_event_id`s + +### Option 3: Event Sync +Periodic sync to ensure database and calendar are in sync: +- Check all upcoming sessions +- Verify events exist in Google Calendar +- Recreate if missing (with warning) + +--- + +## Troubleshooting + +### Issue: Event not deleted when session cancelled +**Check:** +1. Does the session have `calendar_event_id`? + ```sql + SELECT id, calendar_event_id FROM consulting_sessions WHERE id = 'session-uuid'; + ``` +2. Are the OAuth credentials valid? + ```sql + SELECT google_oauth_config FROM platform_settings; + ``` +3. Check the edge function logs: + ```bash + supabase functions logs delete-calendar-event + ``` + +### Issue: "Token exchange failed" +**Solution:** Refresh OAuth credentials in settings +- Go to: Admin → Settings → Integrations +- Update `google_oauth_config` with new `refresh_token` + +### Issue: Event already deleted (410 Gone) +**This is normal!** The function handles this gracefully and continues. + +--- + +## Files Modified + +1. ✅ `supabase/functions/create-google-meet-event/index.ts` - Use consulting_sessions, store calendar_event_id +2. ✅ `supabase/migrations/20241228_add_calendar_event_id.sql` - Add calendar_event_id column +3. ✅ `supabase/functions/delete-calendar-event/index.ts` - NEW: Delete calendar events +4. ✅ `src/pages/admin/AdminConsulting.tsx` - Auto-delete on cancel, add calendar_event_id to interface +5. ✅ `src/pages/member/OrderDetail.tsx` - Add "Tambah ke Kalender" button +6. ✅ `src/components/reviews/ConsultingHistory.tsx` - Add "Tambah ke Kalender" button + +--- + +**All set!** 🎉 +Your consulting sessions now have full calendar lifecycle management. diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..d2c017f --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,227 @@ +# Consulting Slots Migration - Code Updates Summary + +## ✅ Completed Files + +### 1. src/pages/ConsultingBooking.tsx ✅ +- Updated interface: `ConfirmedSlot` → `ConfirmedSession` with `session_date` field +- Updated `fetchConfirmedSlots()` to query `consulting_sessions` table +- Updated slot creation logic to: + - Create ONE `consulting_sessions` row with session-level data + - Create MULTIPLE `consulting_time_slots` rows for each 45-min block +- Conflict checking logic already compatible (uses `start_time`/`end_time` fields) + +### 2. supabase/functions/create-meet-link/index.ts ✅ +- Changed update query from `consulting_slots` to `consulting_sessions` +- Updates meet_link once per session instead of once per slot + +## ⏳ In Progress + +### 3. src/pages/admin/AdminConsulting.tsx (PARTIAL) +**Updated:** +- Interface: `ConsultingSlot` → `ConsultingSession` +- State: `slots` → `sessions`, `selectedSlot` → `selectedSession` +- `fetchSessions()` - now queries `consulting_sessions` with profiles join +- `openMeetDialog()` - uses session parameter +- `saveMeetLink()` - updates `consulting_sessions` table +- `createMeetLink()` - uses session fields (`session_date`, etc.) +- `updateSessionStatus()` - renamed from `updateSlotStatus()` +- Filtering logic - simplified (no grouping needed) +- Stats sections - use `sessions` arrays +- Today's Sessions Alert - uses `todaySessions` array + +**Still Needs Manual Update:** +Replace all remaining references in the table rendering sections (lines ~428-end): + +```typescript +// FIND AND REPLACE THESE PATTERNS: + +// 1. Tabs list: +Mendatang ({upcomingOrders.length}) +Riwayat ({pastOrders.length}) +// CHANGE TO: +Mendatang ({upcomingSessions.length}) +Riwayat ({pastSessions.length}) + +// 2. Desktop table - upcoming: +{upcomingOrders.map((order) => { + const firstSlot = order.slots[0]; + const lastSlot = order.slots[order.slots.length - 1]; + const sessionCount = order.slots.length; + return ( + +// CHANGE TO: +{upcomingSessions.map((session) => { + return ( + + +// 3. Date cell: +{format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })} +{isToday(parseISO(firstSlot.date)) && Hari Ini} +{isTomorrow(parseISO(firstSlot.date)) && Besok} +// CHANGE TO: +{format(parseISO(session.session_date), 'd MMM yyyy', { locale: id })} +{isToday(parseISO(session.session_date)) && Hari Ini} +{isTomorrow(parseISO(session.session_date)) && Besok} + +// 4. Time cell: +
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}
+{sessionCount > 1 && ( +
{sessionCount} sesi
+)} +// CHANGE TO: +
{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}
+{session.total_blocks > 1 && ( +
{session.total_blocks} blok
+)} + +// 5. Client cell: +

{order.profile?.name || '-'}

+

{order.profile?.email}

+// CHANGE TO: +

{session.profiles?.name || '-'}

+

{session.profiles?.email}

+ +// 6. Category cell: +{firstSlot.topic_category} +// CHANGE TO: +{session.topic_category} + +// 7. Status cell: + + {statusLabels[firstSlot.status]?.label || firstSlot.status} + +// CHANGE TO: + + {statusLabels[session.status]?.label || session.status} + + +// 8. Meet link cell: +{order.meetLink ? ( + +// CHANGE TO: +{session.meet_link ? ( + + +// 9. Action buttons: +onClick={() => openMeetDialog(firstSlot)} +onClick={() => updateSlotStatus(firstSlot.id, 'completed')} +onClick={() => updateSlotStatus(firstSlot.id, 'cancelled')} +// CHANGE TO: +onClick={() => openMeetDialog(session)} +onClick={() => updateSessionStatus(session.id, 'completed')} +onClick={() => updateSessionStatus(session.id, 'cancelled')} + +// 10. Empty state: + + Tidak ada jadwal mendatang + +// CHANGE TO (same colSpan): + + Tidak ada jadwal mendatang + + +// 11. Mobile card layout - same pattern as desktop: +{upcomingOrders.map((order) => { + const firstSlot = order.slots[0]; +// CHANGE TO: +{upcomingSessions.map((session) => { + +// Then replace all: +// order.orderId → session.id +// order.slots[0] / firstSlot → session +// order.slots[order.slots.length - 1] / lastSlot → session +// order.profile → session.profiles +// order.meetLink → session.meet_link +// sessionCount → session.total_blocks + +// 12. Past sessions tab - same pattern: +{pastOrders.slice(0, 20).map((order) => { +// CHANGE TO: +{pastSessions.slice(0, 20).map((session) => { + +// 13. Dialog - selectedSlot references: +{selectedSlot && ( +
+

Tanggal: {format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id })}

+

Waktu: {selectedSlot.start_time.substring(0, 5)} - {selectedSlot.end_time.substring(0, 5)}

+

Klien: {selectedSlot.profiles?.name}

+

Topik: {selectedSlot.topic_category}

+ {selectedSlot.notes &&

Catatan: {selectedSlot.notes}

} +
+)} +// CHANGE TO: +{selectedSession && ( +
+

Tanggal: {format(parseISO(selectedSession.session_date), 'd MMMM yyyy', { locale: id })}

+

Waktu: {selectedSession.start_time.substring(0, 5)} - {selectedSession.end_time.substring(0, 5)}

+

Klien: {selectedSession.profiles?.name}

+

Topik: {selectedSession.topic_category}

+ {selectedSession.notes &&

Catatan: {selectedSession.notes}

} +
+)} +``` + +## 📋 Remaining Files to Update + +### 4. src/components/reviews/ConsultingHistory.tsx +**Changes needed:** +- Change query from `consulting_slots` to `consulting_sessions` +- Remove grouping logic (no longer needed) +- Update interface to use `ConsultingSession` with fields: + - `session_date` (instead of `date`) + - `total_duration_minutes` + - `total_blocks` + - `total_price` +- Update all field references in rendering + +### 5. src/pages/member/OrderDetail.tsx +**Changes needed:** +- Find consulting_slots query and change to consulting_sessions +- Update join to include session data +- Update field names in rendering (date → session_date, etc.) + +### 6. supabase/functions/handle-order-paid/index.ts +**Changes needed:** +- Change status update from `consulting_slots` to `consulting_sessions` +- Update logic to set `status = 'confirmed'` for session + +--- + +## Quick Reference: Field Name Changes + +| Old (consulting_slots) | New (consulting_sessions) | +|------------------------|---------------------------| +| `date` | `session_date` | +| `slots` array | Single `session` object | +| `slots[0]` / `firstSlot` | `session` | +| `slots[length-1]` / `lastSlot` | `session` | +| `order_id` (for grouping) | `id` (session ID) | +| `meet_link` (per slot) | `meet_link` (per session) | +| Row count × 45min | `total_duration_minutes` | +| Row count | `total_blocks` | + +--- + +## Testing Checklist + +After migration: +- [ ] Test booking flow - creates session + time slots +- [ ] Test availability checking - uses sessions table +- [ ] Test meet link creation - updates session +- [ ] Test admin consulting page - displays sessions +- [ ] Test user consulting history - displays sessions +- [ ] Test order detail - shows consulting session info +- [ ] Test payment confirmation - updates session status + +--- + +## Rollback Plan (if needed) + +If issues arise: +1. Restore old table: `ALTER TABLE consulting_slots RENAME TO consulting_slots_backup;` +2. Create view: `CREATE VIEW consulting_slots AS SELECT ... FROM consulting_sessions JOIN consulting_time_slots;` +3. Revert code changes from git + +--- + +**Note:** All SQL tables should already be created. This document covers code changes only. diff --git a/src/components/reviews/ConsultingHistory.tsx b/src/components/reviews/ConsultingHistory.tsx index cb2def8..739756b 100644 --- a/src/components/reviews/ConsultingHistory.tsx +++ b/src/components/reviews/ConsultingHistory.tsx @@ -4,27 +4,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { Video, Calendar, Clock, Star, MessageSquare, CheckCircle } from 'lucide-react'; +import { Video, Calendar, Clock, Star, MessageSquare, CheckCircle, Download } from 'lucide-react'; import { format } from 'date-fns'; import { id } from 'date-fns/locale'; import { ReviewModal } from './ReviewModal'; -interface ConsultingSlot { +interface ConsultingSession { id: string; - date: string; + session_date: string; start_time: string; end_time: string; status: string; topic_category: string | null; meet_link: string | null; order_id: string | null; -} - -interface GroupedOrder { - orderId: string | null; - slots: ConsultingSlot[]; - firstDate: string; - meetLink: string | null; + total_blocks: number; } interface ConsultingHistoryProps { @@ -32,7 +26,7 @@ interface ConsultingHistoryProps { } export function ConsultingHistory({ userId }: ConsultingHistoryProps) { - const [slots, setSlots] = useState([]); + const [sessions, setSessions] = useState([]); const [reviewedOrderIds, setReviewedOrderIds] = useState>(new Set()); const [loading, setLoading] = useState(true); const [reviewModal, setReviewModal] = useState<{ @@ -46,18 +40,18 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { }, [userId]); const fetchData = async () => { - // Fetch consulting slots - const { data: slotsData } = await supabase - .from('consulting_slots') - .select('id, date, start_time, end_time, status, topic_category, meet_link, order_id') + // Fetch consulting sessions + const { data: sessionsData } = await supabase + .from('consulting_sessions') + .select('id, session_date, start_time, end_time, status, topic_category, meet_link, order_id, total_blocks') .eq('user_id', userId) - .order('date', { ascending: false }); + .order('session_date', { ascending: false }); - if (slotsData) { - setSlots(slotsData); + if (sessionsData) { + setSessions(sessionsData); // Check which orders have been reviewed - const orderIds = slotsData + const orderIds = sessionsData .filter(s => s.order_id) .map(s => s.order_id as string); @@ -78,26 +72,6 @@ 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': @@ -113,14 +87,12 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { } }; - 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)}`; + const openReviewModal = (session: ConsultingSession) => { + const dateLabel = format(new Date(session.session_date), 'd MMMM yyyy', { locale: id }); + const timeLabel = `${session.start_time.substring(0, 5)} - ${session.end_time.substring(0, 5)}`; setReviewModal({ open: true, - orderId: order.orderId, + orderId: session.order_id, label: `Sesi konsultasi ${dateLabel}, ${timeLabel}`, }); }; @@ -132,8 +104,31 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { } }; - const doneOrders = groupedOrders.filter(o => o.slots.every(s => s.status === 'done')); - const upcomingOrders = groupedOrders.filter(o => o.slots.some(s => s.status === 'confirmed')); + // Generate Google Calendar link for adding to user's calendar + const generateCalendarLink = (session: ConsultingSession) => { + if (!session.meet_link) return null; + + const startDate = new Date(`${session.session_date}T${session.start_time}`); + const endDate = new Date(`${session.session_date}T${session.end_time}`); + + // Format dates for Google Calendar (YYYYMMDDTHHmmssZ) + const formatDate = (date: Date) => { + return date.toISOString().replace(/-|:|\.\d\d\d/g, ''); + }; + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: `Konsultasi: ${session.topic_category || 'Sesi Konsultasi'}`, + dates: `${formatDate(startDate)}/${formatDate(endDate)}`, + details: `Link Meet: ${session.meet_link}`, + location: session.meet_link, + }); + + return `https://www.google.com/calendar/render?${params.toString()}`; + }; + + const doneSessions = sessions.filter(s => s.status === 'done' || s.status === 'completed'); + const upcomingSessions = sessions.filter(s => s.status === 'confirmed'); if (loading) { return ( @@ -152,7 +147,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { ); } - if (slots.length === 0) { + if (sessions.length === 0) { return null; } @@ -167,68 +162,79 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { {/* Upcoming sessions */} - {upcomingOrders.length > 0 && ( + {upcomingSessions.length > 0 && (

Sesi Mendatang

- {upcomingOrders.map((order) => { - const firstSlot = order.slots[0]; - const lastSlot = order.slots[order.slots.length - 1]; - return ( -
- )} {/* Completed sessions */} - {doneOrders.length > 0 && ( + {doneSessions.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; + {doneSessions.map((session) => { + const hasReviewed = session.order_id ? reviewedOrderIds.has(session.order_id) : false; return ( -
+

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

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

- {getStatusBadge(firstSlot.status)} + {getStatusBadge(session.status)} {hasReviewed ? ( @@ -238,7 +244,7 @@ export function ConsultingHistory({ userId }: ConsultingHistoryProps) { - )} -
- ); - })} + {todaySessions.map((session) => ( +
+ + {session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)} • {session.profiles?.name || 'N/A'} ({session.topic_category}) + + {session.meet_link ? ( + + Join + + ) : ( + + )} +
+ ))}
@@ -366,25 +328,25 @@ export default function AdminConsulting() {
-
{todayOrders.length}
+
{todaySessions.length}

Hari Ini

-
{upcomingOrders.filter(o => o.slots.some(s => s.status === 'confirmed')).length}
+
{upcomingSessions.filter(s => s.status === 'confirmed').length}

Dikonfirmasi

-
{upcomingOrders.filter(o => !o.meetLink && o.slots.some(s => s.status === 'confirmed')).length}
+
{upcomingSessions.filter(s => !s.meet_link && s.status === 'confirmed').length}

Perlu Link Meet

-
{pastOrders.filter(o => o.slots.every(s => s.status === 'completed')).length}
+
{pastSessions.filter(s => s.status === 'completed').length}

Selesai

@@ -471,7 +433,7 @@ export default function AdminConsulting() { {/* Result count */}

- Menampilkan {filteredGroupedOrders.length} dari {groupedOrders.length} jadwal konsultasi + Menampilkan {filteredSessions.length} dari {sessions.length} jadwal konsultasi

@@ -480,8 +442,8 @@ export default function AdminConsulting() { {/* Tabs */} - Mendatang ({upcomingOrders.length}) - Riwayat ({pastOrders.length}) + Mendatang ({upcomingSessions.length}) + Riwayat ({pastSessions.length}) @@ -502,45 +464,42 @@ export default function AdminConsulting() { - {upcomingOrders.map((order) => { - const firstSlot = order.slots[0]; - const lastSlot = order.slots[order.slots.length - 1]; - const sessionCount = order.slots.length; + {upcomingSessions.map((session) => { return ( - +
- {format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })} - {isToday(parseISO(firstSlot.date)) && Hari Ini} - {isTomorrow(parseISO(firstSlot.date)) && Besok} + {format(parseISO(session.session_date), 'd MMM yyyy', { locale: id })} + {isToday(parseISO(session.session_date)) && Hari Ini} + {isTomorrow(parseISO(session.session_date)) && Besok}
-
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}
- {sessionCount > 1 && ( -
{sessionCount} sesi
+
{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}
+ {session.total_blocks > 1 && ( +
{session.total_blocks} sesi
)}
-

{order.profile?.name || '-'}

-

{order.profile?.email}

+

{session.profiles?.name || '-'}

+

{session.profiles?.email}

- {firstSlot.topic_category} + {session.topic_category} - - {statusLabels[firstSlot.status]?.label || firstSlot.status} + + {statusLabels[session.status]?.label || session.status} - {order.meetLink ? ( + {session.meet_link ? ( - {firstSlot.status === 'confirmed' && ( + {session.status === 'confirmed' && ( <>
); })} - {upcomingOrders.length === 0 && ( + {upcomingSessions.length === 0 && (
Tidak ada jadwal mendatang
@@ -711,32 +667,29 @@ export default function AdminConsulting() { - {pastOrders.slice(0, 20).map((order) => { - const firstSlot = order.slots[0]; - const lastSlot = order.slots[order.slots.length - 1]; - const sessionCount = order.slots.length; + {pastSessions.slice(0, 20).map((session) => { return ( - - {format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })} + + {format(parseISO(session.session_date), 'd MMM yyyy', { locale: id })}
-
{firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)}
- {sessionCount > 1 && ( -
{sessionCount} sesi
+
{session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)}
+ {session.total_blocks > 1 && ( +
{session.total_blocks} sesi
)}
- {order.profile?.name || '-'} - {firstSlot.topic_category} + {session.profiles?.name || '-'} + {session.topic_category} - - {statusLabels[firstSlot.status]?.label || firstSlot.status} + + {statusLabels[session.status]?.label || session.status}
); })} - {pastOrders.length === 0 && ( + {pastSessions.length === 0 && ( Belum ada riwayat konsultasi @@ -751,44 +704,41 @@ export default function AdminConsulting() { {/* Mobile Card Layout */}
- {pastOrders.slice(0, 20).map((order) => { - const firstSlot = order.slots[0]; - const lastSlot = order.slots[order.slots.length - 1]; - const sessionCount = order.slots.length; + {pastSessions.slice(0, 20).map((session) => { return ( -
+

- {format(parseISO(firstSlot.date), 'd MMM yyyy', { locale: id })} + {format(parseISO(session.session_date), 'd MMM yyyy', { locale: id })}

- {firstSlot.start_time.substring(0, 5)} - {lastSlot.end_time.substring(0, 5)} - {sessionCount > 1 && ( - ({sessionCount} sesi) + {session.start_time.substring(0, 5)} - {session.end_time.substring(0, 5)} + {session.total_blocks > 1 && ( + ({session.total_blocks} sesi) )}

- - {statusLabels[firstSlot.status]?.label || firstSlot.status} + + {statusLabels[session.status]?.label || session.status}
Klien: - {order.profile?.name || '-'} + {session.profiles?.name || '-'}
Kategori: - {firstSlot.topic_category} + {session.topic_category}
); })} - {pastOrders.length === 0 && ( + {pastSessions.length === 0 && (
Belum ada riwayat konsultasi
@@ -813,13 +763,13 @@ export default function AdminConsulting() {
- {selectedSlot && ( + {selectedSession && (
-

Tanggal: {format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id })}

-

Waktu: {selectedSlot.start_time.substring(0, 5)} - {selectedSlot.end_time.substring(0, 5)}

-

Klien: {selectedSlot.profiles?.name}

-

Topik: {selectedSlot.topic_category}

- {selectedSlot.notes &&

Catatan: {selectedSlot.notes}

} +

Tanggal: {format(parseISO(selectedSession.session_date), 'd MMMM yyyy', { locale: id })}

+

Waktu: {selectedSession.start_time.substring(0, 5)} - {selectedSession.end_time.substring(0, 5)}

+

Klien: {selectedSession.profiles?.name}

+

Topik: {selectedSession.topic_category}

+ {selectedSession.notes &&

Catatan: {selectedSession.notes}

}
)} @@ -833,9 +783,9 @@ export default function AdminConsulting() {
-
- + {!settings.integration_n8n_base_url && (

Tip: Konfigurasi webhook di Pengaturan → Integrasi untuk pembuatan otomatis diff --git a/src/pages/member/OrderDetail.tsx b/src/pages/member/OrderDetail.tsx index 5f7a00a..2110fb5 100644 --- a/src/pages/member/OrderDetail.tsx +++ b/src/pages/member/OrderDetail.tsx @@ -10,7 +10,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { formatIDR, formatDate } from "@/lib/format"; -import { ArrowLeft, Package, CreditCard, Calendar, AlertCircle, Video, Clock, RefreshCw } from "lucide-react"; +import { ArrowLeft, Package, CreditCard, Calendar as CalendarIcon, AlertCircle, Video, Clock, RefreshCw, Download } from "lucide-react"; import { QRCodeSVG } from "qrcode.react"; import { getPaymentStatusLabel, getPaymentStatusColor, getProductTypeLabel } from "@/lib/statusHelpers"; @@ -44,11 +44,13 @@ interface Order { interface ConsultingSlot { id: string; - date: string; + session_date: string; start_time: string; end_time: string; status: string; meet_link?: string; + topic_category?: string; + notes?: string; } export default function OrderDetail() { @@ -123,15 +125,15 @@ export default function OrderDetail() { } else { setOrder(data); - // Always fetch consulting slots for this order (consulting orders don't have order_items) - const { data: slots } = await supabase - .from("consulting_slots") + // Always fetch consulting sessions for this order (consulting orders don't have order_items) + const { data: sessions } = await supabase + .from("consulting_sessions") .select("*") .eq("order_id", id) - .order("date", { ascending: true }); + .order("session_date", { ascending: true }); - if (slots && slots.length > 0) { - setConsultingSlots(slots as ConsultingSlot[]); + if (sessions && sessions.length > 0) { + setConsultingSlots(sessions as ConsultingSlot[]); } } @@ -246,6 +248,29 @@ export default function OrderDetail() { } }; + // Generate Google Calendar link for adding to user's calendar + const generateCalendarLink = (session: ConsultingSlot) => { + if (!session.meet_link) return null; + + const startDate = new Date(`${session.session_date}T${session.start_time}`); + const endDate = new Date(`${session.session_date}T${session.end_time}`); + + // Format dates for Google Calendar (YYYYMMDDTHHmmssZ) + const formatDate = (date: Date) => { + return date.toISOString().replace(/-|:|\.\d\d\d/g, ''); + }; + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: `Konsultasi: ${session.topic_category || 'Sesi Konsultasi'}`, + dates: `${formatDate(startDate)}/${formatDate(endDate)}`, + details: `Link Meet: ${session.meet_link}${session.notes ? `\n\nCatatan: ${session.notes}` : ''}`, + location: session.meet_link, + }); + + return `https://www.google.com/calendar/render?${params.toString()}`; + }; + if (authLoading || loading) { return ( @@ -491,7 +516,7 @@ export default function OrderDetail() { {/* Smart Item/Service Display */} {consultingSlots.length > 0 ? ( - // === Consulting Orders (NO order_items, has consulting_slots) === + // === Consulting Orders (NO order_items, has consulting_sessions) === <> @@ -507,17 +532,14 @@ export default function OrderDetail() {

Waktu Konsultasi

- {consultingSlots[0].start_time.substring(0,5)} - {consultingSlots[consultingSlots.length-1].end_time.substring(0,5)} -

-

- {consultingSlots.length} blok ({consultingSlots.length * 45} menit) + {consultingSlots[0].start_time.substring(0,5)} - {consultingSlots[0].end_time.substring(0,5)}

Tanggal

- {new Date(consultingSlots[0].date).toLocaleDateString("id-ID", { + {new Date(consultingSlots[0].session_date).toLocaleDateString("id-ID", { weekday: "long", year: "numeric", month: "long", @@ -526,17 +548,41 @@ export default function OrderDetail() {

- {consultingSlots[0]?.meet_link && ( + {consultingSlots[0]?.topic_category && (
+ )} + + {consultingSlots[0]?.meet_link && ( + )}
diff --git a/supabase/functions/create-google-meet-event/index.ts b/supabase/functions/create-google-meet-event/index.ts index 3f4fef3..7e0cacc 100644 --- a/supabase/functions/create-google-meet-event/index.ts +++ b/supabase/functions/create-google-meet-event/index.ts @@ -311,27 +311,15 @@ serve(async (req: Request): Promise => { 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 => { 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( diff --git a/supabase/functions/create-meet-link/index.ts b/supabase/functions/create-meet-link/index.ts deleted file mode 100644 index 1becdd0..0000000 --- a/supabase/functions/create-meet-link/index.ts +++ /dev/null @@ -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 => { - 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" } } - ); - } -}); diff --git a/supabase/functions/delete-calendar-event/index.ts b/supabase/functions/delete-calendar-event/index.ts new file mode 100644 index 0000000..b52e090 --- /dev/null +++ b/supabase/functions/delete-calendar-event/index.ts @@ -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 => { + 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" } } + ); + } +}); diff --git a/supabase/functions/handle-order-paid/index.ts b/supabase/functions/handle-order-paid/index.ts index 6a40035..fbd6bb6 100644 --- a/supabase/functions/handle-order-paid/index.ts +++ b/supabase/functions/handle-order-paid/index.ts @@ -30,7 +30,7 @@ serve(async (req: Request): Promise => { 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 => { 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 => { 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 => { 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 => { "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 => { 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 => { } } - // 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 => { 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 diff --git a/supabase/migrations/20241228_add_calendar_event_id.sql b/supabase/migrations/20241228_add_calendar_event_id.sql new file mode 100644 index 0000000..e2509f2 --- /dev/null +++ b/supabase/migrations/20241228_add_calendar_event_id.sql @@ -0,0 +1,12 @@ +-- Add calendar_event_id column to consulting_sessions +-- This stores the Google Calendar event ID for later deletion + +ALTER TABLE consulting_sessions +ADD COLUMN calendar_event_id TEXT; + +-- Create index for faster lookups +CREATE INDEX idx_consulting_sessions_calendar_event +ON consulting_sessions(calendar_event_id); + +-- Add comment +COMMENT ON COLUMN consulting_sessions.calendar_event_id IS 'Google Calendar event ID - used to delete events when sessions are cancelled/refunded';