Implemented sync functionality with sheets and email sending

This commit is contained in:
Roman Krček
2025-07-08 15:30:37 +02:00
parent 39bd172798
commit 5bd642b947
10 changed files with 1208 additions and 65 deletions

View File

@@ -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. 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 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. 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: The database schema in supabase is as follows:
-- WARNING: This schema is for context only and is not meant to be run. -- WARNING: This schema is for context only and is not meant to be run.
-- Table order and constraints may not be valid for execution. -- 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 ( CREATE TABLE public.events (
id uuid NOT NULL DEFAULT gen_random_uuid(), id uuid NOT NULL DEFAULT gen_random_uuid(),
@@ -142,6 +145,7 @@ CREATE TABLE public.participants (
scanned_at timestamp with time zone, scanned_at timestamp with time zone,
scanned_by uuid, scanned_by uuid,
section_id uuid, section_id uuid,
email_sent boolean DEFAULT false,
CONSTRAINT participants_pkey PRIMARY KEY (id), CONSTRAINT participants_pkey PRIMARY KEY (id),
CONSTRAINT participants_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(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), 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) 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

View File

@@ -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 * Revoke a Google access token
* @param accessToken - Google access token to revoke * @param accessToken - Google access token to revoke

View File

@@ -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<typeof setTimeout> | 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<void> {
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<void> {
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';
}
}
}

View File

@@ -1,14 +1,14 @@
import { google } from 'googleapis'; import { google } from 'googleapis';
import quotedPrintable from 'quoted-printable'; 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 * @param text - Email body text
* @returns HTML email template * @returns HTML email template
*/ */
export function createEmailTemplate(text: string): string { export function createEmailTemplate(text: string): string {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<style> <style>
@@ -16,73 +16,79 @@ export function createEmailTemplate(text: string): string {
</style> </style>
</head> </head>
<body style="font-family: 'Lato', sans-serif; background-color: #f9f9f9; padding: 20px; margin: 0;"> <body style="font-family: 'Lato', sans-serif; background-color: #f9f9f9; padding: 20px; margin: 0;">
<div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px;"> <div style="max-width: 600px; margin: auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<p style="white-space: pre-line; font-size: 16px; color: #333;">${text}</p> <p style="white-space: pre-line;font-size: 16px; line-height: 1.5; color: #333;">${text}</p>
<img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; height: auto;" /> <img src="cid:qrCode1" alt="QR Code" style="display: block; margin: 20px auto; max-width: 50%; min-width: 200px; height: auto;" />
<div style="width: 100%; display: flex; flex-direction: row; justify-content: space-between">
<div style="height: 4px; width: 20%; background: #00aeef;"></div>
<div style="height: 4px; width: 20%; background: #ec008c;"></div>
<div style="height: 4px; width: 20%; background: #7ac143;"></div>
<div style="height: 4px; width: 20%; background: #f47b20;"></div>
<div style="height: 4px; width: 20%; background: #2e3192;"></div>
</div>
<div style="font-size: 12px; color: #999; padding-top: 0px; margin-top: 10px; line-height: 1.5; ">
<p>This email has been generated with the help of ScanWave</p>
</div>
</div> </div>
</body> </body>
</html>`; </html>`;
} }
/** /**
* Send an email through Gmail * Send an email through Gmail with QR code
* @param refreshToken - Google refresh token * @param refreshToken - Google refresh token
* @param params - Email parameters (to, subject, text, qr_code) * @param params - Email parameters (to, subject, text, qr_code)
*/ */
export async function sendGmail( export async function sendGmail(
refreshToken: string, refreshToken: string,
{ { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string }
to,
subject,
text,
qr_code
}: {
to: string;
subject: string;
text: string;
qr_code: string;
}
) { ) {
const oauth = getAuthenticatedClient(refreshToken); const oauth = getOAuthClient();
const gmail = google.gmail({ version: 'v1', auth: oauth }); oauth.setCredentials({ refresh_token: refreshToken });
const message_html = createEmailTemplate(text); const gmail = google.gmail({ version: 'v1', auth: oauth });
const boundary = 'BOUNDARY';
const nl = '\r\n';
const htmlBuffer = Buffer.from(message_html, 'utf8'); const message_html = createEmailTemplate(text);
const htmlLatin1 = htmlBuffer.toString('latin1');
const htmlQP = quotedPrintable.encode(htmlLatin1);
const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl);
const rawParts = [ const boundary = 'BOUNDARY';
'MIME-Version: 1.0', const nl = '\r\n'; // RFC-5322 line ending
`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: <qrCode1>',
'Content-Disposition: inline; filename="qr.png"',
'',
qrLines,
'',
'--' + boundary + '--',
''
];
const rawMessage = rawParts.join(nl); // Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode
const raw = Buffer.from(rawMessage).toString('base64url'); 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);
await gmail.users.messages.send({ const rawParts = [
userId: 'me', 'MIME-Version: 1.0',
requestBody: { raw } `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: <qrCode1>',
'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 }
});
} }

