From 7a0214c053e8e914b9f6839f08d4d8ea8f492c3a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:42:37 +0200 Subject: [PATCH] feat: implement cloud backup auto-sync feature with scheduling (#280) * feat: implement cloud backup auto-sync feature with scheduling - Added a new cron route for auto-syncing Google Drive backups hourly. - Introduced a schedule management system for enabling/disabling auto-sync and setting the sync hour. - Updated the logo upload API to handle logo file management more efficiently. - Created a public storage bucket for company logos with appropriate size and type restrictions. - Enhanced the LogoUpload component to validate file types and sizes during upload. - Added tests for the new auto-sync functionality to ensure correct behavior under various conditions. * Update extensions/general/cloud-backup/lib/sync.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * Update app/api/settings/logo/route.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * refactor: remove unused parameters from saveExtensionData function --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- .../auto-sync/cron/__tests__/route.test.ts | 227 ++++++++++++++++++ .../cloud-backup/auto-sync/cron/route.ts | 159 ++++++++++++ app/api/settings/logo/route.ts | 41 +++- components/settings/LogoUpload.tsx | 44 +++- .../components/CloudBackupCard.tsx | 154 +++++++++++- extensions/general/cloud-backup/index.ts | 208 ++++++++-------- extensions/general/cloud-backup/lib/sync.ts | 180 ++++++++++++++ extensions/general/cloud-backup/types.ts | 20 +- .../20260420120000_logos_bucket.sql | 23 ++ vercel.json | 4 + 10 files changed, 930 insertions(+), 130 deletions(-) create mode 100644 app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts create mode 100644 app/api/extensions/cloud-backup/auto-sync/cron/route.ts create mode 100644 extensions/general/cloud-backup/lib/sync.ts create mode 100644 supabase/migrations/20260420120000_logos_bucket.sql diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts new file mode 100644 index 00000000..0ae768df --- /dev/null +++ b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@supabase/supabase-js', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/extensions/general/cloud-backup/lib/sync', () => ({ + performSync: vi.fn(), + SCHEDULE_KEY: 'google_drive_schedule', + saveExtensionData: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/auth/cron', () => ({ + verifyCronSecret: vi.fn().mockReturnValue(null), +})) + +import { GET } from '../route' +import { createClient } from '@supabase/supabase-js' +import { + performSync, + saveExtensionData, +} from '@/extensions/general/cloud-backup/lib/sync' +import { verifyCronSecret } from '@/lib/auth/cron' + +const mockCreateClient = vi.mocked(createClient) +const mockPerformSync = vi.mocked(performSync) +const mockSaveExtensionData = vi.mocked(saveExtensionData) +const mockVerifyCronSecret = vi.mocked(verifyCronSecret) + +function makeRequest() { + return new Request('http://localhost/api/extensions/cloud-backup/auto-sync/cron', { + headers: { authorization: 'Bearer test-secret' }, + }) +} + +function makeSupabaseStub(rows: unknown[], error: unknown = null) { + const chain = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }), + } + return { from: vi.fn().mockReturnValue(chain) } as any +} + +describe('cloud-backup auto-sync cron', () => { + beforeEach(() => { + vi.clearAllMocks() + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co' + process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key' + process.env.NEXT_PUBLIC_APP_URL = 'https://app.test' + mockVerifyCronSecret.mockReturnValue(null) + }) + + it('returns 401 when cron auth fails', async () => { + mockVerifyCronSecret.mockReturnValueOnce( + new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) as any + ) + + const res = await GET(makeRequest()) + expect(res.status).toBe(401) + expect(mockCreateClient).not.toHaveBeenCalled() + }) + + it('skips schedules that are disabled', async () => { + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub([ + { + company_id: 'c-1', + user_id: 'u-1', + value: { enabled: false, hour_utc: new Date().getUTCHours() }, + }, + ]) + ) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(body.processed).toBe(0) + expect(mockPerformSync).not.toHaveBeenCalled() + }) + + it('skips schedules whose hour does not match the current UTC hour', async () => { + const offHour = (new Date().getUTCHours() + 5) % 24 + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub([ + { + company_id: 'c-1', + user_id: 'u-1', + value: { enabled: true, hour_utc: offHour }, + }, + ]) + ) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(body.processed).toBe(0) + expect(mockPerformSync).not.toHaveBeenCalled() + }) + + it('skips schedules whose last_auto_sync_at is less than 20h ago', async () => { + const recent = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString() + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub([ + { + company_id: 'c-1', + user_id: 'u-1', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: recent, + }, + }, + ]) + ) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(body.processed).toBe(0) + expect(mockPerformSync).not.toHaveBeenCalled() + }) + + it('runs sync and persists success for qualifying schedules', async () => { + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub([ + { + company_id: 'c-1', + user_id: 'u-1', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: null, + }, + }, + ]) + ) + mockPerformSync.mockResolvedValueOnce({ + ok: true, + lastSync: { + at: '2026-04-20T03:00:00Z', + file_id: 'f-1', + file_name: 'arkiv.zip', + file_size_bytes: 1000, + folder_id: 'folder-1', + }, + webViewLink: 'https://drive.google.com/file/d/f-1/view', + }) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(mockPerformSync).toHaveBeenCalledWith( + expect.objectContaining({ + companyId: 'c-1', + userId: 'u-1', + includeDocuments: true, + }) + ) + expect(body.successes).toBe(1) + expect(body.errors).toBe(0) + + // Persisted the success state on the schedule + const [, , , key, value] = mockSaveExtensionData.mock.calls[0] + expect(key).toBe('google_drive_schedule') + expect((value as any).last_auto_sync_status).toBe('success') + expect((value as any).last_auto_sync_error).toBeNull() + }) + + it('records error status when performSync returns ok=false', async () => { + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub([ + { + company_id: 'c-1', + user_id: 'u-1', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: null, + }, + }, + ]) + ) + mockPerformSync.mockResolvedValueOnce({ + ok: false, + reason: 'archive_too_large', + message: 'Archive exceeds size limit', + size_bytes: 100 * 1024 * 1024, + size_limit_bytes: 80 * 1024 * 1024, + }) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(body.successes).toBe(0) + expect(body.errors).toBe(1) + const [, , , , value] = mockSaveExtensionData.mock.calls[0] + expect((value as any).last_auto_sync_status).toBe('error') + expect((value as any).last_auto_sync_error).toBe('Archive exceeds size limit') + }) + + it('catches thrown errors and records them against the schedule', async () => { + mockCreateClient.mockReturnValueOnce( + makeSupabaseStub([ + { + company_id: 'c-1', + user_id: 'u-1', + value: { + enabled: true, + hour_utc: new Date().getUTCHours(), + last_auto_sync_at: null, + }, + }, + ]) + ) + mockPerformSync.mockRejectedValueOnce(new Error('Drive quota exceeded')) + + const res = await GET(makeRequest()) + const body = await res.json() + + expect(body.errors).toBe(1) + const [, , , , value] = mockSaveExtensionData.mock.calls[0] + expect((value as any).last_auto_sync_status).toBe('error') + expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded') + }) +}) diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts new file mode 100644 index 00000000..d7c92014 --- /dev/null +++ b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts @@ -0,0 +1,159 @@ +import { createClient } from '@supabase/supabase-js' +import { NextResponse } from 'next/server' +import { verifyCronSecret } from '@/lib/auth/cron' +import { + performSync, + SCHEDULE_KEY, + saveExtensionData, +} from '@/extensions/general/cloud-backup/lib/sync' +import type { GoogleDriveSchedule } from '@/extensions/general/cloud-backup/types' + +/** + * GET /api/extensions/cloud-backup/auto-sync/cron + * + * Runs hourly. Finds all companies with `google_drive_schedule.enabled = true` + * whose `hour_utc` matches the current UTC hour, and whose `last_auto_sync_at` + * is either unset or more than 20 hours old. Triggers a full Drive backup for + * each qualifying company via the shared `performSync()` helper. + * + * Uses the service role client — no user session, no RLS. Each row in + * `extension_data` carries its own `user_id` (the user who configured the + * schedule), which we use as the "actor" when writing back the sync result. + */ +export async function GET(request: Request) { + const authError = verifyCronSecret(request) + if (authError) return authError + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY + + if (!supabaseUrl || !supabaseServiceKey) { + return NextResponse.json( + { error: 'Missing Supabase configuration' }, + { status: 500 } + ) + } + + const supabase = createClient(supabaseUrl, supabaseServiceKey) + const currentHourUtc = new Date().getUTCHours() + const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + + const { data: rows, error } = await supabase + .from('extension_data') + .select('company_id, user_id, value') + .eq('extension_id', 'cloud-backup') + .eq('key', SCHEDULE_KEY) + + if (error) { + console.error('[cloud-backup-cron] Failed to fetch schedules', { + message: error.message, + code: error.code, + }) + return NextResponse.json({ error: 'Failed to fetch schedules' }, { status: 500 }) + } + + if (!rows || rows.length === 0) { + return NextResponse.json({ message: 'No schedules configured', processed: 0 }) + } + + const candidates = rows.filter((r) => { + const schedule = r.value as GoogleDriveSchedule | null + if (!schedule || !schedule.enabled) return false + if (schedule.hour_utc !== currentHourUtc) return false + if (schedule.last_auto_sync_at) { + const ageMs = Date.now() - new Date(schedule.last_auto_sync_at).getTime() + if (ageMs < 20 * 60 * 60 * 1000) return false + } + return true + }) + + if (candidates.length === 0) { + return NextResponse.json({ + message: 'No companies due this hour', + checked: rows.length, + processed: 0, + }) + } + + const startTime = Date.now() + const TIME_BUDGET_MS = 250_000 // 4m10s — leaves 50s margin below Vercel's 300s Pro limit + + const results: { + companyId: string + status: 'success' | 'error' | 'skipped' + error?: string + }[] = [] + + for (const row of candidates) { + if (Date.now() - startTime > TIME_BUDGET_MS) { + console.log( + `[cloud-backup-cron] Time budget reached after ${results.length} companies; ${candidates.length - results.length} skipped until next run` + ) + break + } + + const companyId = row.company_id as string + const userId = row.user_id as string + const schedule = row.value as GoogleDriveSchedule + + try { + const syncResult = await performSync({ + supabase, + companyId, + userId, + origin, + includeDocuments: true, + }) + + const updated: GoogleDriveSchedule = { + ...schedule, + last_auto_sync_at: new Date().toISOString(), + last_auto_sync_status: syncResult.ok ? 'success' : 'error', + last_auto_sync_error: syncResult.ok ? null : syncResult.message, + } + await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated) + + results.push({ + companyId, + status: syncResult.ok ? 'success' : 'error', + error: syncResult.ok ? undefined : syncResult.message, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + console.error('[cloud-backup-cron] Sync failed for company', { + companyId, + message, + }) + + const updated: GoogleDriveSchedule = { + ...schedule, + last_auto_sync_at: new Date().toISOString(), + last_auto_sync_status: 'error', + last_auto_sync_error: message.slice(0, 200), + } + await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated).catch( + (persistErr) => { + console.error('[cloud-backup-cron] Failed to persist failure state', persistErr) + } + ) + + results.push({ companyId, status: 'error', error: message }) + } + } + + const successCount = results.filter((r) => r.status === 'success').length + const errorCount = results.filter((r) => r.status === 'error').length + + console.log( + `[cloud-backup-cron] Processed ${results.length} companies: ${successCount} succeeded, ${errorCount} failed` + ) + + return NextResponse.json({ + checked: rows.length, + candidates: candidates.length, + processed: results.length, + successes: successCount, + errors: errorCount, + results, + }) +} diff --git a/app/api/settings/logo/route.ts b/app/api/settings/logo/route.ts index ad12a865..8532ff15 100644 --- a/app/api/settings/logo/route.ts +++ b/app/api/settings/logo/route.ts @@ -33,13 +33,29 @@ export async function POST(request: Request) { } const buffer = Buffer.from(await file.arrayBuffer()) - const ext = file.name.split('.').pop() || 'png' - const storagePath = `logos/${companyId}/logo.${ext}` + const mimeToExt: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + } + const ext = mimeToExt[file.type] ?? 'png' + const storagePath = `${companyId}/logo-${Date.now()}.${ext}` - // Upload with service client to bypass storage RLS (auth already verified above) const serviceClient = createServiceClient() + + // Remove any previous logo files for this company so we don't pile up orphans. + const { data: existing } = await serviceClient.storage + .from('logos') + .list(companyId) + if (existing && existing.length > 0) { + await serviceClient.storage + .from('logos') + .remove(existing.map((f) => `${companyId}/${f.name}`)) + } + const { error: uploadError } = await serviceClient.storage - .from('documents') + .from('logos') .upload(storagePath, buffer, { contentType: file.type, upsert: true, @@ -49,9 +65,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 }) } - // Get public URL const { data: urlData } = serviceClient.storage - .from('documents') + .from('logos') .getPublicUrl(storagePath) // Update company settings @@ -86,12 +101,14 @@ export async function DELETE() { .single() if (settings?.logo_url) { - // Extract storage path from URL - const url = new URL(settings.logo_url) - const pathMatch = url.pathname.match(/\/object\/public\/documents\/(.+)/) - if (pathMatch) { - const serviceClient = createServiceClient() - await serviceClient.storage.from('documents').remove([pathMatch[1]]) + const serviceClient = createServiceClient() + const { data: existing } = await serviceClient.storage + .from('logos') + .list(companyId) + if (existing && existing.length > 0) { + await serviceClient.storage + .from('logos') + .remove(existing.map((f) => `${companyId}/${f.name}`)) } } diff --git a/components/settings/LogoUpload.tsx b/components/settings/LogoUpload.tsx index 3f7f9d2e..4acff902 100644 --- a/components/settings/LogoUpload.tsx +++ b/components/settings/LogoUpload.tsx @@ -16,8 +16,23 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) { const [isUploading, setIsUploading] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [preview, setPreview] = useState(logoUrl) + const [isDragging, setIsDragging] = useState(false) const inputRef = useRef(null) + const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'] + + function validateAndUpload(file: File) { + if (!ALLOWED_TYPES.includes(file.type)) { + toast({ title: 'Otillåten filtyp', description: 'PNG, JPG, SVG eller WebP.', variant: 'destructive' }) + return + } + if (file.size > 2 * 1024 * 1024) { + toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' }) + return + } + handleUpload(file) + } + async function handleUpload(file: File) { setIsUploading(true) @@ -69,13 +84,24 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) { function handleFileChange(e: React.ChangeEvent) { const file = e.target.files?.[0] if (!file) return + validateAndUpload(file) + } - if (file.size > 2 * 1024 * 1024) { - toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' }) - return - } + function handleDrop(e: React.DragEvent) { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) validateAndUpload(file) + } - handleUpload(file) + function handleDragOver(e: React.DragEvent) { + e.preventDefault() + setIsDragging(true) + } + + function handleDragLeave(e: React.DragEvent) { + e.preventDefault() + setIsDragging(false) } return ( @@ -123,8 +149,14 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {