diff --git a/apps/db/supabase/migrations/20250620134409_add_workspace_calendar_sync_log.sql b/apps/db/supabase/migrations/20250620134409_add_workspace_calendar_sync_log.sql new file mode 100644 index 000000000..f1dd1ba13 --- /dev/null +++ b/apps/db/supabase/migrations/20250620134409_add_workspace_calendar_sync_log.sql @@ -0,0 +1,58 @@ +-- Add workspace_calendar_sync_log table to track calendar sync operations + +-- Create workspace_calendar_sync_log table +CREATE TABLE "public"."workspace_calendar_sync_log" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ws_id" uuid NOT NULL REFERENCES "workspaces"("id") ON DELETE CASCADE, + "google_account_email" text, + "sync_started_at" timestamp with time zone NOT NULL, + "sync_ended_at" timestamp with time zone, + "status" text NOT NULL, + "error_message" text, + "event_snapshot_before" jsonb NOT NULL, + "upserted_events" jsonb, + "deleted_events" jsonb, + "triggered_by" text NOT NULL, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); + +-- Enable Row Level Security +ALTER TABLE "public"."workspace_calendar_sync_log" ENABLE ROW LEVEL SECURITY; + +-- Create primary key +CREATE UNIQUE INDEX workspace_calendar_sync_log_pkey ON public.workspace_calendar_sync_log USING btree (id); +ALTER TABLE "public"."workspace_calendar_sync_log" ADD CONSTRAINT "workspace_calendar_sync_log_pkey" PRIMARY KEY using index "workspace_calendar_sync_log_pkey"; + +-- Create indexes for better query performance +CREATE INDEX workspace_calendar_sync_log_workspace_id_idx ON public.workspace_calendar_sync_log USING btree (ws_id); +CREATE INDEX workspace_calendar_sync_log_status_idx ON public.workspace_calendar_sync_log USING btree (status); +CREATE INDEX workspace_calendar_sync_log_sync_started_at_idx ON public.workspace_calendar_sync_log USING btree (sync_started_at); + +-- RLS Policies +-- Users can see sync logs for workspaces they are members of +CREATE POLICY "Users can view sync logs for their workspaces" +ON public.workspace_calendar_sync_log +FOR SELECT +TO authenticated +USING ( + EXISTS ( + SELECT 1 FROM public.workspace_members wm + WHERE wm.ws_id = ws_id + AND wm.user_id = auth.uid() + ) +); + +-- Add check constraint for status values +ALTER TABLE "public"."workspace_calendar_sync_log" +ADD CONSTRAINT "workspace_calendar_sync_log_status_check" +CHECK (status IN ('success', 'failed', 'in_progress', 'cancelled', 'partial_success')); + +-- Add check constraint for triggered_by values +ALTER TABLE "public"."workspace_calendar_sync_log" +ADD CONSTRAINT "workspace_calendar_sync_log_triggered_by_check" +CHECK (triggered_by IN ('active_sync', 'trigger_dot_dev', 'manual')); + +-- Add check constraint to ensure sync_ended_at is after sync_started_at when both are present +ALTER TABLE "public"."workspace_calendar_sync_log" +ADD CONSTRAINT "workspace_calendar_sync_log_timestamps_check" +CHECK (sync_ended_at IS NULL OR sync_ended_at >= sync_started_at); \ No newline at end of file diff --git a/packages/trigger/google-calendar-sync.ts b/packages/trigger/google-calendar-sync.ts index 7a58db914..5093a4e28 100644 --- a/packages/trigger/google-calendar-sync.ts +++ b/packages/trigger/google-calendar-sync.ts @@ -59,7 +59,85 @@ export const syncGoogleCalendarEvents = async () => { tokens.map((token) => token.ws_id) ); + const endSync = async ( + ws_id: string, + googleAccountEmail: string | null, + syncStartedAt: string, + syncEndedAt: string, + status: string, + errorMessage: string, + eventSnapshotBefore: WorkspaceCalendarEvent[], + upsertedEvents: WorkspaceCalendarEvent[], + deletedEvents: WorkspaceCalendarEvent[] + ) => { + await supabase.from('workspace_calendar_sync_log').insert({ + ws_id, + google_account_email: googleAccountEmail, + sync_started_at: syncStartedAt, + sync_ended_at: syncEndedAt, + status: status, + error_message: errorMessage, + event_snapshot_before: eventSnapshotBefore, + upserted_events: upsertedEvents, + deleted_events: deletedEvents, + triggered_by: 'trigger_dot_dev', + }); + }; + for (const token of tokens || []) { + const syncStartedAt = dayjs().toISOString(); + let googleAccountEmail: string | null = null; + + const auth = getGoogleAuthClient(token); + + try { + // get google account email from google api + const googleAccount = await auth.getTokenInfo( + token.access_token as string + ); + googleAccountEmail = googleAccount.email || null; + console.log('googleAccountEmail', googleAccountEmail); + } catch (error) { + console.error('Error fetching google account email:', error); + await endSync( + token.ws_id, + null, + syncStartedAt, + dayjs().toISOString(), + 'failed', + 'Error fetching google account email: ' + error, + [], + [], + [] + ); + continue; + } + + // get events before sync + const { data: eventsBeforeSync, error: errorEventsBeforeSync } = + await supabase + .from('workspace_calendar_events') + .select('*') + .eq('ws_id', token.ws_id) + .not('google_event_id', 'is', null); + if (errorEventsBeforeSync) { + console.error( + 'Error fetching events before sync:', + errorEventsBeforeSync + ); + await endSync( + token.ws_id, + googleAccountEmail, + syncStartedAt, + dayjs().toISOString(), + 'failed', + 'Error fetching events before sync: ' + errorEventsBeforeSync.message, + eventsBeforeSync || [], + [], + [] + ); + continue; + } const { ws_id, access_token, refresh_token } = token; if (!access_token) { console.error('No Google access token found for wsIds:', { @@ -67,10 +145,21 @@ export const syncGoogleCalendarEvents = async () => { hasAccessToken: !!access_token, hasRefreshToken: !!refresh_token, }); + await endSync( + token.ws_id, + googleAccountEmail, + syncStartedAt, + dayjs().toISOString(), + 'failed', + 'No Google access token found for wsIds', + eventsBeforeSync || [], + [], + [] + ); + continue; } try { - const auth = getGoogleAuthClient(token); const calendar = google.calendar({ version: 'v3', auth }); const startOfCurrentWeek = dayjs().startOf('week'); @@ -115,6 +204,17 @@ export const syncGoogleCalendarEvents = async () => { }); if (error) { console.error('Error upserting events:', error); + await endSync( + ws_id, + googleAccountEmail, + syncStartedAt, + dayjs().toISOString(), + 'failed', + 'Error upserting events: ' + error.message, + eventsBeforeSync || [], + formattedEvents as WorkspaceCalendarEvent[], + [] + ); continue; } console.log( @@ -134,6 +234,17 @@ export const syncGoogleCalendarEvents = async () => { if (dbError) { console.error('Error fetching events after upsert:', dbError); + await endSync( + ws_id, + googleAccountEmail, + syncStartedAt, + dayjs().toISOString(), + 'failed', + 'Error fetching events after upsert: ' + dbError.message, + eventsBeforeSync || [], + formattedEvents as WorkspaceCalendarEvent[], + [] + ); continue; } @@ -158,6 +269,17 @@ export const syncGoogleCalendarEvents = async () => { if (deleteError) { console.error('Error deleting events:', deleteError); + await endSync( + ws_id, + googleAccountEmail, + syncStartedAt, + dayjs().toISOString(), + 'failed', + 'Error deleting events: ' + deleteError.message, + eventsBeforeSync || [], + formattedEvents as WorkspaceCalendarEvent[], + eventsToDelete as WorkspaceCalendarEvent[] + ); continue; } @@ -167,10 +289,33 @@ export const syncGoogleCalendarEvents = async () => { eventsToDelete.map((e) => e.title) ); + await endSync( + ws_id, + googleAccountEmail, + syncStartedAt, + dayjs().toISOString(), + 'success', + '', + eventsBeforeSync || [], + formattedEvents as WorkspaceCalendarEvent[], + eventsToDelete as WorkspaceCalendarEvent[] + ); + // Update lastUpsert timestamp after successful upsert await updateLastUpsert(ws_id, supabase); } catch (error) { console.error('Error fetching Google Calendar events:', error); + await endSync( + ws_id, + googleAccountEmail, + syncStartedAt, + dayjs().toISOString(), + 'failed', + 'Error fetching Google Calendar events: ' + error, + eventsBeforeSync || [], + [], + [] + ); } } } catch (error) { diff --git a/packages/types/src/supabase.ts b/packages/types/src/supabase.ts index 50d13d86e..e13ae1580 100644 --- a/packages/types/src/supabase.ts +++ b/packages/types/src/supabase.ts @@ -5062,6 +5062,59 @@ export type Database = { }, ]; }; + workspace_calendar_sync_log: { + Row: { + created_at: string; + deleted_events: Json | null; + error_message: string | null; + event_snapshot_before: Json; + google_account_email: string | null; + id: string; + status: string; + sync_ended_at: string | null; + sync_started_at: string; + triggered_by: string; + upserted_events: Json | null; + ws_id: string; + }; + Insert: { + created_at?: string; + deleted_events?: Json | null; + error_message?: string | null; + event_snapshot_before: Json; + google_account_email?: string | null; + id?: string; + status: string; + sync_ended_at?: string | null; + sync_started_at: string; + triggered_by: string; + upserted_events?: Json | null; + ws_id: string; + }; + Update: { + created_at?: string; + deleted_events?: Json | null; + error_message?: string | null; + event_snapshot_before?: Json; + google_account_email?: string | null; + id?: string; + status?: string; + sync_ended_at?: string | null; + sync_started_at?: string; + triggered_by?: string; + upserted_events?: Json | null; + ws_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'workspace_calendar_sync_log_ws_id_fkey'; + columns: ['ws_id']; + isOneToOne: false; + referencedRelation: 'workspaces'; + referencedColumns: ['id']; + }, + ]; + }; workspace_configs: { Row: { created_at: string; @@ -7679,7 +7732,7 @@ export type Database = { }; Functions: { calculate_productivity_score: { - Args: { category_color: string; duration_seconds: number }; + Args: { duration_seconds: number; category_color: string }; Returns: number; }; check_ws_creator: { @@ -7696,12 +7749,12 @@ export type Database = { }; count_search_users: { Args: - | { search_query: string } | { - search_query: string; - role_filter?: string; enabled_filter?: boolean; - }; + role_filter?: string; + search_query: string; + } + | { search_query: string }; Returns: number; }; create_ai_chat: { @@ -7711,17 +7764,17 @@ export type Database = { generate_cross_app_token: { Args: | { - p_expiry_seconds?: number; - p_target_app: string; - p_origin_app: string; p_user_id: string; + p_origin_app: string; + p_target_app: string; + p_expiry_seconds?: number; } | { + p_user_id: string; p_session_data?: Json; - p_target_app: string; p_expiry_seconds?: number; + p_target_app: string; p_origin_app: string; - p_user_id: string; }; Returns: string; }; @@ -7733,11 +7786,11 @@ export type Database = { }[]; }; get_daily_income_expense: { - Args: { _ws_id: string; past_days?: number }; + Args: { past_days?: number; _ws_id: string }; Returns: { - total_income: number; day: string; total_expense: number; + total_income: number; }[]; }; get_daily_prompt_completion_tokens: { @@ -7771,9 +7824,9 @@ export type Database = { get_hourly_prompt_completion_tokens: { Args: { past_hours?: number }; Returns: { - total_completion_tokens: number; hour: string; total_prompt_tokens: number; + total_completion_tokens: number; }[]; }; get_inventory_batches_count: { @@ -7787,21 +7840,21 @@ export type Database = { get_inventory_products: { Args: { _has_unit?: boolean; - _category_ids?: string[]; - _ws_id?: string; _warehouse_ids?: string[]; + _ws_id?: string; + _category_ids?: string[]; }; Returns: { - created_at: string; - ws_id: string; - amount: number; - price: number; - category: string; unit_id: string; - unit: string; + category: string; + price: number; + amount: number; + ws_id: string; + created_at: string; manufacturer: string; - id: string; name: string; + id: string; + unit: string; }[]; }; get_inventory_products_count: { @@ -7821,7 +7874,7 @@ export type Database = { Returns: number; }; get_monthly_income_expense: { - Args: { _ws_id: string; past_months?: number }; + Args: { past_months?: number; _ws_id: string }; Returns: { total_expense: number; total_income: number; @@ -7841,117 +7894,117 @@ export type Database = { Returns: number; }; get_possible_excluded_groups: { - Args: { _ws_id: string; included_groups: string[] }; + Args: { included_groups: string[]; _ws_id: string }; Returns: { - id: string; name: string; - ws_id: string; + id: string; amount: number; + ws_id: string; }[]; }; get_possible_excluded_tags: { Args: { _ws_id: string; included_tags: string[] }; Returns: { - id: string; - name: string; - ws_id: string; amount: number; + ws_id: string; + name: string; + id: string; }[]; }; get_session_statistics: { Args: Record; Returns: { - unique_users_count: number; - completed_count: number; + active_count: number; latest_session_date: string; + completed_count: number; total_count: number; - active_count: number; + unique_users_count: number; }[]; }; get_session_templates: { Args: { limit_count?: number; - workspace_id: string; user_id_param: string; + workspace_id: string; }; Returns: { + description: string; + category_id: string; + task_id: string; + tags: string[]; + category_name: string; category_color: string; task_name: string; usage_count: number; avg_duration: number; last_used: string; - task_id: string; - category_name: string; - category_id: string; - tags: string[]; title: string; - description: string; }[]; }; get_submission_statistics: { Args: Record; Returns: { - unique_users_count: number; latest_submission_date: string; total_count: number; + unique_users_count: number; }[]; }; get_transaction_categories_with_amount: { Args: Record; Returns: { - name: string; + is_expense: boolean; ws_id: string; created_at: string; amount: number; id: string; - is_expense: boolean; + name: string; }[]; }; get_user_role: { - Args: { user_id: string; ws_id: string }; + Args: { ws_id: string; user_id: string }; Returns: string; }; get_user_session_stats: { Args: { user_id: string }; Returns: { + active_sessions: number; current_session_age: unknown; total_sessions: number; - active_sessions: number; }[]; }; get_user_sessions: { Args: { user_id: string }; Returns: { - is_current: boolean; - session_id: string; created_at: string; updated_at: string; user_agent: string; ip: string; + is_current: boolean; + session_id: string; }[]; }; get_user_tasks: { Args: { _board_id: string }; Returns: { - name: string; - end_date: string; - start_date: string; - completed: boolean; - priority: number; - description: string; - id: string; board_id: string; + description: string; + priority: number; + completed: boolean; + start_date: string; + end_date: string; list_id: string; + id: string; + name: string; }[]; }; get_user_whitelist_status: { Args: { user_id_param: string }; Returns: { - allow_role_management: boolean; - allow_challenge_management: boolean; + is_whitelisted: boolean; allow_manage_all_challenges: boolean; + allow_challenge_management: boolean; + allow_role_management: boolean; enabled: boolean; - is_whitelisted: boolean; }[]; }; get_workspace_drive_size: { @@ -7967,20 +8020,20 @@ export type Database = { Returns: number; }; get_workspace_transactions_count: { - Args: { ws_id: string; start_date?: string; end_date?: string }; + Args: { end_date?: string; start_date?: string; ws_id: string }; Returns: number; }; get_workspace_user_groups: { Args: { _ws_id: string; - included_tags: string[]; - excluded_tags: string[]; search_query: string; + excluded_tags: string[]; + included_tags: string[]; }; Returns: { - id: string; - name: string; notes: string; + name: string; + id: string; ws_id: string; tags: string[]; tag_count: number; @@ -7993,32 +8046,32 @@ export type Database = { }; get_workspace_users: { Args: { - search_query: string; - excluded_groups: string[]; included_groups: string[]; _ws_id: string; + excluded_groups: string[]; + search_query: string; }; Returns: { + id: string; + avatar_url: string; + full_name: string; + display_name: string; email: string; + phone: string; + gender: string; + birthday: string; + ethnicity: string; + guardian: string; + address: string; + national_id: string; + note: string; + balance: number; + ws_id: string; groups: string[]; - full_name: string; - avatar_url: string; - id: string; + group_count: number; linked_users: Json; created_at: string; updated_at: string; - phone: string; - ws_id: string; - balance: number; - note: string; - national_id: string; - address: string; - guardian: string; - ethnicity: string; - birthday: string; - gender: string; - display_name: string; - group_count: number; }[]; }; get_workspace_users_count: { @@ -8034,7 +8087,7 @@ export type Database = { Returns: number; }; get_workspace_wallets_income: { - Args: { ws_id: string; start_date?: string; end_date?: string }; + Args: { end_date?: string; start_date?: string; ws_id: string }; Returns: number; }; has_other_owner: { @@ -8042,7 +8095,7 @@ export type Database = { Returns: boolean; }; insert_ai_chat_message: { - Args: { source: string; chat_id: string; message: string }; + Args: { source: string; message: string; chat_id: string }; Returns: undefined; }; is_list_accessible: { @@ -8062,7 +8115,7 @@ export type Database = { Returns: boolean; }; is_nova_user_email_in_team: { - Args: { _user_email: string; _team_id: string }; + Args: { _team_id: string; _user_email: string }; Returns: boolean; }; is_nova_user_id_in_team: { @@ -8082,7 +8135,7 @@ export type Database = { Returns: boolean; }; is_task_board_member: { - Args: { _board_id: string; _user_id: string }; + Args: { _user_id: string; _board_id: string }; Returns: boolean; }; is_user_task_in_board: { @@ -8102,11 +8155,11 @@ export type Database = { Returns: Json; }; nova_get_user_daily_sessions: { - Args: { challenge_id: string; user_id: string }; + Args: { user_id: string; challenge_id: string }; Returns: number; }; nova_get_user_total_sessions: { - Args: { challenge_id: string; user_id: string }; + Args: { user_id: string; challenge_id: string }; Returns: number; }; revoke_all_cross_app_tokens: { @@ -8132,8 +8185,6 @@ export type Database = { enabled_filter?: boolean; }; Returns: { - created_at: string; - enabled: boolean; allow_challenge_management: boolean; allow_manage_all_challenges: boolean; allow_role_management: boolean; @@ -8141,27 +8192,29 @@ export type Database = { new_email: string; birthday: string; team_name: string[]; - user_id: string; id: string; display_name: string; deleted: boolean; avatar_url: string; handle: string; bio: string; + created_at: string; + user_id: string; + enabled: boolean; }[]; }; search_users_by_name: { Args: { - min_similarity?: number; - result_limit?: number; search_query: string; + result_limit?: number; + min_similarity?: number; }; Returns: { handle: string; - id: string; - display_name: string; - avatar_url: string; relevance: number; + avatar_url: string; + display_name: string; + id: string; }[]; }; sum_quiz_scores: { @@ -8171,7 +8224,7 @@ export type Database = { }[]; }; transactions_have_same_abs_amount: { - Args: { transaction_id_2: string; transaction_id_1: string }; + Args: { transaction_id_1: string; transaction_id_2: string }; Returns: boolean; }; transactions_have_same_amount: { @@ -8183,11 +8236,11 @@ export type Database = { Returns: undefined; }; update_session_total_score: { - Args: { user_id_param: string; challenge_id_param: string }; + Args: { challenge_id_param: string; user_id_param: string }; Returns: undefined; }; validate_cross_app_token: { - Args: { p_target_app: string; p_token: string }; + Args: { p_token: string; p_target_app: string }; Returns: string; }; validate_cross_app_token_with_session: {