mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-07-12 18:43:45 +00:00
Merge pull request #90 from jcreek/89-add-backup-functionality-for-google-drive-and-dropbox
feat(#89): Add backup functionality for google drive and dropbox
This commit is contained in:
+6
-1
@@ -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"
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | null;
|
||||
lastExportedAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
@@ -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<SupabaseClient['from']>) {
|
||||
if (this.pokedexId) {
|
||||
return query.eq('pokedexId', this.pokedexId);
|
||||
}
|
||||
return query.is('pokedexId', null);
|
||||
}
|
||||
|
||||
async listEnabled(): Promise<PokedexExportIntegration[]> {
|
||||
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<PokedexExportIntegration[]> {
|
||||
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<PokedexExportIntegrationDB> &
|
||||
Pick<PokedexExportIntegrationDB, 'provider'>
|
||||
): Promise<PokedexExportIntegration> {
|
||||
const payload: Partial<PokedexExportIntegrationDB> = {
|
||||
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<PokedexExportIntegration[]> {
|
||||
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<void> {
|
||||
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<string, unknown> | null;
|
||||
folderId?: string | null;
|
||||
path?: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
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;
|
||||
@@ -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<PokedexExportIntegration> {
|
||||
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<PokedexExportIntegration> {
|
||||
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<PokedexExportIntegration> {
|
||||
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<string, string>;
|
||||
};
|
||||
|
||||
function getGoogleFileId(metadata: Record<string, unknown> | 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<string, unknown> | null,
|
||||
pokedexId: string,
|
||||
fileId: string
|
||||
): Record<string, unknown> {
|
||||
const data = (metadata as GoogleDriveMetadata | null) ?? {};
|
||||
const files = { ...(data.files ?? {}) };
|
||||
files[pokedexId] = fileId;
|
||||
return { ...data, files };
|
||||
}
|
||||
|
||||
function withoutGoogleFileId(
|
||||
metadata: Record<string, unknown> | null,
|
||||
pokedexId: string
|
||||
): Record<string, unknown> | 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<void> {
|
||||
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<string, unknown> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<PokedexExportResult> {
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type QueueItem = {
|
||||
attempts: number;
|
||||
notBefore: number; // unix ms
|
||||
debounceTimer: ReturnType<typeof setTimeout> | 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();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export function getEnv() {
|
||||
return process.env;
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -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: '/' });
|
||||
}
|
||||
@@ -159,6 +159,9 @@
|
||||
<li>
|
||||
<a href="/my-pokedexes"> My Pokédexes </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/backup-settings"> Backup Settings </a>
|
||||
</li>
|
||||
<li><SignOut {supabase} on:signedOut={getUser} /></li>
|
||||
{:else}
|
||||
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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<string, unknown> = {};
|
||||
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')
|
||||
.update(patch)
|
||||
.eq('userId', userId)
|
||||
.is('pokedexId', null)
|
||||
.eq('provider', provider)
|
||||
.select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError')
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 path = url.searchParams.get('path');
|
||||
const returnToRaw = url.searchParams.get('returnTo');
|
||||
const returnTo =
|
||||
returnToRaw && returnToRaw.startsWith('/') && !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) {
|
||||
const missingClientRedirect = pokedexId
|
||||
? `/pokedex/${pokedexId}?export=dropbox-missing-client`
|
||||
: '/pokedex?export=dropbox-missing-client';
|
||||
throw redirect(302, missingClientRedirect);
|
||||
}
|
||||
|
||||
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()}`);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
|
||||
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) {
|
||||
console.error(err);
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
throw err;
|
||||
}
|
||||
return json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
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.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) {
|
||||
const missingClientRedirect = pokedexId
|
||||
? `/pokedex/${pokedexId}?export=google-missing-client`
|
||||
: '/backup-settings?export=google-missing-client';
|
||||
throw redirect(302, missingClientRedirect);
|
||||
}
|
||||
|
||||
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()}`);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 {};
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type ExportIntegrationSummary = {
|
||||
id: string;
|
||||
provider: 'google_drive' | 'dropbox';
|
||||
enabled: boolean;
|
||||
fileName: string | null;
|
||||
folderId: string | null;
|
||||
path: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
lastExportedAt: string | null;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
let exportIntegrations: ExportIntegrationSummary[] = [];
|
||||
let exportLoading = false;
|
||||
let exportError: string | null = null;
|
||||
let googleIntegration: ExportIntegrationSummary | undefined;
|
||||
let dropboxIntegration: ExportIntegrationSummary | undefined;
|
||||
|
||||
function formatTimestamp(value: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
async function loadExportIntegrations() {
|
||||
exportLoading = true;
|
||||
exportError = null;
|
||||
try {
|
||||
const response = await fetch('/api/export-integrations', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
|
||||
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
|
||||
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
exportError = message || 'Failed to load export settings';
|
||||
} finally {
|
||||
exportLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getGoogleFolderUrl(folderId: string): string {
|
||||
return `https://drive.google.com/drive/folders/${folderId}`;
|
||||
}
|
||||
|
||||
function getDropboxFolderUrl(path?: string | null): string {
|
||||
const fallback = '/Apps/Living Dex Tracker';
|
||||
const trimmed = (path ?? '').trim() || fallback;
|
||||
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
return `https://www.dropbox.com/home${normalized}`;
|
||||
}
|
||||
|
||||
function connectGoogleDrive() {
|
||||
const params = new URLSearchParams({
|
||||
returnTo: '/backup-settings'
|
||||
});
|
||||
window.location.href = `/api/integrations/google-drive/connect?${params.toString()}`;
|
||||
}
|
||||
|
||||
function connectDropbox() {
|
||||
const params = new URLSearchParams({
|
||||
returnTo: '/backup-settings'
|
||||
});
|
||||
window.location.href = `/api/integrations/dropbox/connect?${params.toString()}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
void loadExportIntegrations();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Backup Settings - Living Dex Tracker</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto p-4 max-w-screen-lg">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold">Backup Settings</h1>
|
||||
<a href="/my-pokedexes" class="btn btn-outline btn-sm">Back to My Pokédexes</a>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<p class="text-sm text-base-content/70">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{#if exportLoading}
|
||||
<p class="text-sm text-base-content/70 mt-4">Loading export settings…</p>
|
||||
{:else if exportError}
|
||||
<p class="text-sm text-error mt-4">{exportError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Google Drive</h2>
|
||||
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{googleIntegration ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="btn btn-sm btn-outline" on:click={connectGoogleDrive}>
|
||||
{googleIntegration ? 'Reconnect' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
{#if googleIntegration?.folderId}
|
||||
<div class="text-xs">
|
||||
<a
|
||||
href={getGoogleFolderUrl(googleIntegration.folderId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link link-primary"
|
||||
>
|
||||
Open Drive folder
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if googleIntegration?.lastExportedAt}
|
||||
<p class="text-xs text-base-content/60">
|
||||
Last export: {formatTimestamp(googleIntegration.lastExportedAt)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if googleIntegration?.lastError}
|
||||
<p class="text-xs text-error">{googleIntegration.lastError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold">Dropbox</h2>
|
||||
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{dropboxIntegration ? 'Connected' : 'Not Connected'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="btn btn-sm btn-outline" on:click={connectDropbox}>
|
||||
{dropboxIntegration ? 'Reconnect' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
{#if dropboxIntegration}
|
||||
<div class="text-xs">
|
||||
<a
|
||||
href={getDropboxFolderUrl(dropboxIntegration.path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="link link-primary"
|
||||
>
|
||||
Open Dropbox folder
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{#if dropboxIntegration?.lastExportedAt}
|
||||
<p class="text-xs text-base-content/60">
|
||||
Last export: {formatTimestamp(dropboxIntegration.lastExportedAt)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if dropboxIntegration?.lastError}
|
||||
<p class="text-xs text-error">{dropboxIntegration.lastError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,7 +167,10 @@
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">My Pokédexes</h1>
|
||||
<button class="btn btn-primary" on:click={openCreateModal}> Create New Pokédex </button>
|
||||
<div class="flex items-center gap-2">
|
||||
<a class="btn btn-outline" href="/backup-settings">Backup Settings</a>
|
||||
<button class="btn btn-primary" on:click={openCreateModal}> Create New Pokédex </button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pokedexes.length === 0}
|
||||
|
||||
@@ -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,77 @@
|
||||
lastFlushAttemptAt: null,
|
||||
lastSuccessfulFlushAt: null
|
||||
};
|
||||
let exportAfterFlush = false;
|
||||
let exportInFlight = false;
|
||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let exportGeneration = 0;
|
||||
let exportInFlightGeneration = 0;
|
||||
let toggleFlushTimer: ReturnType<typeof setTimeout> | 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;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
} 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;
|
||||
@@ -62,6 +141,9 @@
|
||||
catchWriteQueueUnsubscribe?.();
|
||||
catchWriteQueueUnsubscribe = null;
|
||||
});
|
||||
onDestroy(() => {
|
||||
resetExportState();
|
||||
});
|
||||
|
||||
function openPokemonModal(pokemon: CombinedData) {
|
||||
selectedPokemon = pokemon;
|
||||
@@ -80,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;
|
||||
@@ -94,9 +178,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 +279,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 +339,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 +464,7 @@
|
||||
window.clearInterval(reconcileInterval);
|
||||
};
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -496,6 +599,7 @@
|
||||
{#if pokedex.description}
|
||||
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right side: Actions -->
|
||||
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user