Add/db and speed (#1243)
* fix(privacy): make privacy policy page dark mode friendly Replace the hardcoded light gradient background with bg-background and add dark:prose-invert to the prose blocks so body text is readable on dark cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cloud-backup): sync archives to Dropbox alongside Google Drive Introduce a CloudStorageProvider interface so performSync builds the archive set once and talks to storage only through it. Google Drive keeps its existing behaviour; Dropbox is a second implementation, so the compliance-relevant half (fingerprints, per-year layout, size fallback, progressive persistence) cannot drift between targets. Dropbox uses App folder access, matching the drive.file scope's "only what the app created" guarantee. Uploads are single-shot under 8 MB and chunked upload sessions above, every write verified against Dropbox's content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so Swedish file names survive the Dropbox-API-Arg header. Each provider owns its extension_data keys, schedule, failure counter and alert throttle, so a dead Dropbox token cannot pause a healthy Drive backup. The google_drive_* keys and the /oauth/callback path are untouched: both are wire format for already-connected companies. isConfigured() gates /connect only. A deployment that loses its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off. Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row renders disabled without them. No migration: state is extension_data JSON throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: remove merge-conflict markers committed in DECISIONS.md The merge that brought main into this branch staged DECISIONS.md while it still carried conflict markers, so cdc3a513 shipped an unresolved hunk (compliance swarm ISO 27001 A.8.32). DECISIONS.md is an append-only log, so both sides are kept: main's systemdokumentation entry followed by this branch's Dropbox entries. No decision was dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a7152a7af
commit
fbd4b992f5
@@ -50,9 +50,9 @@ function makeRequest() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The route issues two queries against extension_data: schedules
|
||||
* (key = google_drive_schedule) and connections (key = google_drive_connection,
|
||||
* with an .in() filter). Route rows to the right result by the `key` eq filter.
|
||||
* The route issues two queries against extension_data: every provider's
|
||||
* schedule keys, then the matching connection keys. Both use `.in('key', ...)`,
|
||||
* so route rows to the right result by inspecting the key list.
|
||||
*/
|
||||
function makeSupabaseStub(
|
||||
scheduleRows: unknown[],
|
||||
@@ -63,16 +63,18 @@ function makeSupabaseStub(
|
||||
} = {}
|
||||
) {
|
||||
const from = vi.fn().mockImplementation(() => {
|
||||
let key: string | null = null
|
||||
let kind: 'schedule' | 'connection' = 'schedule'
|
||||
const chain: any = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockImplementation((column: string, value: string) => {
|
||||
if (column === 'key') key = value
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
in: vi.fn().mockImplementation((column: string, values: string[]) => {
|
||||
if (column === 'key') {
|
||||
kind = values.some((v) => v.endsWith('_connection')) ? 'connection' : 'schedule'
|
||||
}
|
||||
return chain
|
||||
}),
|
||||
then: (resolve: (v: unknown) => void) => {
|
||||
if (key === 'google_drive_connection') {
|
||||
if (kind === 'connection') {
|
||||
return resolve({
|
||||
data: options.connectionRows ?? [],
|
||||
error: options.connectionError ?? null,
|
||||
@@ -93,6 +95,7 @@ function scheduleRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
key: 'google_drive_schedule',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: 12,
|
||||
@@ -104,6 +107,10 @@ function scheduleRow(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function connectionRow(companyId: string, value: unknown, key = 'google_drive_connection') {
|
||||
return { company_id: companyId, key, value }
|
||||
}
|
||||
|
||||
function okSyncResult() {
|
||||
return {
|
||||
ok: true as const,
|
||||
@@ -126,6 +133,12 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
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'
|
||||
// Without credentials the cron deliberately skips a provider, so the
|
||||
// Google flow has to look configured for these tests to exercise it.
|
||||
process.env.GOOGLE_CLIENT_ID = 'client-id'
|
||||
process.env.GOOGLE_CLIENT_SECRET = 'client-secret'
|
||||
delete process.env.DROPBOX_APP_KEY
|
||||
delete process.env.DROPBOX_APP_SECRET
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
mockSendBackupFailureAlert.mockResolvedValue({ sent: true })
|
||||
})
|
||||
@@ -338,10 +351,7 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-1',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
connectionRow('c-1', { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' }),
|
||||
],
|
||||
})
|
||||
)
|
||||
@@ -352,7 +362,7 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(body.results).toEqual([
|
||||
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
|
||||
{ companyId: 'c-1', provider: 'google_drive', status: 'skipped', error: 'needs_reauth' },
|
||||
])
|
||||
// The incident had not been alerted yet: one alert, persisted on the schedule.
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
@@ -372,10 +382,7 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
[scheduleRow({ last_alert_at: '2026-07-10T04:00:00.000Z' })],
|
||||
{
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-1',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
connectionRow('c-1', { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' }),
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -398,11 +405,8 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
],
|
||||
{
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-dead',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
{ company_id: 'c-live', value: { status: 'active' } },
|
||||
connectionRow('c-dead', { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' }),
|
||||
connectionRow('c-live', { status: 'active' }),
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -420,6 +424,78 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
expect(body.successes).toBe(1)
|
||||
})
|
||||
|
||||
it('runs both providers for the same company as independent jobs', async () => {
|
||||
process.env.DROPBOX_APP_KEY = 'app-key'
|
||||
process.env.DROPBOX_APP_SECRET = 'app-secret'
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
scheduleRow(),
|
||||
{ ...scheduleRow(), key: 'dropbox_schedule' },
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValue(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).toHaveBeenCalledTimes(2)
|
||||
const targets = mockPerformSync.mock.calls.map((c) => c[0].provider?.id)
|
||||
expect(targets).toEqual(['google_drive', 'dropbox'])
|
||||
// Each writes back to its own schedule record.
|
||||
const writtenKeys = mockSaveExtensionData.mock.calls.map((c) => c[3])
|
||||
expect(writtenKeys).toEqual(['google_drive_schedule', 'dropbox_schedule'])
|
||||
expect(body.successes).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps one provider running when the other holds a dead token', async () => {
|
||||
process.env.DROPBOX_APP_KEY = 'app-key'
|
||||
process.env.DROPBOX_APP_SECRET = 'app-secret'
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[scheduleRow(), { ...scheduleRow(), key: 'dropbox_schedule' }],
|
||||
{
|
||||
connectionRows: [
|
||||
connectionRow(
|
||||
'c-1',
|
||||
{ status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
'dropbox_connection'
|
||||
),
|
||||
connectionRow('c-1', { status: 'active' }),
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
mockPerformSync.mockResolvedValue(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
// A dead Dropbox token must not stop the healthy Drive backup.
|
||||
expect(mockPerformSync).toHaveBeenCalledTimes(1)
|
||||
expect(mockPerformSync.mock.calls[0][0].provider?.id).toBe('google_drive')
|
||||
expect(body.successes).toBe(1)
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'needs_reauth', providerLabel: 'Dropbox' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips a due schedule whose provider has no credentials here', async () => {
|
||||
// Dropbox stays unconfigured (see beforeEach): the schedule exists but the
|
||||
// deployment cannot honour it, so it must not burn the failure counter.
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([{ ...scheduleRow(), key: 'dropbox_schedule' }])
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(mockSaveExtensionData).not.toHaveBeenCalled()
|
||||
expect(body.processed).toBe(0)
|
||||
})
|
||||
|
||||
it('fails open and attempts the sync when the connection lookup errors', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
|
||||
@@ -3,35 +3,37 @@ import { NextResponse } from 'next/server'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
performSync,
|
||||
CONNECTION_KEY,
|
||||
SCHEDULE_KEY,
|
||||
saveExtensionData,
|
||||
} from '@/extensions/general/cloud-backup/lib/sync'
|
||||
import { performSync, saveExtensionData } from '@/extensions/general/cloud-backup/lib/sync'
|
||||
import { isScheduleDue } from '@/extensions/general/cloud-backup/lib/schedule'
|
||||
import { CLOUD_PROVIDERS } from '@/extensions/general/cloud-backup/lib/provider-registry'
|
||||
import {
|
||||
sendBackupFailureAlert,
|
||||
shouldSendBackupAlert,
|
||||
type BackupAlertKind,
|
||||
} from '@/extensions/general/cloud-backup/lib/backup-alert'
|
||||
import type { CloudStorageProvider } from '@/extensions/general/cloud-backup/lib/cloud-provider'
|
||||
import type {
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveSchedule,
|
||||
CloudConnection,
|
||||
CloudSchedule,
|
||||
} from '@/extensions/general/cloud-backup/types'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/cloud-backup/auto-sync/cron
|
||||
*
|
||||
* Runs hourly. Finds all companies whose auto-sync is due (daily slot has
|
||||
* passed and no attempt has run since it: see `isScheduleDue`) and triggers a
|
||||
* full Drive backup for each via the shared `performSync()` helper. Companies
|
||||
* left over when a run hits its time budget stay due and are picked up by the
|
||||
* next hourly run instead of losing the day.
|
||||
* Runs hourly. Finds every (company, provider) pair whose auto-sync is due
|
||||
* (daily slot has passed and no attempt has run since it: see `isScheduleDue`)
|
||||
* and triggers a full backup for each via the shared `performSync()` helper.
|
||||
* Pairs left over when a run hits its time budget stay due and are picked up
|
||||
* by the next hourly run instead of losing the day.
|
||||
*
|
||||
* Failures increment `consecutive_failures` on the schedule; alert emails go
|
||||
* out on dead tokens (once per incident) and repeated failures (threshold in
|
||||
* `backup-alert.ts`), throttled per company.
|
||||
* Providers are scheduled independently: a company can back up to Google Drive
|
||||
* nightly and to Dropbox weekly, and a Dropbox failure never marks the Drive
|
||||
* backup unhealthy. Each provider keeps its own schedule record, failure
|
||||
* counter and alert throttle.
|
||||
*
|
||||
* Failures increment `consecutive_failures` on that provider's schedule; alert
|
||||
* emails go out on dead tokens (once per incident) and repeated failures
|
||||
* (threshold in `backup-alert.ts`), throttled per company and provider.
|
||||
*
|
||||
* 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
|
||||
@@ -52,11 +54,20 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const now = new Date()
|
||||
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
// Every provider's schedules are fetched, but only providers this deployment
|
||||
// has credentials for are run: without them every sync would fail on the
|
||||
// token refresh and burn the failure counter. Schedules belonging to an
|
||||
// unconfigured provider are counted and logged rather than dropped silently,
|
||||
// because that is a deployment mistake someone needs to see.
|
||||
const providerByScheduleKey = new Map<string, CloudStorageProvider>(
|
||||
CLOUD_PROVIDERS.map((p) => [p.keys.schedule, p])
|
||||
)
|
||||
|
||||
const { data: rows, error } = await supabase
|
||||
.from('extension_data')
|
||||
.select('company_id, user_id, value')
|
||||
.select('company_id, user_id, key, value')
|
||||
.eq('extension_id', 'cloud-backup')
|
||||
.eq('key', SCHEDULE_KEY)
|
||||
.in('key', [...providerByScheduleKey.keys()])
|
||||
|
||||
if (error) {
|
||||
ctx.log.error('failed to fetch schedules', error, {
|
||||
@@ -70,9 +81,40 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
return NextResponse.json({ message: 'No schedules configured', processed: 0 })
|
||||
}
|
||||
|
||||
const candidates = rows.filter((r) =>
|
||||
isScheduleDue(r.value as GoogleDriveSchedule | null, now)
|
||||
)
|
||||
interface Candidate {
|
||||
companyId: string
|
||||
userId: string
|
||||
provider: CloudStorageProvider
|
||||
schedule: CloudSchedule
|
||||
}
|
||||
|
||||
const candidates: Candidate[] = []
|
||||
const unconfigured = new Map<string, number>()
|
||||
for (const row of rows) {
|
||||
const provider = providerByScheduleKey.get(row.key as string)
|
||||
if (!provider) continue
|
||||
const schedule = row.value as CloudSchedule | null
|
||||
if (!isScheduleDue(schedule, now)) continue
|
||||
if (!provider.isConfigured()) {
|
||||
unconfigured.set(provider.id, (unconfigured.get(provider.id) ?? 0) + 1)
|
||||
continue
|
||||
}
|
||||
candidates.push({
|
||||
companyId: row.company_id as string,
|
||||
userId: row.user_id as string,
|
||||
provider,
|
||||
schedule: schedule as CloudSchedule,
|
||||
})
|
||||
}
|
||||
|
||||
if (unconfigured.size > 0) {
|
||||
// Companies are expecting a backup that this deployment cannot perform.
|
||||
ctx.log.error(
|
||||
'due backups skipped: provider has no OAuth credentials in this environment',
|
||||
undefined,
|
||||
{ skipped: Object.fromEntries(unconfigured) }
|
||||
)
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -83,17 +125,17 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
}
|
||||
|
||||
// Connections flagged needs_reauth carry a permanently dead refresh token
|
||||
// (Google returned 400 invalid_grant): skip them instead of retrying every
|
||||
// night. They stay visible in the UI until the user reconnects.
|
||||
// (the provider returned 400 invalid_grant): skip them instead of retrying
|
||||
// every night. They stay visible in the UI until the user reconnects.
|
||||
const connectionKeys = [
|
||||
...new Set(candidates.map((c) => c.provider.keys.connection)),
|
||||
]
|
||||
const { data: connectionRows, error: connectionError } = await supabase
|
||||
.from('extension_data')
|
||||
.select('company_id, value')
|
||||
.select('company_id, key, value')
|
||||
.eq('extension_id', 'cloud-backup')
|
||||
.eq('key', CONNECTION_KEY)
|
||||
.in(
|
||||
'company_id',
|
||||
candidates.map((r) => r.company_id as string)
|
||||
)
|
||||
.in('key', connectionKeys)
|
||||
.in('company_id', [...new Set(candidates.map((c) => c.companyId))])
|
||||
|
||||
if (connectionError) {
|
||||
// Fail open: without connection data we cannot tell who needs reauth,
|
||||
@@ -103,10 +145,12 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
})
|
||||
}
|
||||
|
||||
const connectionByCompany = new Map<string, GoogleDriveConnection>()
|
||||
// Keyed by company + connection key: one company can hold a healthy Drive
|
||||
// connection and a dead Dropbox one at the same time.
|
||||
const connectionByCompanyAndKey = new Map<string, CloudConnection>()
|
||||
for (const r of connectionRows ?? []) {
|
||||
const value = r.value as GoogleDriveConnection | null
|
||||
if (value) connectionByCompany.set(r.company_id as string, value)
|
||||
const value = r.value as CloudConnection | null
|
||||
if (value) connectionByCompanyAndKey.set(`${r.company_id}:${r.key}`, value)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
@@ -114,6 +158,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
|
||||
const results: {
|
||||
companyId: string
|
||||
provider: string
|
||||
status: 'success' | 'error' | 'skipped'
|
||||
error?: string
|
||||
}[] = []
|
||||
@@ -125,6 +170,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const maybeAlert = async (params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
providerLabel: string
|
||||
kind: BackupAlertKind
|
||||
consecutiveFailures: number
|
||||
errorMessage: string | null
|
||||
@@ -144,6 +190,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const sent = await sendBackupFailureAlert(supabase, {
|
||||
companyId: params.companyId,
|
||||
userId: params.userId,
|
||||
providerLabel: params.providerLabel,
|
||||
kind: params.kind,
|
||||
consecutiveFailures: params.consecutiveFailures,
|
||||
errorMessage: params.errorMessage,
|
||||
@@ -152,7 +199,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
return sent.sent ? new Date().toISOString() : prior
|
||||
}
|
||||
|
||||
for (const row of candidates) {
|
||||
for (const candidate of candidates) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
ctx.log.info('time budget reached', {
|
||||
processedSoFar: results.length,
|
||||
@@ -161,11 +208,12 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
break
|
||||
}
|
||||
|
||||
const companyId = row.company_id as string
|
||||
const userId = row.user_id as string
|
||||
const schedule = row.value as GoogleDriveSchedule
|
||||
const { companyId, userId, provider, schedule } = candidate
|
||||
const scheduleKey = provider.keys.schedule
|
||||
|
||||
const connection = connectionByCompany.get(companyId)
|
||||
const connection = connectionByCompanyAndKey.get(
|
||||
`${companyId}:${provider.keys.connection}`
|
||||
)
|
||||
if (connection?.status === 'needs_reauth') {
|
||||
// Do not touch last_auto_sync_* here: the schedule keeps showing the
|
||||
// failure from the night the dead token was detected. But make sure the
|
||||
@@ -180,21 +228,30 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
providerLabel: provider.label,
|
||||
kind: 'needs_reauth',
|
||||
consecutiveFailures: schedule.consecutive_failures ?? 0,
|
||||
errorMessage: null,
|
||||
lastAlertAt: schedule.last_alert_at,
|
||||
})
|
||||
if (lastAlertAt !== (schedule.last_alert_at ?? null)) {
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, {
|
||||
await saveExtensionData(supabase, companyId, userId, scheduleKey, {
|
||||
...schedule,
|
||||
last_alert_at: lastAlertAt,
|
||||
}).catch((persistErr) => {
|
||||
ctx.log.error('failed to persist alert state', persistErr as Error, { companyId })
|
||||
ctx.log.error('failed to persist alert state', persistErr as Error, {
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
results.push({ companyId, status: 'skipped', error: 'needs_reauth' })
|
||||
results.push({
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
status: 'skipped',
|
||||
error: 'needs_reauth',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -206,6 +263,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
origin,
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
provider,
|
||||
})
|
||||
|
||||
const consecutiveFailures = syncResult.ok
|
||||
@@ -217,6 +275,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
providerLabel: provider.label,
|
||||
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
|
||||
consecutiveFailures,
|
||||
errorMessage: safeSyncError,
|
||||
@@ -224,7 +283,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
})
|
||||
}
|
||||
|
||||
const updated: GoogleDriveSchedule = {
|
||||
const updated: CloudSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
|
||||
@@ -232,10 +291,11 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated)
|
||||
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated)
|
||||
|
||||
results.push({
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
status: syncResult.ok ? 'success' : 'error',
|
||||
error: safeSyncError ?? undefined,
|
||||
})
|
||||
@@ -243,19 +303,21 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const safeMessage = getErrorMessage(err)
|
||||
ctx.log.error('cloud backup sync failed for company', err as Error, {
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
})
|
||||
|
||||
const consecutiveFailures = (schedule.consecutive_failures ?? 0) + 1
|
||||
const lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
providerLabel: provider.label,
|
||||
kind: 'repeated_failures',
|
||||
consecutiveFailures,
|
||||
errorMessage: safeMessage.slice(0, 200),
|
||||
lastAlertAt: schedule.last_alert_at,
|
||||
})
|
||||
|
||||
const updated: GoogleDriveSchedule = {
|
||||
const updated: CloudSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: 'error',
|
||||
@@ -263,13 +325,21 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated).catch(
|
||||
await saveExtensionData(supabase, companyId, userId, scheduleKey, updated).catch(
|
||||
(persistErr) => {
|
||||
ctx.log.error('failed to persist failure state', persistErr as Error, { companyId })
|
||||
ctx.log.error('failed to persist failure state', persistErr as Error, {
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
results.push({ companyId, status: 'error', error: safeMessage })
|
||||
results.push({
|
||||
companyId,
|
||||
provider: provider.id,
|
||||
status: 'error',
|
||||
error: safeMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user