View File

@@ -24,6 +24,19 @@ export const GET: RequestHandler = async ({ url }) => {
throw redirect(302, '/private/events?error=incomplete_tokens'); 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 // Create a success page with tokens that closes the popup and communicates with parent
const html = ` const html = `
<!DOCTYPE html> <!DOCTYPE html>
@@ -67,6 +80,7 @@ export const GET: RequestHandler = async ({ url }) => {
// Store tokens in localStorage (same origin) // Store tokens in localStorage (same origin)
localStorage.setItem('google_access_token', '${tokens.access_token}'); localStorage.setItem('google_access_token', '${tokens.access_token}');
localStorage.setItem('google_refresh_token', '${tokens.refresh_token}'); localStorage.setItem('google_refresh_token', '${tokens.refresh_token}');
${userEmail ? `localStorage.setItem('google_user_email', '${userEmail}');` : ''}
// Set timestamp that the main application will detect // Set timestamp that the main application will detect
localStorage.setItem('google_auth_timestamp', Date.now().toString()); localStorage.setItem('google_auth_timestamp', Date.now().toString());

View File

@@ -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<string> {
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<EmailResult> {
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 });
}
};

View File

@@ -415,7 +415,7 @@
<StepNavigator {currentStep} {totalSteps} /> <StepNavigator {currentStep} {totalSteps} />
<!-- Step Content --> <!-- Step Content -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-6"> <div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
{#if currentStep === 0} {#if currentStep === 0}
<GoogleAuthStep bind:authData bind:errors {connectToGoogle} {cancelGoogleAuth} {disconnectGoogle} /> <GoogleAuthStep bind:authData bind:errors {connectToGoogle} {cancelGoogleAuth} {disconnectGoogle} />
{:else if currentStep === 1} {:else if currentStep === 1}

View File

@@ -20,7 +20,7 @@
<div class="space-y-6"> <div class="space-y-6">
<div class="text-center"> <div class="text-center">
<h3 class="text-lg font-medium text-gray-900 mb-4">Connect Your Google Account</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">Connect Your Google Account</h3>
<p class="text-gray-600 mb-6"> <p class="text-gray-600 mb-4">
To create events and import participants from Google Sheets, you need to connect your Google account. To create events and import participants from Google Sheets, you need to connect your Google account.
</p> </p>

View File

@@ -0,0 +1,610 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import GoogleAuthButton from './components/GoogleAuthButton.svelte';
let { data } = $props();
// Types
interface Event {
id: string;
created_at: string;
created_by: string;
name: string;
date: string;
section_id: string;
email_subject: string;
email_body: string;
sheet_id: string;
name_column: number;
surname_column: number;
email_column: number;
confirmation_column: number;
}
interface Participant {
id: string;
name: string;
surname: string;
email: string;
scanned: boolean;
email_sent: boolean;
}
// State
let event = $state<Event | null>(null);
let participants = $state<Participant[]>([]);
let loading = $state(true);
let participantsLoading = $state(true);
let syncingParticipants = $state(false);
let sendingEmails = $state(false);
let emailProgress = $state({ sent: 0, total: 0 });
let emailResults = $state<{success: boolean, results: any[], summary: any} | null>(null);
let error = $state('');
// Get event ID from URL params
let eventId = $derived($page.url.searchParams.get('id'));
onMount(async () => {
if (eventId) {
await loadEvent();
await loadParticipants();
}
});
async function loadEvent() {
loading = true;
try {
const { data: eventData, error: eventError } = await data.supabase
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (eventError) throw eventError;
event = eventData;
} catch (err) {
console.error('Error loading event:', err);
error = 'Failed to load event';
} finally {
loading = false;
}
}
async function loadParticipants() {
participantsLoading = true;
try {
const { data: participantsData, error: participantsError } = await data.supabase
.from('participants')
.select('id, name, surname, email, scanned, email_sent')
.eq('event', eventId)
.order('scanned', { ascending: true })
.order('email_sent', { ascending: true })
.order('name', { ascending: true });
if (participantsError) throw participantsError;
participants = participantsData || [];
} catch (err) {
console.error('Error loading participants:', err);
error = 'Failed to load participants';
} finally {
participantsLoading = false;
}
}
async function syncParticipants() {
if (!event || !event.sheet_id) return;
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
error = 'Please connect your Google account first to sync participants';
return;
}
syncingParticipants = true;
error = '';
try {
// Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
method: 'GET',
headers: {
Authorization: `Bearer ${refreshToken}`
}
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Google authentication expired. Please reconnect your Google account.');
}
throw new Error('Failed to fetch sheet data');
}
const sheetData = await response.json();
const rows = sheetData.values || [];
if (rows.length === 0) throw new Error('No data found in sheet');
// Extract participant data based on column mapping
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
// Skip header row (start from index 1)
for (let i = 1; i < rows.length; i++) {
const row = rows[i];
if (row.length > 0) {
const name = row[event.name_column - 1] || '';
const surname = row[event.surname_column - 1] || '';
const email = row[event.email_column - 1] || '';
const confirmation = row[event.confirmation_column - 1] || '';
// Only add if the row has meaningful data (not all empty) AND confirmation is TRUE
const isConfirmed =
confirmation.toString().toLowerCase() === 'true' ||
confirmation.toString().toLowerCase() === 'yes' ||
confirmation === '1' ||
confirmation === 'x';
if ((name.trim() || surname.trim() || email.trim()) && isConfirmed) {
names.push(name.trim());
surnames.push(surname.trim());
emails.push(email.trim());
}
}
}
// Call database function to add participants
const { error: syncError } = await data.supabase.rpc('participants_add_bulk', {
p_event: eventId,
p_names: names,
p_surnames: surnames,
p_emails: emails
});
console.log(syncError);
if (syncError) throw syncError;
// Reload participants
await loadParticipants();
} catch (err) {
console.error('Error syncing participants:', err);
error = 'Failed to sync participants';
} finally {
syncingParticipants = false;
}
}
async function sendEmailsToUncontacted() {
if (!event) return;
// Check if user has Google authentication
const refreshToken = localStorage.getItem('google_refresh_token');
if (!refreshToken) {
error = 'Please connect your Google account first to send emails';
return;
}
const uncontactedParticipants = participants.filter(p => !p.email_sent);
if (uncontactedParticipants.length === 0) {
error = 'No uncontacted participants found';
return;
}
sendingEmails = true;
emailProgress = { sent: 0, total: uncontactedParticipants.length };
emailResults = null;
error = '';
try {
// Send all emails in batch
const response = await fetch('/private/api/gmail', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
participants: uncontactedParticipants,
subject: event.email_subject,
text: event.email_body,
eventId: event.id,
refreshToken: refreshToken
})
}); if (response.ok) {
const result = await response.json();
emailProgress.sent = result.summary.success;
emailResults = result;
// Update participants state to reflect email_sent status
participants = participants.map(p => {
const emailedParticipant = result.results.find((r: any) => r.participant.id === p.id);
if (emailedParticipant && emailedParticipant.success) {
return { ...p, email_sent: true };
}
return p;
});
} else {
const errorData = await response.json();
error = errorData.error || 'Failed to send emails';
console.error('Email sending failed:', errorData);
}
} catch (err) {
console.error('Error sending emails:', err);
error = 'Failed to send emails to participants';
} finally {
sendingEmails = false;
}
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
</script>
<!-- Header -->
<div class="mt-2 mb-4">
<h1 class="text-center text-2xl font-bold">Event Overview</h1>
</div>
<!-- Event Information -->
<div class="mb-4 rounded-lg border border-gray-300 bg-white p-6">
{#if loading}
<!-- Loading placeholder -->
<div class="space-y-4">
<div class="h-8 w-1/2 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 w-1/4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 w-1/3 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 w-1/2 animate-pulse rounded bg-gray-200"></div>
</div>
{:else if event}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h2 class="mb-4 text-2xl font-semibold text-gray-900">{event.name}</h2>
<div class="space-y-3">
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Date:</span>
<span class="text-sm text-gray-900">{formatDate(event.date)}</span>
</div>
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Created:</span>
<span class="text-sm text-gray-900">{formatDate(event.created_at)}</span>
</div>
<div class="flex items-center">
<span class="w-20 text-sm font-medium text-gray-500">Sheet ID:</span>
<a
href={`https://docs.google.com/spreadsheets/d/${event.sheet_id}`}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800 transition hover:bg-green-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
Open in Google Sheets
</a>
</div>
</div>
</div>
<div>
<h3 class="mb-3 text-lg font-medium text-gray-900">Email Details</h3>
<div class="space-y-2">
<div>
<span class="text-sm font-medium text-gray-500">Subject:</span>
<p class="text-sm text-gray-900">{event.email_subject}</p>
</div>
<div>
<span class="text-sm font-medium text-gray-500">Body:</span>
<p class="text-sm whitespace-pre-wrap text-gray-900">{event.email_body}</p>
</div>
</div>
</div>
</div>
{:else if error}
<div class="py-8 text-center">
<p class="text-red-600">{error}</p>
</div>
{/if}
</div>
<!-- Google Authentication Section -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Google Account</h2>
<p class="text-sm text-gray-500">Required for syncing participants and sending emails</p>
</div>
<GoogleAuthButton
size="small"
variant="secondary"
onSuccess={() => {
// Refresh the page or update UI state as needed
error = '';
}}
onError={(errorMsg) => {
error = errorMsg;
}}
/>
</div>
<!-- Participants Section -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Participants</h2>
<button
onclick={syncParticipants}
disabled={syncingParticipants || !event}
class="rounded bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if syncingParticipants}
Syncing...
{:else}
Sync Participants
{/if}
</button>
</div>
{#if participantsLoading}
<!-- Loading placeholder for participants -->
<div class="space-y-3">
{#each Array(5) as _}
<div class="grid grid-cols-5 gap-4 border-b border-gray-200 py-3">
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
<div class="h-4 animate-pulse rounded bg-gray-200"></div>
</div>
{/each}
</div>
{:else if participants.length > 0}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Name</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Surname</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Email</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Scanned</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Email Sent</th>
</tr>
</thead>
<tbody>
{#each participants as participant}
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">{participant.name}</td>
<td class="px-4 py-3 text-sm text-gray-900">{participant.surname}</td>
<td class="px-4 py-3 text-sm text-gray-900">{participant.email}</td>
<td class="px-4 py-3 text-sm">
{#if participant.scanned}
<div class="flex items-center text-green-600">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
{:else}
<div class="flex items-center text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
{/if}
</td>
<td class="px-4 py-3 text-sm">
{#if participant.email_sent}
<div class="flex items-center text-blue-600">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
{:else}
<div class="flex items-center text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="py-8 text-center">
<p class="text-gray-500">
No participants found. Click "Sync Participants" to load from Google Sheets.
</p>
</div>
{/if}
</div>
<!-- Email Sending Section -->
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Send Emails</h2>
<div class="text-sm text-gray-500">
{participants.filter(p => !p.email_sent).length} uncontacted participants
</div>
</div>
{#if sendingEmails}
<div class="rounded-lg bg-blue-50 p-4 border border-blue-200">
<div class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="text-blue-800 font-medium">Sending {emailProgress.total} emails... Please wait.</span>
</div>
</div>
{:else}
<div class="space-y-4">
{#if participants.filter(p => !p.email_sent).length > 0}
<div class="rounded-lg bg-yellow-50 p-4 border border-yellow-200">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-600 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span class="text-yellow-800 text-sm">
<strong>Warning:</strong> Do not close this window while emails are being sent. The process may take several minutes.
</span>
</div>
</div>
<div class="flex items-center justify-end">
<button
onclick={sendEmailsToUncontacted}
disabled={!event || participants.filter(p => !p.email_sent).length === 0}
class="rounded bg-green-600 px-4 py-2 text-white transition hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Send Emails
</button>
</div>
{:else}
<div class="text-center py-4">
<div class="flex items-center justify-center text-green-600 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
<p class="text-green-700 font-medium">All participants have been contacted!</p>
<p class="text-sm text-green-600">No pending emails to send.</p>
</div>
{/if}
</div>
{/if}
</div>
<!-- Email Results Section -->
{#if emailResults}
<div class="rounded-lg border border-gray-300 bg-white p-6 mb-4">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Email Results</h2>
<div class="text-sm text-gray-500">
{emailResults.summary.success} successful, {emailResults.summary.errors} failed
</div>
</div>
<div class="mb-4">
<div class="flex items-center gap-4 p-3 rounded-lg bg-gray-50">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="text-sm font-medium">Sent: {emailResults.summary.success}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<span class="text-sm font-medium">Failed: {emailResults.summary.errors}</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<span class="text-sm font-medium">Total: {emailResults.summary.total}</span>
</div>
</div>
</div>
{#if emailResults.results.length > 0}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Name</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Email</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-700">Status</th>
</tr>
</thead>
<tbody>
{#each emailResults.results as result}
<tr class="border-b border-gray-200 hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-900">
{result.participant.name} {result.participant.surname}
</td>
<td class="px-4 py-3 text-sm text-gray-900">{result.participant.email}</td>
<td class="px-4 py-3 text-sm">
{#if result.success}
<div class="flex items-center text-green-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-sm font-medium">Sent</span>
</div>
{:else}
<div class="flex items-center text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-sm font-medium">Failed</span>
{#if result.error}
<span class="text-xs text-red-500 ml-2">({result.error})</span>
{/if}
</div>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/if}
{#if error}
<div class="mt-4 rounded border border-red-200 bg-red-50 p-3">
<p class="text-sm text-red-600">{error}</p>
</div>
{/if}

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { onMount } from 'svelte';
import { GoogleAuthManager, createGoogleAuthState } from '$lib/google/auth/manager.js';
// Props
let {
onSuccess,
onError,
disabled = false,
size = 'default',
variant = 'primary'
} = $props<{
onSuccess?: (token: string) => void;
onError?: (error: string) => void;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary';
}>();
// State
let authState = $state(createGoogleAuthState());
let authManager = new GoogleAuthManager(authState);
onMount(() => {
authManager.checkConnection();
});
async function handleConnect() {
if (authState.connecting || disabled) return;
try {
await authManager.connectToGoogle();
if (authState.isConnected && authState.token) {
onSuccess?.(authState.token);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to Google';
onError?.(errorMessage);
}
}
async function handleDisconnect() {
await authManager.disconnectGoogle();
}
// Size classes
const sizeClasses = {
small: 'px-3 py-1.5 text-sm',
default: 'px-4 py-2 text-base',
large: 'px-6 py-3 text-lg'
};
// Variant classes
const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
secondary: 'bg-white hover:bg-gray-50 text-gray-900 border-gray-300'
};
</script>
{#if authState.isConnected}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-sm font-medium text-green-800">Connected</span>
</div>
{#if authState.userEmail}
<div class="flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 border border-blue-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207" />
</svg>
<span class="text-sm font-medium text-blue-800">{authState.userEmail}</span>
</div>
{/if}
<button
onclick={handleDisconnect}
class="text-sm text-red-600 hover:text-red-800 flex items-center gap-1"
aria-label="Disconnect from Google"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Disconnect
</button>
</div>
{:else}
<div class="flex flex-col gap-2">
<button
onclick={handleConnect}
disabled={authState.connecting || disabled}
class="inline-flex items-center border font-medium rounded-md transition disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 {sizeClasses[size as keyof typeof sizeClasses]} {variantClasses[variant as keyof typeof variantClasses]}"
aria-label="Connect to Google"
>
{#if authState.connecting}
<div class="w-4 h-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
Connecting...
{:else}
<svg class="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Connect to Google
{/if}
</button>
{#if authState.error}
<div class="text-sm text-red-600 mt-1">
{authState.error}
</div>
{/if}
</div>
{/if}