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}`))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,23 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(logoUrl)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
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<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) validateAndUpload(file)
|
||||
}
|
||||
|
||||
handleUpload(file)
|
||||
function handleDragOver(e: React.DragEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
function handleDragLeave(e: React.DragEvent<HTMLButtonElement>) {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -123,8 +149,14 @@ export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
disabled={isUploading}
|
||||
className="flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed border-border/60 py-8 px-4 text-center transition-colors hover:border-border hover:bg-muted/20 disabled:opacity-50"
|
||||
className={`flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed py-8 px-4 text-center transition-colors disabled:opacity-50 ${
|
||||
isDragging ? 'border-foreground bg-muted/40' : 'border-border/60 hover:border-border hover:bg-muted/20'
|
||||
}`}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Cloud, ExternalLink, Loader2, RefreshCw, Unplug } from 'lucide-react'
|
||||
import type { CloudBackupStatus } from '../types'
|
||||
import type { CloudBackupStatus, GoogleDriveSchedule } from '../types'
|
||||
|
||||
const API_BASE = '/api/extensions/ext/cloud-backup'
|
||||
|
||||
@@ -186,6 +188,11 @@ export default function CloudBackupCard() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ScheduleSection
|
||||
schedule={status.schedule}
|
||||
onUpdated={loadStatus}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button onClick={handleSync} disabled={isSyncing}>
|
||||
{isSyncing ? (
|
||||
@@ -261,3 +268,148 @@ function formatDate(iso: string): string {
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
interface ScheduleSectionProps {
|
||||
schedule: GoogleDriveSchedule | null
|
||||
onUpdated: () => Promise<void> | void
|
||||
}
|
||||
|
||||
function ScheduleSection({ schedule, onUpdated }: ScheduleSectionProps) {
|
||||
const { toast } = useToast()
|
||||
|
||||
// Convert stored UTC hour to the user's local hour for display.
|
||||
const initialLocalHour =
|
||||
schedule && typeof schedule.hour_utc === 'number'
|
||||
? utcHourToLocalHour(schedule.hour_utc)
|
||||
: utcHourToLocalHour(3)
|
||||
const [enabled, setEnabled] = useState(schedule?.enabled ?? false)
|
||||
const [localHour, setLocalHour] = useState(initialLocalHour)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(schedule?.enabled ?? false)
|
||||
setLocalHour(
|
||||
schedule && typeof schedule.hour_utc === 'number'
|
||||
? utcHourToLocalHour(schedule.hour_utc)
|
||||
: utcHourToLocalHour(3)
|
||||
)
|
||||
}, [schedule?.enabled, schedule?.hour_utc])
|
||||
|
||||
const save = useCallback(
|
||||
async (nextEnabled: boolean, nextLocalHour: number) => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: nextEnabled,
|
||||
hour_utc: localHourToUtcHour(nextLocalHour),
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Kunde inte spara schema')
|
||||
}
|
||||
await onUpdated()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Kunde inte spara schema',
|
||||
description: err instanceof Error ? err.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[onUpdated, toast]
|
||||
)
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
setEnabled(checked)
|
||||
save(checked, localHour)
|
||||
},
|
||||
[localHour, save]
|
||||
)
|
||||
|
||||
const handleHourChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const next = Number(e.target.value)
|
||||
setLocalHour(next)
|
||||
if (enabled) save(enabled, next)
|
||||
},
|
||||
[enabled, save]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 bg-background p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<Label htmlFor="auto-sync-toggle" className="text-sm font-medium">
|
||||
Automatisk synkronisering
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kör en daglig säkerhetsbackup till din Drive.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-sync-toggle"
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="auto-sync-hour" className="text-xs text-muted-foreground">
|
||||
Tid (din lokala tid)
|
||||
</Label>
|
||||
<select
|
||||
id="auto-sync-hour"
|
||||
value={localHour}
|
||||
onChange={handleHourChange}
|
||||
disabled={isSaving}
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, h) => (
|
||||
<option key={h} value={h}>
|
||||
{h.toString().padStart(2, '0')}:00
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isSaving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schedule?.last_auto_sync_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Senaste automatiska synk: {formatDate(schedule.last_auto_sync_at)}{' '}
|
||||
{schedule.last_auto_sync_status === 'success' ? (
|
||||
<span className="text-emerald-600">· lyckades</span>
|
||||
) : schedule.last_auto_sync_status === 'error' ? (
|
||||
<span className="text-destructive">
|
||||
· misslyckades
|
||||
{schedule.last_auto_sync_error ? ` (${schedule.last_auto_sync_error})` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Convert a UTC hour (0-23) to the browser's local hour. */
|
||||
function utcHourToLocalHour(hourUtc: number): number {
|
||||
const d = new Date()
|
||||
d.setUTCHours(hourUtc, 0, 0, 0)
|
||||
return d.getHours()
|
||||
}
|
||||
|
||||
/** Convert a local hour (0-23) to UTC. */
|
||||
function localHourToUtcHour(localHour: number): number {
|
||||
const d = new Date()
|
||||
d.setHours(localHour, 0, 0, 0)
|
||||
return d.getUTCHours()
|
||||
}
|
||||
|
||||
@@ -1,36 +1,31 @@
|
||||
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
generateFullArchive,
|
||||
estimateArchiveSize,
|
||||
type ArchiveScope,
|
||||
} from '@/lib/reports/full-archive-export'
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
exchangeCodeForTokens,
|
||||
fetchUserEmail,
|
||||
getOAuthEnv,
|
||||
refreshAccessToken,
|
||||
revokeToken,
|
||||
} from './lib/google-oauth'
|
||||
import { ensureFolder, uploadFile } from './lib/google-drive'
|
||||
import {
|
||||
createOAuthState,
|
||||
decryptToken,
|
||||
encryptToken,
|
||||
verifyOAuthState,
|
||||
} from './lib/crypto'
|
||||
import {
|
||||
performSync,
|
||||
CONNECTION_KEY,
|
||||
LAST_SYNC_KEY,
|
||||
SCHEDULE_KEY,
|
||||
} from './lib/sync'
|
||||
import type {
|
||||
CloudBackupStatus,
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveLastSync,
|
||||
GoogleDriveSchedule,
|
||||
} from './types'
|
||||
|
||||
const CONNECTION_KEY = 'google_drive_connection'
|
||||
const LAST_SYNC_KEY = 'google_drive_last_sync'
|
||||
const ROOT_FOLDER_NAME = 'gnubok'
|
||||
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
|
||||
|
||||
function jsonError(message: string, status = 500): Response {
|
||||
return NextResponse.json({ error: message }, { status })
|
||||
}
|
||||
@@ -41,26 +36,12 @@ async function loadConnection(
|
||||
return ctx.settings.get<GoogleDriveConnection>(CONNECTION_KEY)
|
||||
}
|
||||
|
||||
async function getFreshAccessToken(
|
||||
ctx: ExtensionContext,
|
||||
connection: GoogleDriveConnection
|
||||
): Promise<string> {
|
||||
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const env = getOAuthEnv(origin)
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
||||
const { access_token } = await refreshAccessToken(env, refreshToken)
|
||||
return access_token
|
||||
}
|
||||
|
||||
async function fetchCompanyName(ctx: ExtensionContext): Promise<string> {
|
||||
const { data } = await ctx.supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number')
|
||||
.eq('company_id', ctx.companyId)
|
||||
.single()
|
||||
const name = (data?.company_name as string) || 'företag'
|
||||
const org = (data?.org_number as string) || ctx.companyId.slice(0, 8)
|
||||
return `${name} (${org})`.replace(/[\\/]/g, '-')
|
||||
const DEFAULT_SCHEDULE: GoogleDriveSchedule = {
|
||||
enabled: false,
|
||||
hour_utc: 3, // 05:00 Swedish summer time / 04:00 winter — low-traffic default
|
||||
last_auto_sync_at: null,
|
||||
last_auto_sync_status: null,
|
||||
last_auto_sync_error: null,
|
||||
}
|
||||
|
||||
export const cloudBackupExtension: Extension = {
|
||||
@@ -154,7 +135,7 @@ export const cloudBackupExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// Revoke the refresh token and clear the stored connection.
|
||||
// Revoke the refresh token and clear the stored connection + schedule.
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/disconnect',
|
||||
@@ -172,6 +153,7 @@ export const cloudBackupExtension: Extension = {
|
||||
}
|
||||
await ctx.settings.set(CONNECTION_KEY, null)
|
||||
await ctx.settings.set(LAST_SYNC_KEY, null)
|
||||
await ctx.settings.set(SCHEDULE_KEY, null)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err) {
|
||||
ctx.log.error('disconnect failed', err)
|
||||
@@ -191,16 +173,74 @@ export const cloudBackupExtension: Extension = {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const connection = await loadConnection(ctx)
|
||||
const lastSync = await ctx.settings.get<GoogleDriveLastSync>(LAST_SYNC_KEY)
|
||||
const schedule = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||
const status: CloudBackupStatus = {
|
||||
connected: !!connection,
|
||||
account_email: connection?.account_email ?? null,
|
||||
connected_at: connection?.connected_at ?? null,
|
||||
last_sync: lastSync ?? null,
|
||||
schedule: schedule ?? null,
|
||||
}
|
||||
return NextResponse.json({ data: status })
|
||||
},
|
||||
},
|
||||
|
||||
// Read the auto-sync schedule. Returns the default (disabled) shape if
|
||||
// the user has never configured one.
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/schedule',
|
||||
handler: async (_request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
const schedule = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||
return NextResponse.json({ data: schedule ?? DEFAULT_SCHEDULE })
|
||||
},
|
||||
},
|
||||
|
||||
// Update the auto-sync schedule. Preserves `last_auto_sync_*` fields the
|
||||
// cron writes — those are not user-editable.
|
||||
{
|
||||
method: 'PUT',
|
||||
path: '/schedule',
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
enabled?: boolean
|
||||
hour_utc?: number
|
||||
}
|
||||
if (typeof body.enabled !== 'boolean') {
|
||||
return jsonError('enabled must be a boolean', 400)
|
||||
}
|
||||
if (
|
||||
typeof body.hour_utc !== 'number' ||
|
||||
!Number.isInteger(body.hour_utc) ||
|
||||
body.hour_utc < 0 ||
|
||||
body.hour_utc > 23
|
||||
) {
|
||||
return jsonError('hour_utc must be an integer between 0 and 23', 400)
|
||||
}
|
||||
|
||||
const existing = await ctx.settings.get<GoogleDriveSchedule>(SCHEDULE_KEY)
|
||||
const updated: GoogleDriveSchedule = {
|
||||
enabled: body.enabled,
|
||||
hour_utc: body.hour_utc,
|
||||
last_auto_sync_at: existing?.last_auto_sync_at ?? null,
|
||||
last_auto_sync_status: existing?.last_auto_sync_status ?? null,
|
||||
last_auto_sync_error: existing?.last_auto_sync_error ?? null,
|
||||
}
|
||||
await ctx.settings.set(SCHEDULE_KEY, updated)
|
||||
return NextResponse.json({ data: updated })
|
||||
} catch (err) {
|
||||
ctx.log.error('update schedule failed', err)
|
||||
return jsonError(
|
||||
err instanceof Error ? err.message : 'Invalid request body',
|
||||
400
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Generate an archive and upload it to Drive. Returns the Drive file info.
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -208,92 +248,40 @@ export const cloudBackupExtension: Extension = {
|
||||
handler: async (request, ctx) => {
|
||||
if (!ctx) return jsonError('Missing context', 500)
|
||||
try {
|
||||
const connection = await loadConnection(ctx)
|
||||
if (!connection) {
|
||||
return jsonError('not_connected', 400)
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
include_documents?: boolean
|
||||
}
|
||||
const scope: ArchiveScope = 'all'
|
||||
const includeDocuments = body.include_documents !== false
|
||||
|
||||
const estimate = await estimateArchiveSize(ctx.supabase, ctx.companyId, scope)
|
||||
const effectiveBytes = includeDocuments
|
||||
? estimate.total_bytes
|
||||
: estimate.total_bytes - estimate.document_bytes
|
||||
if (effectiveBytes > SIZE_LIMIT_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'archive_too_large',
|
||||
size_bytes: effectiveBytes,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
|
||||
const accessToken = await getFreshAccessToken(ctx, connection)
|
||||
|
||||
// Ensure folder structure. Persist ids on first sync so later runs skip the lookup.
|
||||
let rootFolderId = connection.root_folder_id
|
||||
let companyFolderId = connection.company_folder_id
|
||||
if (!rootFolderId) {
|
||||
const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null)
|
||||
rootFolderId = root.id
|
||||
}
|
||||
if (!companyFolderId) {
|
||||
const companyName = await fetchCompanyName(ctx)
|
||||
const companyFolder = await ensureFolder(
|
||||
accessToken,
|
||||
companyName,
|
||||
rootFolderId
|
||||
)
|
||||
companyFolderId = companyFolder.id
|
||||
}
|
||||
if (
|
||||
rootFolderId !== connection.root_folder_id ||
|
||||
companyFolderId !== connection.company_folder_id
|
||||
) {
|
||||
await ctx.settings.set(CONNECTION_KEY, {
|
||||
...connection,
|
||||
root_folder_id: rootFolderId,
|
||||
company_folder_id: companyFolderId,
|
||||
})
|
||||
}
|
||||
|
||||
const archive = await generateFullArchive(ctx.supabase, ctx.companyId, {
|
||||
scope: 'all',
|
||||
include_documents: includeDocuments,
|
||||
const origin =
|
||||
process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin
|
||||
const result = await performSync({
|
||||
supabase: ctx.supabase,
|
||||
companyId: ctx.companyId,
|
||||
userId: ctx.userId,
|
||||
origin,
|
||||
includeDocuments: body.include_documents !== false,
|
||||
})
|
||||
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
const fileName = `arkiv_full_${stamp}.zip`
|
||||
|
||||
const uploaded = await uploadFile(
|
||||
accessToken,
|
||||
companyFolderId,
|
||||
fileName,
|
||||
archive
|
||||
)
|
||||
|
||||
const lastSync: GoogleDriveLastSync = {
|
||||
at: new Date().toISOString(),
|
||||
file_id: uploaded.id,
|
||||
file_name: uploaded.name,
|
||||
file_size_bytes: uploaded.size_bytes,
|
||||
folder_id: companyFolderId,
|
||||
if (!result.ok) {
|
||||
if (result.reason === 'not_connected') {
|
||||
return jsonError('not_connected', 400)
|
||||
}
|
||||
if (result.reason === 'archive_too_large') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'archive_too_large',
|
||||
size_bytes: result.size_bytes,
|
||||
size_limit_bytes: result.size_limit_bytes,
|
||||
},
|
||||
{ status: 413 }
|
||||
)
|
||||
}
|
||||
return jsonError(result.message, 500)
|
||||
}
|
||||
await ctx.settings.set(LAST_SYNC_KEY, lastSync)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...lastSync,
|
||||
web_view_link: uploaded.web_view_link,
|
||||
...result.lastSync,
|
||||
web_view_link: result.webViewLink,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
generateFullArchive,
|
||||
estimateArchiveSize,
|
||||
} from '@/lib/reports/full-archive-export'
|
||||
import {
|
||||
getOAuthEnv,
|
||||
refreshAccessToken,
|
||||
} from './google-oauth'
|
||||
import { ensureFolder, uploadFile } from './google-drive'
|
||||
import { decryptToken } from './crypto'
|
||||
import type { GoogleDriveConnection, GoogleDriveLastSync } from '../types'
|
||||
|
||||
export const CONNECTION_KEY = 'google_drive_connection'
|
||||
export const LAST_SYNC_KEY = 'google_drive_last_sync'
|
||||
export const SCHEDULE_KEY = 'google_drive_schedule'
|
||||
export const ROOT_FOLDER_NAME = 'gnubok'
|
||||
export const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
|
||||
|
||||
export type SyncFailureReason =
|
||||
| 'not_connected'
|
||||
| 'archive_too_large'
|
||||
| 'upload_failed'
|
||||
| 'internal'
|
||||
|
||||
export type PerformSyncResult =
|
||||
| {
|
||||
ok: true
|
||||
lastSync: GoogleDriveLastSync
|
||||
webViewLink: string
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
reason: SyncFailureReason
|
||||
message: string
|
||||
size_bytes?: number
|
||||
size_limit_bytes?: number
|
||||
}
|
||||
|
||||
interface PerformSyncParams {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
origin: string
|
||||
includeDocuments: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the end-to-end cloud backup sync: estimate size → refresh token →
|
||||
* ensure Drive folder hierarchy → generate archive → upload → persist
|
||||
* last_sync. Shared by the manual HTTP handler and the scheduled cron.
|
||||
*
|
||||
* Settings ops use raw `extension_data` queries (not the extension context
|
||||
* wrapper) because the cron runs under the service role with no user session.
|
||||
*/
|
||||
export async function performSync(params: PerformSyncParams): Promise<PerformSyncResult> {
|
||||
const { supabase, companyId, userId, origin, includeDocuments } = params
|
||||
|
||||
const connection = await loadExtensionData<GoogleDriveConnection>(
|
||||
supabase,
|
||||
companyId,
|
||||
CONNECTION_KEY
|
||||
)
|
||||
if (!connection) {
|
||||
return { ok: false, reason: 'not_connected', message: 'Google Drive not connected' }
|
||||
}
|
||||
|
||||
const estimate = await estimateArchiveSize(supabase, companyId, 'all')
|
||||
const effectiveBytes = includeDocuments
|
||||
? estimate.total_bytes
|
||||
: estimate.total_bytes - estimate.document_bytes
|
||||
if (effectiveBytes > SIZE_LIMIT_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'archive_too_large',
|
||||
message: 'Archive exceeds size limit',
|
||||
size_bytes: effectiveBytes,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
const env = getOAuthEnv(origin)
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
||||
const { access_token: accessToken } = await refreshAccessToken(env, refreshToken)
|
||||
|
||||
let rootFolderId = connection.root_folder_id
|
||||
let companyFolderId = connection.company_folder_id
|
||||
if (!rootFolderId) {
|
||||
const root = await ensureFolder(accessToken, ROOT_FOLDER_NAME, null)
|
||||
rootFolderId = root.id
|
||||
}
|
||||
if (!companyFolderId) {
|
||||
const companyName = await fetchCompanyLabel(supabase, companyId)
|
||||
const companyFolder = await ensureFolder(accessToken, companyName, rootFolderId)
|
||||
companyFolderId = companyFolder.id
|
||||
}
|
||||
if (
|
||||
rootFolderId !== connection.root_folder_id ||
|
||||
companyFolderId !== connection.company_folder_id
|
||||
) {
|
||||
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, {
|
||||
...connection,
|
||||
root_folder_id: rootFolderId,
|
||||
company_folder_id: companyFolderId,
|
||||
})
|
||||
}
|
||||
|
||||
const archive = await generateFullArchive(supabase, companyId, {
|
||||
scope: 'all',
|
||||
include_documents: includeDocuments,
|
||||
})
|
||||
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\..+/, '')
|
||||
const fileName = `arkiv_full_${stamp}.zip`
|
||||
|
||||
const uploaded = await uploadFile(accessToken, companyFolderId, fileName, archive)
|
||||
|
||||
const lastSync: GoogleDriveLastSync = {
|
||||
at: new Date().toISOString(),
|
||||
file_id: uploaded.id,
|
||||
file_name: uploaded.name,
|
||||
file_size_bytes: uploaded.size_bytes,
|
||||
folder_id: companyFolderId,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, LAST_SYNC_KEY, lastSync)
|
||||
|
||||
return { ok: true, lastSync, webViewLink: uploaded.web_view_link }
|
||||
}
|
||||
|
||||
export async function loadExtensionData<T>(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
key: string
|
||||
): Promise<T | null> {
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'cloud-backup')
|
||||
.eq('key', key)
|
||||
.maybeSingle()
|
||||
return (data?.value as T) ?? null
|
||||
}
|
||||
|
||||
export async function saveExtensionData<T>(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
key: string,
|
||||
value: T
|
||||
): Promise<void> {
|
||||
const { error } = await supabase.from('extension_data').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
extension_id: 'cloud-backup',
|
||||
key,
|
||||
value,
|
||||
},
|
||||
{ onConflict: 'company_id,extension_id,key' }
|
||||
)
|
||||
if (error) throw new Error(`Failed to save extension data: ${error.message}`)
|
||||
}
|
||||
|
||||
async function fetchCompanyLabel(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<string> {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const name = (data?.company_name as string) || 'företag'
|
||||
const org = (data?.org_number as string) || companyId.slice(0, 8)
|
||||
return `${name} (${org})`.replace(/[\\/]/g, '-')
|
||||
}
|
||||
@@ -25,7 +25,24 @@ export interface GoogleDriveLastSync {
|
||||
}
|
||||
|
||||
/**
|
||||
* Status returned to the UI. Mirrors the two storage shapes above in a
|
||||
* Schedule stored under key `google_drive_schedule`. `hour_utc` is 0-23 in UTC;
|
||||
* the UI converts to/from the user's local timezone. Runs once per day at that
|
||||
* hour via a cron route (`app/api/extensions/cloud-backup/auto-sync/cron`).
|
||||
*/
|
||||
export interface GoogleDriveSchedule {
|
||||
enabled: boolean
|
||||
/** 0-23, UTC hour when the daily auto-sync should run. */
|
||||
hour_utc: number
|
||||
/** ISO timestamp of the last auto-sync attempt (success or failure). */
|
||||
last_auto_sync_at: string | null
|
||||
/** Outcome of the last auto-sync attempt. */
|
||||
last_auto_sync_status: 'success' | 'error' | null
|
||||
/** Short error message if the last auto-sync failed. */
|
||||
last_auto_sync_error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Status returned to the UI. Mirrors the storage shapes above in a
|
||||
* shape safe to expose to the client (no encrypted token).
|
||||
*/
|
||||
export interface CloudBackupStatus {
|
||||
@@ -33,4 +50,5 @@ export interface CloudBackupStatus {
|
||||
account_email: string | null
|
||||
connected_at: string | null
|
||||
last_sync: GoogleDriveLastSync | null
|
||||
schedule: GoogleDriveSchedule | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration: Public `logos` storage bucket for company logos
|
||||
--
|
||||
-- Logos render on invoices, the invoice PDF, and the public invoice-action
|
||||
-- page, so they need to be fetchable without signed URLs. The existing
|
||||
-- `documents` bucket is private (WORM archive) and not appropriate for
|
||||
-- this use case — `getPublicUrl()` on that bucket returns an unusable URL.
|
||||
--
|
||||
-- This bucket is world-readable; writes happen via the server route using
|
||||
-- the service role, so we do not need user-scoped INSERT/UPDATE/DELETE
|
||||
-- policies for authenticated clients.
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||
VALUES (
|
||||
'logos',
|
||||
'logos',
|
||||
true,
|
||||
2097152, -- 2 MB
|
||||
ARRAY['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET public = EXCLUDED.public,
|
||||
file_size_limit = EXCLUDED.file_size_limit,
|
||||
allowed_mime_types = EXCLUDED.allowed_mime_types;
|
||||
@@ -27,6 +27,10 @@
|
||||
{
|
||||
"path": "/api/events/cleanup/cron",
|
||||
"schedule": "0 2 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/extensions/cloud-backup/auto-sync/cron",
|
||||
"schedule": "0 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user