From 5bd642b947d2bedbc340faa57dc1dc74ad8066b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Tue, 8 Jul 2025 15:30:37 +0200 Subject: [PATCH] Implemented sync functionality with sheets and email sending --- .github/copilot-instructions.md | 9 +- src/lib/google/auth/client.ts | 116 ++++ src/lib/google/auth/manager.ts | 120 ++++ src/lib/google/gmail/server.ts | 122 ++-- src/routes/auth/google/callback/+server.ts | 14 + src/routes/private/api/gmail/+server.ts | 161 +++++ .../private/events/event/new/+page.svelte | 2 +- .../new/components/GoogleAuthStep.svelte | 2 +- .../private/events/event/view/+page.svelte | 610 ++++++++++++++++++ .../view/components/GoogleAuthButton.svelte | 117 ++++ 10 files changed, 1208 insertions(+), 65 deletions(-) create mode 100644 src/lib/google/auth/manager.ts create mode 100644 src/routes/private/api/gmail/+server.ts create mode 100644 src/routes/private/events/event/view/components/GoogleAuthButton.svelte diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 303229d..084af10 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -91,6 +91,7 @@ onsubmit|preventDefault={handleSubmit} is depracated, do not use it! Loading session using page.server.ts is not needed as the session is already available in the locals object. +Do not use import { page } from '$app/stores'; as it is deprecated! Use instead: import { page } from '$app/state'; IMPORTANT: Always make sure that the client-side module are not importing secrets or are running any sensritive code that could expose secrets to the client. @@ -100,6 +101,8 @@ fetch data from there instead of directly in the client-side module. The database schema in supabase is as follows: -- WARNING: This schema is for context only and is not meant to be run. -- Table order and constraints may not be valid for execution. +-- WARNING: This schema is for context only and is not meant to be run. +-- Table order and constraints may not be valid for execution. CREATE TABLE public.events ( id uuid NOT NULL DEFAULT gen_random_uuid(), @@ -142,6 +145,7 @@ CREATE TABLE public.participants ( scanned_at timestamp with time zone, scanned_by uuid, section_id uuid, + email_sent boolean DEFAULT false, CONSTRAINT participants_pkey PRIMARY KEY (id), CONSTRAINT participants_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id), CONSTRAINT participants_event_fkey FOREIGN KEY (event) REFERENCES public.events(id), @@ -167,8 +171,3 @@ CREATE TABLE public.sections ( CONSTRAINT sections_pkey PRIMARY KEY (id) ); -An event is created by calling RPC databse function create_event -by passing the following parameters: -- name, date, email_subject, email_body, sheet_id, name_column, surname_column, email_column, confirmation_column - - diff --git a/src/lib/google/auth/client.ts b/src/lib/google/auth/client.ts index 6c9e654..1a5c535 100644 --- a/src/lib/google/auth/client.ts +++ b/src/lib/google/auth/client.ts @@ -98,6 +98,122 @@ export async function getUserInfo(accessToken: string): Promise<{ email: string; } } +/** + * Authenticate with Google using OAuth popup flow + * @returns Authentication result with success status and tokens + */ +export async function authenticateWithGoogle(): Promise<{ + success: boolean; + refreshToken?: string; + userEmail?: string; + error?: string; +}> { + if (!browser) { + return { success: false, error: 'Not in browser environment' }; + } + + return new Promise((resolve) => { + try { + // Open popup window for OAuth + const popup = window.open( + '/auth/google', + 'google-auth', + 'width=500,height=600,scrollbars=yes,resizable=yes,left=' + + Math.round(window.screen.width / 2 - 250) + ',top=' + + Math.round(window.screen.height / 2 - 300) + ); + + if (!popup) { + resolve({ success: false, error: 'Failed to open popup window. Please allow popups for this site.' }); + return; + } + + let authCompleted = false; + let popupTimer: number | null = null; + + // Store current timestamp to detect changes in localStorage + const startTimestamp = localStorage.getItem('google_auth_timestamp') ?? '0'; + + // Poll localStorage for auth completion + const pollInterval = setInterval(() => { + try { + const currentTimestamp = localStorage.getItem('google_auth_timestamp'); + + // If timestamp has changed, auth is complete + if (currentTimestamp && currentTimestamp !== startTimestamp) { + handleAuthSuccess(); + } + } catch (e) { + console.error('Error checking auth timestamp:', e); + } + }, 500); // Poll every 500ms + + // Common handler for authentication success + function handleAuthSuccess() { + if (authCompleted) return; // Prevent duplicate handling + + authCompleted = true; + + // Clean up timers + clearInterval(pollInterval); + if (popupTimer) clearTimeout(popupTimer); + + // Get tokens from localStorage + const refreshToken = localStorage.getItem('google_refresh_token'); + const userEmail = localStorage.getItem('google_user_email'); + + if (refreshToken) { + resolve({ + success: true, + refreshToken, + userEmail: userEmail ?? undefined + }); + } else { + resolve({ success: false, error: 'No refresh token found after authentication' }); + } + } + + // Clean up function to handle all cleanup in one place + const cleanUp = () => { + clearInterval(pollInterval); + if (popupTimer) clearTimeout(popupTimer); + }; + + // Set a timeout for initial auth check + popupTimer = setTimeout(() => { + if (!authCompleted) { + cleanUp(); + // Check if tokens were stored by the popup before it was closed + const refreshToken = localStorage.getItem('google_refresh_token'); + const userEmail = localStorage.getItem('google_user_email'); + + if (refreshToken) { + resolve({ + success: true, + refreshToken, + userEmail: userEmail ?? undefined + }); + } else { + resolve({ success: false, error: 'Authentication timeout or cancelled' }); + } + } + }, 30 * 1000) as unknown as number; + + // Final cleanup timeout + setTimeout(() => { + if (!authCompleted) { + cleanUp(); + resolve({ success: false, error: 'Authentication timeout' }); + } + }, 60 * 1000); + + } catch (error) { + console.error('Error connecting to Google:', error); + resolve({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }); + } + }); +} + /** * Revoke a Google access token * @param accessToken - Google access token to revoke diff --git a/src/lib/google/auth/manager.ts b/src/lib/google/auth/manager.ts new file mode 100644 index 0000000..f87901e --- /dev/null +++ b/src/lib/google/auth/manager.ts @@ -0,0 +1,120 @@ +import { authenticateWithGoogle } from '$lib/google/auth/client.js'; + +export interface GoogleAuthState { + isConnected: boolean; + checking: boolean; + connecting: boolean; + showCancelOption: boolean; + token: string | null; + error: string | null; + userEmail: string | null; +} + +export function createGoogleAuthState(): GoogleAuthState { + return { + isConnected: false, + checking: false, + connecting: false, + showCancelOption: false, + token: null, + error: null, + userEmail: null + }; +} + +export class GoogleAuthManager { + private readonly state: GoogleAuthState; + private cancelTimeout: ReturnType | null = null; + + constructor(state: GoogleAuthState) { + this.state = state; + } + + checkConnection(): void { + this.state.checking = true; + this.state.error = null; + + try { + const token = localStorage.getItem('google_refresh_token'); + const email = localStorage.getItem('google_user_email'); + + this.state.isConnected = !!token; + this.state.token = token; + this.state.userEmail = email; + } catch (error) { + console.error('Error checking connection:', error); + this.state.error = 'Failed to check connection status'; + } finally { + this.state.checking = false; + } + } + + async connectToGoogle(): Promise { + if (this.state.connecting) return; + + this.state.connecting = true; + this.state.error = null; + this.state.showCancelOption = false; + + // Show cancel option after 5 seconds + this.cancelTimeout = setTimeout(() => { + this.state.showCancelOption = true; + }, 5000); + + try { + const result = await authenticateWithGoogle(); + + if (result.success && result.refreshToken) { + // Store tokens + localStorage.setItem('google_refresh_token', result.refreshToken); + if (result.userEmail) { + localStorage.setItem('google_user_email', result.userEmail); + } + + // Update state + this.state.isConnected = true; + this.state.token = result.refreshToken; + this.state.userEmail = result.userEmail; + } else { + throw new Error(result.error ?? 'Authentication failed'); + } + } catch (error) { + this.state.error = error instanceof Error ? error.message : 'Failed to connect to Google'; + } finally { + this.state.connecting = false; + this.state.showCancelOption = false; + if (this.cancelTimeout) { + clearTimeout(this.cancelTimeout); + this.cancelTimeout = null; + } + } + } + + cancelGoogleAuth(): void { + this.state.connecting = false; + this.state.showCancelOption = false; + this.state.error = null; + + if (this.cancelTimeout) { + clearTimeout(this.cancelTimeout); + this.cancelTimeout = null; + } + } + + async disconnectGoogle(): Promise { + try { + // Clear local storage + localStorage.removeItem('google_refresh_token'); + localStorage.removeItem('google_user_email'); + + // Reset state + this.state.isConnected = false; + this.state.token = null; + this.state.userEmail = null; + this.state.error = null; + } catch (error) { + console.error('Error disconnecting:', error); + this.state.error = 'Failed to disconnect'; + } + } +} diff --git a/src/lib/google/gmail/server.ts b/src/lib/google/gmail/server.ts index c6838ca..9130843 100644 --- a/src/lib/google/gmail/server.ts +++ b/src/lib/google/gmail/server.ts @@ -1,14 +1,14 @@ import { google } from 'googleapis'; import quotedPrintable from 'quoted-printable'; -import { getAuthenticatedClient } from '../auth/server.js'; +import { getOAuthClient } from '../auth/server.js'; /** - * Create an HTML email template + * Create an HTML email template with ScanWave branding * @param text - Email body text * @returns HTML email template */ export function createEmailTemplate(text: string): string { - return ` + return ` -
-

${text}

- QR Code +
+

${text}

+ QR Code +
+
+
+
+
+
+
+
+

This email has been generated with the help of ScanWave

+
`; } /** - * Send an email through Gmail + * Send an email through Gmail with QR code * @param refreshToken - Google refresh token * @param params - Email parameters (to, subject, text, qr_code) */ export async function sendGmail( - refreshToken: string, - { - to, - subject, - text, - qr_code - }: { - to: string; - subject: string; - text: string; - qr_code: string; - } + refreshToken: string, + { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } ) { - const oauth = getAuthenticatedClient(refreshToken); - const gmail = google.gmail({ version: 'v1', auth: oauth }); - - const message_html = createEmailTemplate(text); - const boundary = 'BOUNDARY'; - const nl = '\r\n'; + const oauth = getOAuthClient(); + oauth.setCredentials({ refresh_token: refreshToken }); - const htmlBuffer = Buffer.from(message_html, 'utf8'); - const htmlLatin1 = htmlBuffer.toString('latin1'); - const htmlQP = quotedPrintable.encode(htmlLatin1); - const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); + const gmail = google.gmail({ version: 'v1', auth: oauth }); - const rawParts = [ - 'MIME-Version: 1.0', - `To: ${to}`, - `Subject: ${subject}`, - `Content-Type: multipart/related; boundary="${boundary}"`, - '--' + boundary, - 'Content-Type: text/html; charset="UTF-8"', - 'Content-Transfer-Encoding: quoted-printable', - '', - htmlQP, - '', - '--' + boundary, - 'Content-Type: image/png', - 'Content-Transfer-Encoding: base64', - 'Content-ID: ', - 'Content-Disposition: inline; filename="qr.png"', - '', - qrLines, - '', - '--' + boundary + '--', - '' - ]; - - const rawMessage = rawParts.join(nl); - const raw = Buffer.from(rawMessage).toString('base64url'); + const message_html = createEmailTemplate(text); - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw } - }); + const boundary = 'BOUNDARY'; + const nl = '\r\n'; // RFC-5322 line ending + + // Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode + const htmlBuffer = Buffer.from(message_html, 'utf8'); + const htmlLatin1 = htmlBuffer.toString('latin1'); + const htmlQP = quotedPrintable.encode(htmlLatin1); + const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); + + const rawParts = [ + 'MIME-Version: 1.0', + `To: ${to}`, + `Subject: ${subject}`, + `Content-Type: multipart/related; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + 'Content-Transfer-Encoding: quoted-printable', + '', + htmlQP, + '', + `--${boundary}`, + 'Content-Type: image/png', + 'Content-Transfer-Encoding: base64', + 'Content-ID: ', + 'Content-Disposition: inline; filename="qr.png"', + '', + qrLines, + '', + `--${boundary}--`, + '' + ]; + + const rawMessage = rawParts.join(nl); + + const raw = Buffer.from(rawMessage).toString('base64url'); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw } + }); } diff --git a/src/routes/auth/google/callback/+server.ts b/src/routes/auth/google/callback/+server.ts index a6f339b..d41adc9 100644 --- a/src/routes/auth/google/callback/+server.ts +++ b/src/routes/auth/google/callback/+server.ts @@ -24,6 +24,19 @@ export const GET: RequestHandler = async ({ url }) => { throw redirect(302, '/private/events?error=incomplete_tokens'); } + // Get user info to retrieve email + let userEmail = ''; + try { + oauth.setCredentials(tokens); + const { google } = await import('googleapis'); + const oauth2 = google.oauth2({ version: 'v2', auth: oauth }); + const userInfo = await oauth2.userinfo.get(); + userEmail = userInfo.data.email ?? ''; + } catch (emailError) { + console.error('Error fetching user email:', emailError); + // Continue without email - it's not critical for the auth flow + } + // Create a success page with tokens that closes the popup and communicates with parent const html = ` @@ -67,6 +80,7 @@ export const GET: RequestHandler = async ({ url }) => { // Store tokens in localStorage (same origin) localStorage.setItem('google_access_token', '${tokens.access_token}'); localStorage.setItem('google_refresh_token', '${tokens.refresh_token}'); + ${userEmail ? `localStorage.setItem('google_user_email', '${userEmail}');` : ''} // Set timestamp that the main application will detect localStorage.setItem('google_auth_timestamp', Date.now().toString()); diff --git a/src/routes/private/api/gmail/+server.ts b/src/routes/private/api/gmail/+server.ts new file mode 100644 index 0000000..c34fec7 --- /dev/null +++ b/src/routes/private/api/gmail/+server.ts @@ -0,0 +1,161 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sendGmail } from '$lib/google/gmail/server.js'; +import QRCode from 'qrcode'; + +interface Participant { + id: string; + name: string; + surname: string; + email: string; +} + +interface EmailResult { + participant: Participant; + success: boolean; + error?: string; +} + +async function generateQRCode(participant: Participant, eventId: string): Promise { + const qrCodeData = JSON.stringify({ + participantId: participant.id, + eventId: eventId, + name: participant.name, + surname: participant.surname + }); + + const qrCodeBase64 = await QRCode.toDataURL(qrCodeData, { + type: 'image/png', + margin: 2, + scale: 8 + }); + + // Remove the data URL prefix to get just the base64 string + return qrCodeBase64.replace(/^data:image\/png;base64,/, ''); +} + +async function sendEmailToParticipant( + participant: Participant, + subject: string, + text: string, + eventId: string, + refreshToken: string, + supabase: any +): Promise { + try { + const qrCodeBase64Data = await generateQRCode(participant, eventId); + + // Send email with QR code + await sendGmail(refreshToken, { + to: participant.email, + subject: subject, + text: text, + qr_code: qrCodeBase64Data + }); + + + // Call the participant_emailed RPC function + try { + await supabase.rpc('participant_emailed', { + p_participant_id: participant.id + }); + } catch (dbError) { + console.error('Failed to call participant_emailed RPC:', dbError); + // Don't fail the entire operation if the RPC call fails + } + + return { + participant: participant, + success: true + }; + } catch (error) { + console.error('Failed to send email to participant:', participant.email, error); + return { + participant: participant, + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +function validateRequest(participants: unknown, subject: unknown, text: unknown, eventId: unknown, refreshToken: unknown) { + if (!participants || !Array.isArray(participants)) { + return { error: 'Invalid participants array', status: 400 }; + } + + if (!subject || !text) { + return { error: 'Subject and text are required', status: 400 }; + } + + if (!eventId) { + return { error: 'Event ID is required', status: 400 }; + } + + if (!refreshToken || typeof refreshToken !== 'string') { + return { error: 'Refresh token is required', status: 401 }; + } + + return null; +} + +export const POST: RequestHandler = async ({ request, locals }) => { + try { + const { participants, subject, text, eventId, refreshToken } = await request.json(); + + const validationError = validateRequest(participants, subject, text, eventId, refreshToken); + if (validationError) { + return json({ error: validationError.error }, { status: validationError.status }); + } + + const results: EmailResult[] = []; + let successCount = 0; + let errorCount = 0; + + // Send emails to each participant + for (const participant of participants as Participant[]) { + const result = await sendEmailToParticipant( + participant, + subject as string, + text as string, + eventId as string, + refreshToken as string, + locals.supabase + ); + + results.push(result); + + if (result.success) { + successCount++; + } else { + errorCount++; + } + } + + return json({ + success: true, + results, + summary: { + total: participants.length, + success: successCount, + errors: errorCount + } + }); + } catch (error) { + console.error('Email sending error:', error); + + // Handle specific Gmail API errors + if (error instanceof Error) { + if (error.message.includes('Invalid Credentials') || error.message.includes('unauthorized')) { + return json({ error: 'Invalid or expired Google credentials' }, { status: 401 }); + } + if (error.message.includes('quota')) { + return json({ error: 'Gmail API quota exceeded' }, { status: 429 }); + } + if (error.message.includes('rate')) { + return json({ error: 'Rate limit exceeded' }, { status: 429 }); + } + } + + return json({ error: 'Failed to send emails' }, { status: 500 }); + } +}; diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index 5785dc6..40a9190 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -415,7 +415,7 @@ -
+
{#if currentStep === 0} {:else if currentStep === 1} diff --git a/src/routes/private/events/event/new/components/GoogleAuthStep.svelte b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte index a3f469f..f09e104 100644 --- a/src/routes/private/events/event/new/components/GoogleAuthStep.svelte +++ b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte @@ -20,7 +20,7 @@

Connect Your Google Account

-

+

To create events and import participants from Google Sheets, you need to connect your Google account.

diff --git a/src/routes/private/events/event/view/+page.svelte b/src/routes/private/events/event/view/+page.svelte index e69de29..4a96ac9 100644 --- a/src/routes/private/events/event/view/+page.svelte +++ b/src/routes/private/events/event/view/+page.svelte @@ -0,0 +1,610 @@ + + + +
+

Event Overview

+
+ + +
+ {#if loading} + +
+
+
+
+
+
+ {:else if event} +
+
+

{event.name}

+
+
+ Date: + {formatDate(event.date)} +
+
+ Created: + {formatDate(event.created_at)} +
+ +
+
+
+

Email Details

+
+
+ Subject: +

{event.email_subject}

+
+
+ Body: +

{event.email_body}

+
+
+
+
+ {:else if error} +
+

{error}

+
+ {/if} +
+ + +
+
+

Google Account

+

Required for syncing participants and sending emails

+
+ { + // Refresh the page or update UI state as needed + error = ''; + }} + onError={(errorMsg) => { + error = errorMsg; + }} + /> +
+ + +
+
+

Participants

+ +
+ + {#if participantsLoading} + +
+ {#each Array(5) as _} +
+
+
+
+
+
+
+ {/each} +
+ {:else if participants.length > 0} +
+ + + + + + + + + + + + {#each participants as participant} + + + + + + + + {/each} + +
NameSurnameEmailScannedEmail Sent
{participant.name}{participant.surname}{participant.email} + {#if participant.scanned} +
+ + + +
+ {:else} +
+ + + +
+ {/if} +
+ {#if participant.email_sent} +
+ + + +
+ {:else} +
+ + + +
+ {/if} +
+
+ {:else} +
+

+ No participants found. Click "Sync Participants" to load from Google Sheets. +

+
+ {/if} +
+ + +
+
+

Send Emails

+
+ {participants.filter(p => !p.email_sent).length} uncontacted participants +
+
+ + {#if sendingEmails} +
+
+ + + + + Sending {emailProgress.total} emails... Please wait. +
+
+ {:else} +
+ {#if participants.filter(p => !p.email_sent).length > 0} +
+
+ + + + + Warning: Do not close this window while emails are being sent. The process may take several minutes. + +
+
+ +
+ +
+ {:else} +
+
+ + + +
+

All participants have been contacted!

+

No pending emails to send.

+
+ {/if} +
+ {/if} +
+ + +{#if emailResults} +
+
+

Email Results

+
+ {emailResults.summary.success} successful, {emailResults.summary.errors} failed +
+
+ +
+
+
+
+ Sent: {emailResults.summary.success} +
+
+
+ Failed: {emailResults.summary.errors} +
+
+
+ Total: {emailResults.summary.total} +
+
+
+ + {#if emailResults.results.length > 0} +
+ + + + + + + + + + {#each emailResults.results as result} + + + + + + {/each} + +
NameEmailStatus
+ {result.participant.name} {result.participant.surname} + {result.participant.email} + {#if result.success} +
+ + + + Sent +
+ {:else} +
+ + + + Failed + {#if result.error} + ({result.error}) + {/if} +
+ {/if} +
+
+ {/if} +
+{/if} + +{#if error} +
+

{error}

+
+{/if} diff --git a/src/routes/private/events/event/view/components/GoogleAuthButton.svelte b/src/routes/private/events/event/view/components/GoogleAuthButton.svelte new file mode 100644 index 0000000..633a46c --- /dev/null +++ b/src/routes/private/events/event/view/components/GoogleAuthButton.svelte @@ -0,0 +1,117 @@ + + +{#if authState.isConnected} +
+
+ + + + Connected +
+ + {#if authState.userEmail} +
+ + + + {authState.userEmail} +
+ {/if} + + +
+{:else} +
+ + + {#if authState.error} +
+ {authState.error} +
+ {/if} +
+{/if}