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) {
)}
+
+