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:
Mattsson
2026-04-20 13:42:37 +02:00
committed by GitHub
co-authored by greptile-apps[bot]
parent c0b74d50e4
commit 7a0214c053
10 changed files with 930 additions and 130 deletions
@@ -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()
}
+98 -110
View File
@@ -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) {
+180
View File
@@ -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, '-')
}
+19 -1
View File
@@ -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
}