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>
This commit is contained in:
co-authored by
greptile-apps[bot]
parent
c0b74d50e4
commit
7a0214c053
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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}`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user