From ab82f75dc6da8a1d918f320a40412e892f2a4e59 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:32:10 +0000 Subject: [PATCH 1/4] feat(#89): Add backup functionality for google drive and dropbox --- .env.example | 7 +- README.md | 2 +- src/lib/models/PokedexExportIntegration.ts | 35 ++ .../PokedexExportIntegrationRepository.ts | 158 ++++++ src/lib/services/PokedexExportService.ts | 493 ++++++++++++++++++ src/lib/utils/catchRecordWriteQueue.ts | 20 +- src/lib/utils/env.ts | 4 +- src/lib/utils/oauthState.ts | 56 ++ src/routes/+layout.svelte | 3 + src/routes/api/export-integrations/+server.ts | 38 ++ .../export-integrations/[provider]/+server.ts | 51 ++ .../integrations/dropbox/callback/+server.ts | 115 ++++ .../integrations/dropbox/connect/+server.ts | 59 +++ .../google-drive/callback/+server.ts | 111 ++++ .../google-drive/connect/+server.ts | 64 +++ .../pokedexes/[id]/catch-records/+server.ts | 19 + .../api/pokedexes/[id]/export/+server.ts | 37 ++ src/routes/backup-settings/+page.server.ts | 13 + src/routes/backup-settings/+page.svelte | 178 +++++++ src/routes/my-pokedexes/+page.svelte | 5 +- src/routes/pokedex/[id]/+page.svelte | 79 +++ ...003000_add_pokedex_export_integrations.sql | 42 ++ 22 files changed, 1582 insertions(+), 7 deletions(-) create mode 100644 src/lib/models/PokedexExportIntegration.ts create mode 100644 src/lib/repositories/PokedexExportIntegrationRepository.ts create mode 100644 src/lib/services/PokedexExportService.ts create mode 100644 src/lib/utils/oauthState.ts create mode 100644 src/routes/api/export-integrations/+server.ts create mode 100644 src/routes/api/export-integrations/[provider]/+server.ts create mode 100644 src/routes/api/integrations/dropbox/callback/+server.ts create mode 100644 src/routes/api/integrations/dropbox/connect/+server.ts create mode 100644 src/routes/api/integrations/google-drive/callback/+server.ts create mode 100644 src/routes/api/integrations/google-drive/connect/+server.ts create mode 100644 src/routes/api/pokedexes/[id]/export/+server.ts create mode 100644 src/routes/backup-settings/+page.server.ts create mode 100644 src/routes/backup-settings/+page.svelte create mode 100644 supabase/migrations/20260123003000_add_pokedex_export_integrations.sql diff --git a/.env.example b/.env.example index 63cdaea..88b5a63 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,9 @@ PUBLIC_SUPABASE_URL="your-supabase-project-url" PUBLIC_SUPABASE_ANON_KEY="your-supabase-anon-key" SUPABASE_SERVICE_ROLE_KEY="your-supabase-service-role-key" -PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER="false" \ No newline at end of file +PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER="false" + +GOOGLE_OAUTH_CLIENT_ID="your-google-client-id" +GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret" +DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id" +DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret" diff --git a/README.md b/README.md index c5058de..80aa8c1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ A web app to track completion of a living Pokédex. ## Reference Data Updates -The seed data lives in `supabase/migrations/20260118001000_seed_reference_data.sql`. Update that migration directly when new data is added. +The seed data lives in `supabase/migrations/20260118001000_seed_reference_data.sql`. Update that migration directly when new data is added. You can access a local copy of [Supabase](http://localhost:54323/) to check it. ## Building diff --git a/src/lib/models/PokedexExportIntegration.ts b/src/lib/models/PokedexExportIntegration.ts new file mode 100644 index 0000000..531926f --- /dev/null +++ b/src/lib/models/PokedexExportIntegration.ts @@ -0,0 +1,35 @@ +export type ExportProvider = 'google_drive' | 'dropbox'; + +export interface PokedexExportIntegration { + _id: string; + userId: string; + pokedexId: string | null; + provider: ExportProvider; + enabled: boolean; + fileName: string | null; + folderId: string | null; + path: string | null; + accessToken: string; + refreshToken: string | null; + accessTokenExpiresAt: string | null; + metadata: Record | null; + lastExportedAt: string | null; + lastError: string | null; +} + +export interface PokedexExportIntegrationDB { + id: string; + userId: string; + pokedexId: string | null; + provider: ExportProvider; + enabled: boolean; + fileName: string | null; + folderId: string | null; + path: string | null; + accessToken: string; + refreshToken: string | null; + accessTokenExpiresAt: string | null; + metadata: Record | null; + lastExportedAt: string | null; + lastError: string | null; +} diff --git a/src/lib/repositories/PokedexExportIntegrationRepository.ts b/src/lib/repositories/PokedexExportIntegrationRepository.ts new file mode 100644 index 0000000..89e49f0 --- /dev/null +++ b/src/lib/repositories/PokedexExportIntegrationRepository.ts @@ -0,0 +1,158 @@ +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { + PokedexExportIntegration, + PokedexExportIntegrationDB +} from '$lib/models/PokedexExportIntegration'; + +class PokedexExportIntegrationRepository { + constructor( + private supabase: SupabaseClient, + private userId: string, + private pokedexId: string | null + ) {} + + private transform(db: PokedexExportIntegrationDB): PokedexExportIntegration { + return { + _id: db.id, + userId: db.userId, + pokedexId: db.pokedexId, + provider: db.provider, + enabled: db.enabled, + fileName: db.fileName, + folderId: db.folderId, + path: db.path, + accessToken: db.accessToken, + refreshToken: db.refreshToken, + accessTokenExpiresAt: db.accessTokenExpiresAt, + metadata: db.metadata, + lastExportedAt: db.lastExportedAt, + lastError: db.lastError + }; + } + + private baseQuery() { + return this.supabase.from('pokedex_export_integrations').select('*').eq('userId', this.userId); + } + + private addPokedexScope(query: ReturnType) { + if (this.pokedexId) { + return query.eq('pokedexId', this.pokedexId); + } + return query.is('pokedexId', null); + } + + async listEnabled(): Promise { + const { data, error } = await this.addPokedexScope(this.baseQuery()).eq('enabled', true); + + if (error) { + console.error('Failed to load export integrations:', error); + throw new Error(`Failed to load export integrations: ${error.message}`); + } + if (!data) return []; + return data.map((row) => this.transform(row)); + } + + async listAll(): Promise { + const { data, error } = await this.addPokedexScope(this.baseQuery()); + + if (error) { + console.error('Failed to load export integrations:', error); + throw new Error(`Failed to load export integrations: ${error.message}`); + } + if (!data) return []; + return data.map((row) => this.transform(row)); + } + + async upsert( + data: Partial & + Pick + ): Promise { + const payload: Partial = { + userId: this.userId, + pokedexId: null, + ...data + }; + const { data: result, error } = await this.supabase + .from('pokedex_export_integrations') + .upsert(payload, { + onConflict: 'userId,provider' + }) + .select() + .single(); + + if (error) { + console.error('Failed to upsert export integration:', error); + throw new Error(`Failed to save export integration: ${error.message}`); + } + if (!result) { + throw new Error('Failed to save export integration: No result returned'); + } + return this.transform(result); + } + + async listEnabledForPokedexOrUser(): Promise { + const base = this.supabase + .from('pokedex_export_integrations') + .select('*') + .eq('userId', this.userId) + .eq('enabled', true); + const query = this.pokedexId + ? base.or(`pokedexId.eq.${this.pokedexId},pokedexId.is.null`) + : base.is('pokedexId', null); + const { data, error } = await query; + if (error) { + console.error('Failed to load export integrations:', error); + throw new Error(`Failed to load export integrations: ${error.message}`); + } + if (!data) return []; + return data.map((row) => this.transform(row)); + } + + async updateTokens( + id: string, + patch: { + accessToken?: string; + refreshToken?: string | null; + accessTokenExpiresAt?: string | null; + } + ): Promise { + const query = this.supabase + .from('pokedex_export_integrations') + .update(patch) + .eq('id', id) + .eq('userId', this.userId); + const { error } = this.pokedexId + ? await query.eq('pokedexId', this.pokedexId) + : await query.is('pokedexId', null); + + if (error) { + console.error('Failed to update export integration tokens:', error); + } + } + + async updateExportStatus( + id: string, + patch: { + lastExportedAt?: string | null; + lastError?: string | null; + metadata?: Record | null; + folderId?: string | null; + path?: string | null; + } + ): Promise { + const query = this.supabase + .from('pokedex_export_integrations') + .update(patch) + .eq('id', id) + .eq('userId', this.userId); + const { error } = this.pokedexId + ? await query.eq('pokedexId', this.pokedexId) + : await query.is('pokedexId', null); + + if (error) { + console.error('Failed to update export integration status:', error); + } + } +} + +export default PokedexExportIntegrationRepository; diff --git a/src/lib/services/PokedexExportService.ts b/src/lib/services/PokedexExportService.ts new file mode 100644 index 0000000..85b0678 --- /dev/null +++ b/src/lib/services/PokedexExportService.ts @@ -0,0 +1,493 @@ +import { randomUUID } from 'node:crypto'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { CombinedData } from '$lib/models/CombinedData'; +import type { Pokedex } from '$lib/models/Pokedex'; +import type { ExportProvider, PokedexExportIntegration } from '$lib/models/PokedexExportIntegration'; +import PokedexRepository from '$lib/repositories/PokedexRepository'; +import CombinedDataRepository from '$lib/repositories/CombinedDataRepository'; +import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository'; +import { resolveDexScopes } from '$lib/services/PokedexDexScopeService'; +import { getEnv } from '$lib/utils/env'; + +type ExportFailure = { + integrationId: string; + provider: ExportProvider; + error: string; +}; + +export type PokedexExportResult = { + attempted: number; + succeeded: number; + failed: ExportFailure[]; +}; + +function csvEscape(value: unknown): string { + if (value === null || value === undefined) return ''; + const str = String(value); + if (/[",\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function sanitizeFileName(name: string, fallback: string): string { + const trimmed = name.trim(); + const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-'); + if (!safe) return fallback; + return safe.endsWith('.csv') ? safe : `${safe}.csv`; +} + +function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string { + const headers = [ + 'pokemonId', + 'pokedexNumber', + 'pokemon', + 'form', + 'caught', + 'haveToEvolve', + 'inHome', + 'personalNotes' + ]; + + const lines = [headers.map(csvEscape).join(',')]; + + for (const row of combinedData) { + const entry = row.pokedexEntry; + const catchRecord = row.catchRecord ?? { + caught: false, + haveToEvolve: false, + inHome: false, + hasGigantamaxed: false, + personalNotes: '' + }; + + const values = [ + entry._id, + entry.pokedexNumber, + entry.pokemon, + entry.form || '', + catchRecord.caught, + catchRecord.haveToEvolve, + catchRecord.inHome, + catchRecord.personalNotes || '' + ]; + + lines.push(values.map(csvEscape).join(',')); + } + + return lines.join('\r\n'); +} + +function shouldRefreshToken(expiresAt: string | null): boolean { + if (!expiresAt) return false; + const expiry = new Date(expiresAt).getTime(); + if (!Number.isFinite(expiry)) return false; + // Refresh if within 60 seconds of expiry. + return expiry - Date.now() < 60_000; +} + +async function refreshGoogleToken( + integration: PokedexExportIntegration, + repo: PokedexExportIntegrationRepository +): Promise { + if (!integration.refreshToken) { + throw new Error('Missing Google refresh token'); + } + + const env = getEnv(); + const clientId = env.GOOGLE_OAUTH_CLIENT_ID; + const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + throw new Error('Missing Google OAuth client credentials'); + } + + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: integration.refreshToken, + grant_type: 'refresh_token' + }); + + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Google token refresh failed: ${response.status} ${text}`); + } + + const data = (await response.json()) as { + access_token: string; + expires_in?: number; + }; + + const expiresAt = data.expires_in + ? new Date(Date.now() + data.expires_in * 1000).toISOString() + : null; + + await repo.updateTokens(integration._id, { + accessToken: data.access_token, + accessTokenExpiresAt: expiresAt + }); + + return { + ...integration, + accessToken: data.access_token, + accessTokenExpiresAt: expiresAt + }; +} + +async function refreshDropboxToken( + integration: PokedexExportIntegration, + repo: PokedexExportIntegrationRepository +): Promise { + if (!integration.refreshToken) { + throw new Error('Missing Dropbox refresh token'); + } + + const env = getEnv(); + const clientId = env.DROPBOX_OAUTH_CLIENT_ID; + const clientSecret = env.DROPBOX_OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + throw new Error('Missing Dropbox OAuth client credentials'); + } + + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: integration.refreshToken, + grant_type: 'refresh_token' + }); + + const response = await fetch('https://api.dropbox.com/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString() + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Dropbox token refresh failed: ${response.status} ${text}`); + } + + const data = (await response.json()) as { + access_token: string; + expires_in?: number; + }; + + const expiresAt = data.expires_in + ? new Date(Date.now() + data.expires_in * 1000).toISOString() + : null; + + await repo.updateTokens(integration._id, { + accessToken: data.access_token, + accessTokenExpiresAt: expiresAt + }); + + return { + ...integration, + accessToken: data.access_token, + accessTokenExpiresAt: expiresAt + }; +} + +async function ensureAccessToken( + integration: PokedexExportIntegration, + repo: PokedexExportIntegrationRepository +): Promise { + if (!shouldRefreshToken(integration.accessTokenExpiresAt)) { + return integration; + } + + if (integration.provider === 'google_drive') { + return await refreshGoogleToken(integration, repo); + } + + if (integration.provider === 'dropbox') { + return await refreshDropboxToken(integration, repo); + } + + return integration; +} + +type GoogleDriveMetadata = { + files?: Record; +}; + +function getGoogleFileId(metadata: Record | null, pokedexId: string): string | null { + const data = metadata as GoogleDriveMetadata | null; + const fileId = data?.files?.[pokedexId]; + return typeof fileId === 'string' && fileId ? fileId : null; +} + +function withGoogleFileId( + metadata: Record | null, + pokedexId: string, + fileId: string +): Record { + const data = (metadata as GoogleDriveMetadata | null) ?? {}; + const files = { ...(data.files ?? {}) }; + files[pokedexId] = fileId; + return { ...data, files }; +} + +function withoutGoogleFileId( + metadata: Record | null, + pokedexId: string +): Record | null { + const data = (metadata as GoogleDriveMetadata | null) ?? {}; + if (!data.files || !data.files[pokedexId]) return metadata; + const files = { ...data.files }; + delete files[pokedexId]; + const next = { ...data, files }; + if (Object.keys(files).length === 0) { + delete (next as GoogleDriveMetadata).files; + } + return next; +} + +async function uploadToGoogleDrive( + integration: PokedexExportIntegration, + repo: PokedexExportIntegrationRepository, + pokedex: Pokedex, + csv: string +): Promise { + const refreshed = await ensureAccessToken(integration, repo); + const fileName = sanitizeFileName(pokedex.name, 'pokedex.csv'); + + let folderId = refreshed.folderId?.trim() ?? ''; + if (!folderId) { + try { + const folderResponse = await fetch( + 'https://www.googleapis.com/drive/v3/files?' + + new URLSearchParams({ + q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false", + fields: 'files(id,name)', + pageSize: '1' + }).toString(), + { + headers: { + Authorization: `Bearer ${refreshed.accessToken}` + } + } + ); + + if (folderResponse.ok) { + const folderData = (await folderResponse.json()) as { files?: Array<{ id: string }> }; + folderId = folderData.files?.[0]?.id ?? ''; + } + } catch (error) { + console.warn('Failed to lookup Google Drive folder, using root:', error); + } + } + + if (!folderId) { + try { + const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', { + method: 'POST', + headers: { + Authorization: `Bearer ${refreshed.accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'Living Dex Tracker', + mimeType: 'application/vnd.google-apps.folder' + }) + }); + if (createResponse.ok) { + const created = (await createResponse.json()) as { id?: string }; + folderId = created.id ?? ''; + } + } catch (error) { + console.warn('Failed to create Google Drive folder, using root:', error); + } + } + + const existingFileId = getGoogleFileId(refreshed.metadata, pokedex._id); + let currentFileId = existingFileId; + + for (let attempt = 0; attempt < 2; attempt++) { + const metadata: Record = { + name: fileName, + mimeType: 'text/csv' + }; + if (folderId && !currentFileId) { + metadata.parents = [folderId]; + } + + const boundary = `boundary_${randomUUID()}`; + const body = [ + `--${boundary}`, + 'Content-Type: application/json; charset=UTF-8', + '', + JSON.stringify(metadata), + `--${boundary}`, + 'Content-Type: text/csv', + '', + csv, + `--${boundary}--` + ].join('\r\n'); + + const url = currentFileId + ? `https://www.googleapis.com/upload/drive/v3/files/${currentFileId}?uploadType=multipart` + : 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; + const method = currentFileId ? 'PATCH' : 'POST'; + const uploadUrl = currentFileId && folderId + ? `${url}&addParents=${encodeURIComponent(folderId)}` + : url; + + const response = await fetch(uploadUrl, { + method, + headers: { + Authorization: `Bearer ${refreshed.accessToken}`, + 'Content-Type': `multipart/related; boundary=${boundary}` + }, + body + }); + + if (!response.ok) { + const text = await response.text(); + if (currentFileId && response.status === 404 && attempt === 0) { + const nextMetadata = withoutGoogleFileId(refreshed.metadata, pokedex._id); + await repo.updateExportStatus(refreshed._id, { metadata: nextMetadata }); + currentFileId = null; + continue; + } + throw new Error(`Google Drive upload failed: ${response.status} ${text}`); + } + + const data = (await response.json()) as { id?: string }; + if (data.id && folderId) { + await repo.updateExportStatus(refreshed._id, { + folderId + }); + } + + if (!currentFileId && data.id) { + const nextMetadata = withGoogleFileId(refreshed.metadata, pokedex._id, data.id); + await repo.updateExportStatus(refreshed._id, { metadata: nextMetadata }); + } + return; + } +} + +async function uploadToDropbox( + integration: PokedexExportIntegration, + repo: PokedexExportIntegrationRepository, + pokedex: Pokedex, + csv: string +): Promise { + const refreshed = await ensureAccessToken(integration, repo); + const fileName = sanitizeFileName(pokedex.name, 'pokedex.csv'); + const trimmedPath = refreshed.path?.trim() ?? ''; + let targetPath = trimmedPath; + if (!targetPath) { + targetPath = `/${fileName}`; + } else if (targetPath.endsWith('/')) { + targetPath = `${targetPath}${fileName}`; + } else if (!targetPath.toLowerCase().endsWith('.csv')) { + targetPath = `${targetPath}/${fileName}`; + } + + const response = await fetch('https://content.dropboxapi.com/2/files/upload', { + method: 'POST', + headers: { + Authorization: `Bearer ${refreshed.accessToken}`, + 'Content-Type': 'application/octet-stream', + 'Dropbox-API-Arg': JSON.stringify({ + path: targetPath, + mode: 'overwrite', + mute: true + }) + }, + body: csv + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Dropbox upload failed: ${response.status} ${text}`); + } +} + +async function exportToIntegration( + integration: PokedexExportIntegration, + repo: PokedexExportIntegrationRepository, + pokedex: Pokedex, + csv: string +): Promise { + if (integration.provider === 'google_drive') { + await uploadToGoogleDrive(integration, repo, pokedex, csv); + return; + } + if (integration.provider === 'dropbox') { + await uploadToDropbox(integration, repo, pokedex, csv); + } +} + +export async function exportPokedexIfConfigured( + supabase: SupabaseClient, + userId: string, + pokedexId: string +): Promise { + const pokedexRepo = new PokedexRepository(supabase, userId); + const pokedex = await pokedexRepo.findById(pokedexId); + if (!pokedex) { + return { attempted: 0, succeeded: 0, failed: [] }; + } + + const integrationRepo = new PokedexExportIntegrationRepository(supabase, userId, pokedexId); + const integrations = await integrationRepo.listEnabledForPokedexOrUser(); + if (integrations.length === 0) { + return { attempted: 0, succeeded: 0, failed: [] }; + } + + const dexScopes = await resolveDexScopes(supabase, pokedex); + const combinedRepo = new CombinedDataRepository(supabase, userId, pokedexId); + const combinedData = await combinedRepo.findAllCombinedData( + userId, + !!pokedex.isFormDex, + '', + pokedex.gameScope || '', + dexScopes + ); + const csv = buildCsv(pokedex, combinedData); + + const failures: ExportFailure[] = []; + let successes = 0; + + for (const integration of integrations) { + const scopedRepo = new PokedexExportIntegrationRepository( + supabase, + userId, + integration.pokedexId ?? null + ); + try { + await exportToIntegration(integration, scopedRepo, pokedex, csv); + await scopedRepo.updateExportStatus(integration._id, { + lastExportedAt: new Date().toISOString(), + lastError: null + }); + successes++; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push({ + integrationId: integration._id, + provider: integration.provider, + error: message + }); + await scopedRepo.updateExportStatus(integration._id, { + lastError: message + }); + console.error('Failed to export pokedex:', integration.provider, message); + } + } + + return { + attempted: integrations.length, + succeeded: successes, + failed: failures + }; +} diff --git a/src/lib/utils/catchRecordWriteQueue.ts b/src/lib/utils/catchRecordWriteQueue.ts index 754d03f..6b6da6c 100644 --- a/src/lib/utils/catchRecordWriteQueue.ts +++ b/src/lib/utils/catchRecordWriteQueue.ts @@ -14,6 +14,7 @@ type QueueItem = { attempts: number; notBefore: number; // unix ms debounceTimer: ReturnType | null; + version: number; }; export type CreateCatchRecordWriteQueueOptions = { @@ -157,14 +158,25 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue } // Success: drop the flushed keys. - for (const [k] of eligible) { - items.delete(k); + for (const [k, sentItem] of eligible) { + const current = items.get(k); + if (!current) { + items.delete(k); + continue; + } + // Only clear if nothing newer was enqueued after this batch started. + if (current.version === sentItem.version) { + items.delete(k); + } } updateStatus({ lastError: null, lastSuccessfulFlushAt: Date.now() }); } catch (e) { const message = e instanceof Error ? e.message : String(e); // Backoff all eligible items and keep them. for (const [k, item] of eligible) { + const current = items.get(k); + if (!current) continue; + if (current.version !== item.version) continue; const nextAttempts = item.attempts + 1; items.set(k, { ...item, @@ -199,6 +211,7 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue const now = Date.now(); const existing = items.get(k); const debounceMs = opts?.debounceMs ?? 0; + const nextVersion = (existing?.version ?? 0) + 1; // Clear any pending debounce timer: latest state always wins. if (existing?.debounceTimer) { @@ -222,7 +235,8 @@ export function createCatchRecordWriteQueue(options: CreateCatchRecordWriteQueue record, attempts: existing?.attempts ?? 0, notBefore, - debounceTimer + debounceTimer, + version: nextVersion }); updateStatus({}); if (opts?.flushSoon !== false) scheduleFlush(); diff --git a/src/lib/utils/env.ts b/src/lib/utils/env.ts index b207381..b804d6f 100644 --- a/src/lib/utils/env.ts +++ b/src/lib/utils/env.ts @@ -1,3 +1,5 @@ +import { env } from '$env/dynamic/private'; + export function getEnv() { - return process.env; + return env; } diff --git a/src/lib/utils/oauthState.ts b/src/lib/utils/oauthState.ts new file mode 100644 index 0000000..2d8fe77 --- /dev/null +++ b/src/lib/utils/oauthState.ts @@ -0,0 +1,56 @@ +import { randomUUID } from 'node:crypto'; +import type { RequestEvent } from '@sveltejs/kit'; + +export type OAuthStatePayload = { + state: string; + userId: string; + pokedexId?: string | null; + provider: string; + fileName?: string | null; + folderId?: string | null; + path?: string | null; + returnTo?: string | null; +}; + +const STATE_MAX_AGE = 10 * 60; // 10 minutes + +export function createOAuthState(): string { + return randomUUID(); +} + +function cookieName(provider: string): string { + return `oauth_state_${provider}`; +} + +export function setOAuthStateCookie( + event: RequestEvent, + provider: string, + payload: OAuthStatePayload +) { + event.cookies.set(cookieName(provider), JSON.stringify(payload), { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: event.url.protocol === 'https:', + maxAge: STATE_MAX_AGE + }); +} + +export function readOAuthStateCookie( + event: RequestEvent, + provider: string +): OAuthStatePayload | null { + const raw = event.cookies.get(cookieName(provider)); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as OAuthStatePayload; + if (!parsed?.state || !parsed?.userId) return null; + return parsed; + } catch { + return null; + } +} + +export function clearOAuthStateCookie(event: RequestEvent, provider: string) { + event.cookies.delete(cookieName(provider), { path: '/' }); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index eb2adad..aa1abde 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -159,6 +159,9 @@
  • My Pokédexes
  • +
  • + Backup Settings +
  • {:else}
  • diff --git a/src/routes/api/export-integrations/+server.ts b/src/routes/api/export-integrations/+server.ts new file mode 100644 index 0000000..d5c97ec --- /dev/null +++ b/src/routes/api/export-integrations/+server.ts @@ -0,0 +1,38 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository'; +import { requireAuth } from '$lib/utils/auth'; + +export const GET = async (event: RequestEvent) => { + try { + const userId = await requireAuth(event); + + const { session } = await event.locals.safeGetSession(); + if (session) { + await event.locals.supabase.auth.setSession(session); + } + + const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null); + const integrations = await repo.listAll(); + + const safe = integrations.map((integration) => ({ + id: integration._id, + provider: integration.provider, + enabled: integration.enabled, + fileName: integration.fileName, + folderId: integration.folderId, + path: integration.path, + metadata: integration.metadata, + lastExportedAt: integration.lastExportedAt, + lastError: integration.lastError + })); + + return json(safe); + } catch (err) { + console.error(err); + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + return json({ error: 'Internal Server Error' }, { status: 500 }); + } +}; diff --git a/src/routes/api/export-integrations/[provider]/+server.ts b/src/routes/api/export-integrations/[provider]/+server.ts new file mode 100644 index 0000000..d6316a9 --- /dev/null +++ b/src/routes/api/export-integrations/[provider]/+server.ts @@ -0,0 +1,51 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { requireAuth } from '$lib/utils/auth'; + +const ALLOWED_PROVIDERS = new Set(['google_drive', 'dropbox']); + +export const PUT = async (event: RequestEvent) => { + try { + const userId = await requireAuth(event); + const { provider } = event.params; + + if (!provider || !ALLOWED_PROVIDERS.has(provider)) { + return json({ error: 'Invalid provider' }, { status: 400 }); + } + + const { session } = await event.locals.safeGetSession(); + if (session) { + await event.locals.supabase.auth.setSession(session); + } + + const body = (await event.request.json()) as { + folderId?: string | null; + path?: string | null; + }; + + const patch: Record = {}; + if (body.folderId !== undefined) patch.folderId = body.folderId; + if (body.path !== undefined) patch.path = body.path; + + const { data, error } = await event.locals.supabase + .from('pokedex_export_integrations') + .update(patch) + .eq('userId', userId) + .is('pokedexId', null) + .eq('provider', provider) + .select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError') + .single(); + + if (error || !data) { + return json({ error: 'Integration not found' }, { status: 404 }); + } + + return json(data); + } catch (err) { + console.error(err); + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + return json({ error: 'Internal Server Error' }, { status: 500 }); + } +}; diff --git a/src/routes/api/integrations/dropbox/callback/+server.ts b/src/routes/api/integrations/dropbox/callback/+server.ts new file mode 100644 index 0000000..0b46fc9 --- /dev/null +++ b/src/routes/api/integrations/dropbox/callback/+server.ts @@ -0,0 +1,115 @@ +import { json, redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository'; +import { requireAuth } from '$lib/utils/auth'; +import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState'; +import { getEnv } from '$lib/utils/env'; + +export const GET = async (event: RequestEvent) => { + try { + const userId = await requireAuth(event); + const url = new URL(event.request.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const errorParam = url.searchParams.get('error'); + + const oauthState = readOAuthStateCookie(event, 'dropbox'); + if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) { + clearOAuthStateCookie(event, 'dropbox'); + return json({ error: 'Invalid OAuth state' }, { status: 400 }); + } + + clearOAuthStateCookie(event, 'dropbox'); + + const fallbackReturnTo = oauthState.pokedexId + ? `/pokedex/${oauthState.pokedexId}` + : '/backup-settings'; + const baseReturnTo = + oauthState.returnTo && oauthState.returnTo.startsWith('/') + ? oauthState.returnTo + : fallbackReturnTo; + const returnTo = baseReturnTo.includes('?') + ? `${baseReturnTo}&export=dropbox` + : `${baseReturnTo}?export=dropbox`; + + if (errorParam) { + throw redirect(302, `${returnTo}-denied`); + } + + if (!code) { + return json({ error: 'Missing OAuth code' }, { status: 400 }); + } + + const env = getEnv(); + const clientId = env.DROPBOX_OAUTH_CLIENT_ID; + const clientSecret = env.DROPBOX_OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + return json({ error: 'Missing Dropbox OAuth credentials' }, { status: 500 }); + } + + const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`; + const tokenParams = new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code' + }); + + const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenParams.toString() + }); + + if (!tokenResponse.ok) { + const text = await tokenResponse.text(); + return json({ error: `Dropbox token exchange failed: ${text}` }, { status: 500 }); + } + + const tokenData = (await tokenResponse.json()) as { + access_token: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + }; + + const expiresAt = tokenData.expires_in + ? new Date(Date.now() + tokenData.expires_in * 1000).toISOString() + : null; + + const { session } = await event.locals.safeGetSession(); + if (session) { + await event.locals.supabase.auth.setSession(session); + } + + const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null); + + const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null; + const path = oauthState.path?.trim() ? oauthState.path : null; + + try { + await repo.upsert({ + provider: 'dropbox', + enabled: true, + fileName, + path, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token ?? null, + accessTokenExpiresAt: expiresAt, + metadata: tokenData.scope ? { scope: tokenData.scope } : null + }); + } catch (saveError) { + console.error('Dropbox integration save failed:', saveError); + throw redirect(302, `${returnTo}-error`); + } + + throw redirect(302, `${returnTo}-connected`); + } catch (err) { + console.error(err); + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + return json({ error: 'Internal Server Error' }, { status: 500 }); + } +}; diff --git a/src/routes/api/integrations/dropbox/connect/+server.ts b/src/routes/api/integrations/dropbox/connect/+server.ts new file mode 100644 index 0000000..83e7065 --- /dev/null +++ b/src/routes/api/integrations/dropbox/connect/+server.ts @@ -0,0 +1,59 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import PokedexRepository from '$lib/repositories/PokedexRepository'; +import { requireAuth } from '$lib/utils/auth'; +import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState'; +import { getEnv } from '$lib/utils/env'; + +export const GET = async (event: RequestEvent) => { + const userId = await requireAuth(event); + const url = new URL(event.request.url); + const pokedexId = url.searchParams.get('pokedexId'); + const fileName = url.searchParams.get('fileName'); + const path = url.searchParams.get('path'); + const returnToRaw = url.searchParams.get('returnTo'); + const returnTo = + returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings'; + + if (pokedexId) { + const { session } = await event.locals.safeGetSession(); + if (session) { + await event.locals.supabase.auth.setSession(session); + } + + const pokedexRepo = new PokedexRepository(event.locals.supabase, userId); + const pokedex = await pokedexRepo.findById(pokedexId); + if (!pokedex) { + throw redirect(302, '/my-pokedexes'); + } + } + + const env = getEnv(); + const clientId = env.DROPBOX_OAUTH_CLIENT_ID; + if (!clientId) { + throw redirect(302, `/pokedex/${pokedexId}?export=dropbox-missing-client`); + } + + const state = createOAuthState(); + setOAuthStateCookie(event, 'dropbox', { + state, + userId, + pokedexId, + provider: 'dropbox', + fileName, + path, + returnTo + }); + + const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`; + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + state, + token_access_type: 'offline', + scope: 'files.content.write' + }); + + throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`); +}; diff --git a/src/routes/api/integrations/google-drive/callback/+server.ts b/src/routes/api/integrations/google-drive/callback/+server.ts new file mode 100644 index 0000000..2e8ad24 --- /dev/null +++ b/src/routes/api/integrations/google-drive/callback/+server.ts @@ -0,0 +1,111 @@ +import { json, redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository'; +import { requireAuth } from '$lib/utils/auth'; +import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState'; +import { getEnv } from '$lib/utils/env'; + +export const GET = async (event: RequestEvent) => { + try { + const userId = await requireAuth(event); + const url = new URL(event.request.url); + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const errorParam = url.searchParams.get('error'); + + const oauthState = readOAuthStateCookie(event, 'google_drive'); + if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) { + clearOAuthStateCookie(event, 'google_drive'); + return json({ error: 'Invalid OAuth state' }, { status: 400 }); + } + + clearOAuthStateCookie(event, 'google_drive'); + + const fallbackReturnTo = oauthState.pokedexId + ? `/pokedex/${oauthState.pokedexId}` + : '/backup-settings'; + const baseReturnTo = + oauthState.returnTo && oauthState.returnTo.startsWith('/') + ? oauthState.returnTo + : fallbackReturnTo; + const returnTo = baseReturnTo.includes('?') + ? `${baseReturnTo}&export=google` + : `${baseReturnTo}?export=google`; + + if (errorParam) { + throw redirect(302, `${returnTo}-denied`); + } + + if (!code) { + return json({ error: 'Missing OAuth code' }, { status: 400 }); + } + + const env = getEnv(); + const clientId = env.GOOGLE_OAUTH_CLIENT_ID; + const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + return json({ error: 'Missing Google OAuth credentials' }, { status: 500 }); + } + + const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`; + + const tokenParams = new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code' + }); + + const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenParams.toString() + }); + + if (!tokenResponse.ok) { + const text = await tokenResponse.text(); + return json({ error: `Google token exchange failed: ${text}` }, { status: 500 }); + } + + const tokenData = (await tokenResponse.json()) as { + access_token: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + }; + + const expiresAt = tokenData.expires_in + ? new Date(Date.now() + tokenData.expires_in * 1000).toISOString() + : null; + + const { session } = await event.locals.safeGetSession(); + if (session) { + await event.locals.supabase.auth.setSession(session); + } + + const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null); + + const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null; + const folderId = oauthState.folderId?.trim() ? oauthState.folderId : null; + + await repo.upsert({ + provider: 'google_drive', + enabled: true, + fileName, + folderId, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token ?? null, + accessTokenExpiresAt: expiresAt, + metadata: tokenData.scope ? { scope: tokenData.scope } : null + }); + + throw redirect(302, `${returnTo}-connected`); + } catch (err) { + console.error(err); + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + return json({ error: 'Internal Server Error' }, { status: 500 }); + } +}; diff --git a/src/routes/api/integrations/google-drive/connect/+server.ts b/src/routes/api/integrations/google-drive/connect/+server.ts new file mode 100644 index 0000000..e245949 --- /dev/null +++ b/src/routes/api/integrations/google-drive/connect/+server.ts @@ -0,0 +1,64 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import PokedexRepository from '$lib/repositories/PokedexRepository'; +import { requireAuth } from '$lib/utils/auth'; +import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState'; +import { getEnv } from '$lib/utils/env'; + +export const GET = async (event: RequestEvent) => { + const userId = await requireAuth(event); + const url = new URL(event.request.url); + const pokedexId = url.searchParams.get('pokedexId'); + const fileName = url.searchParams.get('fileName'); + const folderId = url.searchParams.get('folderId'); + const returnToRaw = url.searchParams.get('returnTo'); + const returnTo = + returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings'; + + if (pokedexId) { + const { session } = await event.locals.safeGetSession(); + if (session) { + await event.locals.supabase.auth.setSession(session); + } + + const pokedexRepo = new PokedexRepository(event.locals.supabase, userId); + const pokedex = await pokedexRepo.findById(pokedexId); + if (!pokedex) { + throw redirect(302, '/my-pokedexes'); + } + } + + const env = getEnv(); + const clientId = env.GOOGLE_OAUTH_CLIENT_ID; + if (!clientId) { + throw redirect(302, `/pokedex/${pokedexId}?export=google-missing-client`); + } + + const state = createOAuthState(); + setOAuthStateCookie(event, 'google_drive', { + state, + userId, + pokedexId, + provider: 'google_drive', + fileName, + folderId, + returnTo + }); + + const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`; + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: [ + 'https://www.googleapis.com/auth/drive.file', + 'https://www.googleapis.com/auth/drive.metadata.readonly' + ].join(' '), + access_type: 'offline', + prompt: 'consent', + include_granted_scopes: 'true', + state + }); + + throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`); +}; diff --git a/src/routes/api/pokedexes/[id]/catch-records/+server.ts b/src/routes/api/pokedexes/[id]/catch-records/+server.ts index 872b3ca..72179fb 100644 --- a/src/routes/api/pokedexes/[id]/catch-records/+server.ts +++ b/src/routes/api/pokedexes/[id]/catch-records/+server.ts @@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit'; import { type CatchRecord } from '$lib/models/CatchRecord'; import CatchRecordRepository from '$lib/repositories/CatchRecordRepository'; import PokedexRepository from '$lib/repositories/PokedexRepository'; +import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService'; import { requireAuth } from '$lib/utils/auth'; import type { RequestEvent } from '@sveltejs/kit'; @@ -73,6 +74,11 @@ export const PUT = async (event: RequestEvent) => { const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId); const upsertedRecord = await repo.upsert(data); + try { + await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId); + } catch (exportError) { + console.error('Failed to export pokedex after catch record update:', exportError); + } return json(upsertedRecord); } catch (err) { console.error(err); @@ -137,6 +143,11 @@ export const POST = async (event: RequestEvent) => { // Prefer a single bulk upsert when the DB supports it; fall back to per-record upserts. try { const upserted = await repo.bulkUpsert(scoped); + try { + await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId); + } catch (exportError) { + console.error('Failed to export pokedex after bulk catch record update:', exportError); + } return json(upserted); } catch (bulkErr) { console.warn('Bulk upsert failed; falling back to per-record upserts:', bulkErr); @@ -145,6 +156,14 @@ export const POST = async (event: RequestEvent) => { const upsertedRecord = await repo.upsert(record); insertedRecords.push(upsertedRecord); } + try { + await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId); + } catch (exportError) { + console.error( + 'Failed to export pokedex after per-record catch updates:', + exportError + ); + } return json(insertedRecords); } } catch (err) { diff --git a/src/routes/api/pokedexes/[id]/export/+server.ts b/src/routes/api/pokedexes/[id]/export/+server.ts new file mode 100644 index 0000000..0930489 --- /dev/null +++ b/src/routes/api/pokedexes/[id]/export/+server.ts @@ -0,0 +1,37 @@ +import { json } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import PokedexRepository from '$lib/repositories/PokedexRepository'; +import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService'; +import { requireAuth } from '$lib/utils/auth'; + +export const POST = async (event: RequestEvent) => { + try { + const userId = await requireAuth(event); + const { id: pokedexId } = event.params; + + if (!pokedexId) { + return json({ error: 'Pokedex ID is required' }, { status: 400 }); + } + + const { session } = await event.locals.safeGetSession(); + if (session) { + await event.locals.supabase.auth.setSession(session); + } + + const pokedexRepo = new PokedexRepository(event.locals.supabase, userId); + const pokedex = await pokedexRepo.findById(pokedexId); + + if (!pokedex) { + return json({ error: 'Pokedex not found' }, { status: 404 }); + } + + const result = await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId); + return json(result); + } catch (err) { + console.error(err); + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + return json({ error: 'Internal Server Error' }, { status: 500 }); + } +}; diff --git a/src/routes/backup-settings/+page.server.ts b/src/routes/backup-settings/+page.server.ts new file mode 100644 index 0000000..67148f6 --- /dev/null +++ b/src/routes/backup-settings/+page.server.ts @@ -0,0 +1,13 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const { safeGetSession } = locals; + const { session, user } = await safeGetSession(); + + if (!session || !user) { + throw redirect(303, '/signin'); + } + + return {}; +}; diff --git a/src/routes/backup-settings/+page.svelte b/src/routes/backup-settings/+page.svelte new file mode 100644 index 0000000..23a0a19 --- /dev/null +++ b/src/routes/backup-settings/+page.svelte @@ -0,0 +1,178 @@ + + + + Backup Settings - Living Dex Tracker + + +
    +
    +

    Backup Settings

    + Back to My Pokédexes +
    + +
    +
    +

    + Backups automatically export every time you update a pokédex. Each export is named after the + pokédex, so you only need to connect once. +

    + + {#if exportLoading} +

    Loading export settings…

    + {:else if exportError} +

    {exportError}

    + {/if} + +
    +
    +
    +

    Google Drive

    + + {googleIntegration ? 'Connected' : 'Not Connected'} + +
    +
    +
    + +
    + {#if googleIntegration?.folderId} + + {/if} + {#if googleIntegration?.lastExportedAt} +

    + Last export: {formatTimestamp(googleIntegration.lastExportedAt)} +

    + {/if} + {#if googleIntegration?.lastError} +

    {googleIntegration.lastError}

    + {/if} +
    +
    + +
    +
    +

    Dropbox

    + + {dropboxIntegration ? 'Connected' : 'Not Connected'} + +
    +
    +
    + +
    + {#if dropboxIntegration} + + {/if} + {#if dropboxIntegration?.lastExportedAt} +

    + Last export: {formatTimestamp(dropboxIntegration.lastExportedAt)} +

    + {/if} + {#if dropboxIntegration?.lastError} +

    {dropboxIntegration.lastError}

    + {/if} +
    +
    +
    +
    +
    +
    diff --git a/src/routes/my-pokedexes/+page.svelte b/src/routes/my-pokedexes/+page.svelte index 42a3bd9..eb5a762 100644 --- a/src/routes/my-pokedexes/+page.svelte +++ b/src/routes/my-pokedexes/+page.svelte @@ -167,7 +167,10 @@

    My Pokédexes

    - +
    + Backup Settings + +
    {#if pokedexes.length === 0} diff --git a/src/routes/pokedex/[id]/+page.svelte b/src/routes/pokedex/[id]/+page.svelte index fa7a2ea..d6767cf 100644 --- a/src/routes/pokedex/[id]/+page.svelte +++ b/src/routes/pokedex/[id]/+page.svelte @@ -17,6 +17,7 @@ import type { Pokedex } from '$lib/models/Pokedex'; import type { PageData } from './$types'; + export let data: PageData; // Get pokédex from server load (guarded for transient undefined during navigation/HMR) @@ -24,6 +25,13 @@ let pokedexId = ''; $: pokedex = data?.pokedex; $: pokedexId = pokedex?._id ?? ''; + $: if (browser && pokedexId) { + try { + localStorage.setItem('activePokedexId', pokedexId); + } catch { + // Ignore storage errors (private mode, etc.) + } + } let combinedData = null as CombinedData[] | null; let currentPage = 1 as number; @@ -49,6 +57,57 @@ lastFlushAttemptAt: null, lastSuccessfulFlushAt: null }; + let exportAfterFlush = false; + let exportInFlight = false; + let exportTimer: ReturnType | null = null; + let exportGeneration = 0; + let exportInFlightGeneration = 0; + let toggleFlushTimer: ReturnType | null = null; + + function scheduleExportIfIdle() { + if (!browser) return; + if (!pokedexId) return; + const isIdle = catchWriteStatus.pending === 0 && catchWriteStatus.inFlight === 0; + if (!isIdle) return; + if (!exportAfterFlush || exportInFlight) return; + if (exportTimer) return; + exportTimer = setTimeout(async () => { + exportTimer = null; + if (!exportAfterFlush || exportInFlight) return; + exportInFlight = true; + exportInFlightGeneration = exportGeneration; + try { + await fetch(`/api/pokedexes/${pokedexId}/export`, { + method: 'POST', + credentials: 'include' + }); + if (exportGeneration === exportInFlightGeneration) { + exportAfterFlush = false; + } + } catch (error) { + console.error('Failed to auto-export pokedex:', error); + } finally { + exportInFlight = false; + scheduleExportIfIdle(); + } + }, 400); + } + + function scheduleToggleFlush() { + if (!catchWriteQueue) return; + if (toggleFlushTimer) { + clearTimeout(toggleFlushTimer); + } + toggleFlushTimer = setTimeout(async () => { + toggleFlushTimer = null; + try { + await catchWriteQueue?.flushNow(); + } catch (error) { + console.error('Failed to flush catch record updates:', error); + } + }, 250); + } + // Derive from pokedex config $: showOrigins = !!pokedex?.isOriginDex; @@ -94,9 +153,20 @@ catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => { catchWriteStatus = s; + if (s.pending > 0 || s.inFlight > 0) { + if (exportTimer) { + clearTimeout(exportTimer); + exportTimer = null; + } + return; + } + + scheduleExportIfIdle(); }); } + + function applyOptimisticCatchRecordUpdate(next: CatchRecord) { if (!combinedData) return; const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId); @@ -184,6 +254,11 @@ debounceMs, flushSoon: true }); + exportGeneration++; + exportAfterFlush = true; + if (source === 'toggle') { + scheduleToggleFlush(); + } if (source === 'notes-blur') { // Force a flush attempt when the user leaves the textarea. await catchWriteQueue?.flushNow(); @@ -239,6 +314,8 @@ if (record.haveToEvolve) record.caught = false; applyOptimisticCatchRecordUpdate(record); catchWriteQueue?.enqueue(record, { flushSoon: true }); + exportGeneration++; + exportAfterFlush = true; } // Kick a flush attempt (batching will occur inside the queue). await catchWriteQueue?.flushNow(); @@ -362,6 +439,7 @@ window.clearInterval(reconcileInterval); }; }); + @@ -496,6 +574,7 @@ {#if pokedex.description}

    {pokedex.description}

    {/if} +
    diff --git a/supabase/migrations/20260123003000_add_pokedex_export_integrations.sql b/supabase/migrations/20260123003000_add_pokedex_export_integrations.sql new file mode 100644 index 0000000..63c326e --- /dev/null +++ b/supabase/migrations/20260123003000_add_pokedex_export_integrations.sql @@ -0,0 +1,42 @@ +-- Enable exports to external providers (Google Drive / Dropbox) +CREATE TABLE pokedex_export_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + "pokedexId" UUID REFERENCES pokedexes(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('google_drive', 'dropbox')), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + "fileName" TEXT, + "folderId" TEXT, + path TEXT, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT, + "accessTokenExpiresAt" TIMESTAMPTZ, + metadata JSONB, + "lastExportedAt" TIMESTAMPTZ, + "lastError" TEXT, + "createdAt" TIMESTAMPTZ DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ DEFAULT NOW(), + UNIQUE ("userId", provider) +); + +CREATE INDEX idx_pokedex_export_integrations_user_id ON pokedex_export_integrations("userId"); +CREATE INDEX idx_pokedex_export_integrations_pokedex_id ON pokedex_export_integrations("pokedexId"); +CREATE INDEX idx_pokedex_export_integrations_provider ON pokedex_export_integrations(provider); + +CREATE TRIGGER update_pokedex_export_integrations_updated_at + BEFORE UPDATE ON pokedex_export_integrations + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +ALTER TABLE pokedex_export_integrations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own export integrations" ON pokedex_export_integrations + FOR SELECT USING ("userId" = auth.uid()); + +CREATE POLICY "Users can create own export integrations" ON pokedex_export_integrations + FOR INSERT WITH CHECK ("userId" = auth.uid()); + +CREATE POLICY "Users can update own export integrations" ON pokedex_export_integrations + FOR UPDATE USING ("userId" = auth.uid()) WITH CHECK ("userId" = auth.uid()); + +CREATE POLICY "Users can delete own export integrations" ON pokedex_export_integrations + FOR DELETE USING ("userId" = auth.uid()); From 8141bd624ba872acec7bce47e1bc0d20d5185ac0 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:18:57 +0000 Subject: [PATCH 2/4] refactor(#89): Address PR comments --- .../export-integrations/[provider]/+server.ts | 3 +++ .../integrations/dropbox/connect/+server.ts | 5 +++- .../google-drive/callback/+server.ts | 25 ++++++++++------- .../google-drive/connect/+server.ts | 5 +++- src/routes/backup-settings/+page.svelte | 2 ++ src/routes/pokedex/[id]/+page.svelte | 27 ++++++++++++++++++- 6 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/routes/api/export-integrations/[provider]/+server.ts b/src/routes/api/export-integrations/[provider]/+server.ts index d6316a9..5afeeee 100644 --- a/src/routes/api/export-integrations/[provider]/+server.ts +++ b/src/routes/api/export-integrations/[provider]/+server.ts @@ -26,6 +26,9 @@ export const PUT = async (event: RequestEvent) => { const patch: Record = {}; if (body.folderId !== undefined) patch.folderId = body.folderId; if (body.path !== undefined) patch.path = body.path; + if (Object.keys(patch).length === 0) { + return json({ error: 'No fields to update' }, { status: 400 }); + } const { data, error } = await event.locals.supabase .from('pokedex_export_integrations') diff --git a/src/routes/api/integrations/dropbox/connect/+server.ts b/src/routes/api/integrations/dropbox/connect/+server.ts index 83e7065..904fbb6 100644 --- a/src/routes/api/integrations/dropbox/connect/+server.ts +++ b/src/routes/api/integrations/dropbox/connect/+server.ts @@ -31,7 +31,10 @@ export const GET = async (event: RequestEvent) => { const env = getEnv(); const clientId = env.DROPBOX_OAUTH_CLIENT_ID; if (!clientId) { - throw redirect(302, `/pokedex/${pokedexId}?export=dropbox-missing-client`); + const missingClientRedirect = pokedexId + ? `/pokedex/${pokedexId}?export=dropbox-missing-client` + : '/pokedex?export=dropbox-missing-client'; + throw redirect(302, missingClientRedirect); } const state = createOAuthState(); diff --git a/src/routes/api/integrations/google-drive/callback/+server.ts b/src/routes/api/integrations/google-drive/callback/+server.ts index 2e8ad24..d22dba0 100644 --- a/src/routes/api/integrations/google-drive/callback/+server.ts +++ b/src/routes/api/integrations/google-drive/callback/+server.ts @@ -89,16 +89,21 @@ export const GET = async (event: RequestEvent) => { const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null; const folderId = oauthState.folderId?.trim() ? oauthState.folderId : null; - await repo.upsert({ - provider: 'google_drive', - enabled: true, - fileName, - folderId, - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token ?? null, - accessTokenExpiresAt: expiresAt, - metadata: tokenData.scope ? { scope: tokenData.scope } : null - }); + try { + await repo.upsert({ + provider: 'google_drive', + enabled: true, + fileName, + folderId, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token ?? null, + accessTokenExpiresAt: expiresAt, + metadata: tokenData.scope ? { scope: tokenData.scope } : null + }); + } catch (saveError) { + console.error('Google Drive integration save failed:', saveError); + throw redirect(302, `${returnTo}-error`); + } throw redirect(302, `${returnTo}-connected`); } catch (err) { diff --git a/src/routes/api/integrations/google-drive/connect/+server.ts b/src/routes/api/integrations/google-drive/connect/+server.ts index e245949..2098752 100644 --- a/src/routes/api/integrations/google-drive/connect/+server.ts +++ b/src/routes/api/integrations/google-drive/connect/+server.ts @@ -31,7 +31,10 @@ export const GET = async (event: RequestEvent) => { const env = getEnv(); const clientId = env.GOOGLE_OAUTH_CLIENT_ID; if (!clientId) { - throw redirect(302, `/pokedex/${pokedexId}?export=google-missing-client`); + const missingClientRedirect = pokedexId + ? `/pokedex/${pokedexId}?export=google-missing-client` + : '/pokedex?export=google-missing-client'; + throw redirect(302, missingClientRedirect); } const state = createOAuthState(); diff --git a/src/routes/backup-settings/+page.svelte b/src/routes/backup-settings/+page.svelte index 23a0a19..3268345 100644 --- a/src/routes/backup-settings/+page.svelte +++ b/src/routes/backup-settings/+page.svelte @@ -121,6 +121,7 @@ Open Drive folder @@ -156,6 +157,7 @@ Open Dropbox folder diff --git a/src/routes/pokedex/[id]/+page.svelte b/src/routes/pokedex/[id]/+page.svelte index d6767cf..90d1f68 100644 --- a/src/routes/pokedex/[id]/+page.svelte +++ b/src/routes/pokedex/[id]/+page.svelte @@ -64,6 +64,21 @@ let exportInFlightGeneration = 0; let toggleFlushTimer: ReturnType | null = null; + function resetExportState() { + if (exportTimer) { + clearTimeout(exportTimer); + exportTimer = null; + } + if (toggleFlushTimer) { + clearTimeout(toggleFlushTimer); + toggleFlushTimer = null; + } + exportAfterFlush = false; + exportInFlight = false; + exportGeneration = 0; + exportInFlightGeneration = 0; + } + function scheduleExportIfIdle() { if (!browser) return; if (!pokedexId) return; @@ -77,10 +92,15 @@ exportInFlight = true; exportInFlightGeneration = exportGeneration; try { - await fetch(`/api/pokedexes/${pokedexId}/export`, { + const response = await fetch(`/api/pokedexes/${pokedexId}/export`, { method: 'POST', credentials: 'include' }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + console.error('Auto-export failed:', response.status, body); + return; + } if (exportGeneration === exportInFlightGeneration) { exportAfterFlush = false; } @@ -121,6 +141,9 @@ catchWriteQueueUnsubscribe?.(); catchWriteQueueUnsubscribe = null; }); + onDestroy(() => { + resetExportState(); + }); function openPokemonModal(pokemon: CombinedData) { selectedPokemon = pokemon; @@ -139,6 +162,8 @@ const desiredKey = `${localUser.id}:${pokedexId}`; if (catchWriteQueue && catchWriteQueueKey === desiredKey) return; + resetExportState(); + // Unsubscribe from the previous queue's status store before replacing the queue. catchWriteQueueUnsubscribe?.(); catchWriteQueueUnsubscribe = null; From 86e8d6f5e6fd510d581f3b4438361861801798f3 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:26:55 +0000 Subject: [PATCH 3/4] refactor(#89): Address PR comments --- .../api/export-integrations/[provider]/+server.ts | 13 ++++++++++++- .../api/integrations/dropbox/connect/+server.ts | 4 +++- .../integrations/google-drive/connect/+server.ts | 4 +++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/routes/api/export-integrations/[provider]/+server.ts b/src/routes/api/export-integrations/[provider]/+server.ts index 5afeeee..a0a9e93 100644 --- a/src/routes/api/export-integrations/[provider]/+server.ts +++ b/src/routes/api/export-integrations/[provider]/+server.ts @@ -18,10 +18,21 @@ export const PUT = async (event: RequestEvent) => { await event.locals.supabase.auth.setSession(session); } - const body = (await event.request.json()) as { + let body: { folderId?: string | null; path?: string | null; }; + try { + body = (await event.request.json()) as { + folderId?: string | null; + path?: string | null; + }; + } catch (err) { + if (err instanceof SyntaxError) { + return new Response('Malformed JSON', { status: 400 }); + } + throw err; + } const patch: Record = {}; if (body.folderId !== undefined) patch.folderId = body.folderId; diff --git a/src/routes/api/integrations/dropbox/connect/+server.ts b/src/routes/api/integrations/dropbox/connect/+server.ts index 904fbb6..2b0f7e8 100644 --- a/src/routes/api/integrations/dropbox/connect/+server.ts +++ b/src/routes/api/integrations/dropbox/connect/+server.ts @@ -13,7 +13,9 @@ export const GET = async (event: RequestEvent) => { const path = url.searchParams.get('path'); const returnToRaw = url.searchParams.get('returnTo'); const returnTo = - returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings'; + returnToRaw && returnToRaw.startsWith('/') && !returnToRaw.startsWith('//') + ? returnToRaw + : '/backup-settings'; if (pokedexId) { const { session } = await event.locals.safeGetSession(); diff --git a/src/routes/api/integrations/google-drive/connect/+server.ts b/src/routes/api/integrations/google-drive/connect/+server.ts index 2098752..0ba80d6 100644 --- a/src/routes/api/integrations/google-drive/connect/+server.ts +++ b/src/routes/api/integrations/google-drive/connect/+server.ts @@ -13,7 +13,9 @@ export const GET = async (event: RequestEvent) => { const folderId = url.searchParams.get('folderId'); const returnToRaw = url.searchParams.get('returnTo'); const returnTo = - returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings'; + returnToRaw && returnToRaw.startsWith('/') && !returnToRaw.startsWith('//') + ? returnToRaw + : '/backup-settings'; if (pokedexId) { const { session } = await event.locals.safeGetSession(); From e55d5111414da57b810b6820bfa2ae26dcd605ed Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Sat, 24 Jan 2026 17:40:59 +0000 Subject: [PATCH 4/4] refactor(#89): Address PR comments --- src/routes/api/export-integrations/[provider]/+server.ts | 9 +++++++-- .../api/integrations/google-drive/connect/+server.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/routes/api/export-integrations/[provider]/+server.ts b/src/routes/api/export-integrations/[provider]/+server.ts index a0a9e93..d384c4f 100644 --- a/src/routes/api/export-integrations/[provider]/+server.ts +++ b/src/routes/api/export-integrations/[provider]/+server.ts @@ -48,9 +48,14 @@ export const PUT = async (event: RequestEvent) => { .is('pokedexId', null) .eq('provider', provider) .select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError') - .single(); + .maybeSingle(); - if (error || !data) { + if (error) { + console.error(error); + return json({ error: 'Internal Server Error' }, { status: 500 }); + } + + if (data === null) { return json({ error: 'Integration not found' }, { status: 404 }); } diff --git a/src/routes/api/integrations/google-drive/connect/+server.ts b/src/routes/api/integrations/google-drive/connect/+server.ts index 0ba80d6..c9e79f6 100644 --- a/src/routes/api/integrations/google-drive/connect/+server.ts +++ b/src/routes/api/integrations/google-drive/connect/+server.ts @@ -35,7 +35,7 @@ export const GET = async (event: RequestEvent) => { if (!clientId) { const missingClientRedirect = pokedexId ? `/pokedex/${pokedexId}?export=google-missing-client` - : '/pokedex?export=google-missing-client'; + : '/backup-settings?export=google-missing-client'; throw redirect(302, missingClientRedirect); }