diff --git a/DECISIONS.md b/DECISIONS.md index 5eb54cca..8fa8bf61 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -611,3 +611,10 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-27] Removed the granular "BFNAR 2013:2 punkt 9.x" citations from public/docs/systemdokumentation-mall.md instead of renumbering them to kap 8: the swedish-accounting-compliance skill and our own route comments both place systemdokumentation in kap 8, the template asserted kapitel 9 with a full point-by-point mapping (9.1, 9.2, 9.3-9.5, 9.6-9.8, 9.9, 9.16), and neither could be verified against BFN's actual text from here. A downloadable statutory template is the wrong place to guess a legal citation, and a wrong precise cite is worse than a correct general one, so the doc now cites BFL 5 kap. 11 § and BFNAR 2013:2 without pinning points. Restore the points once someone confirms the chapter against the standard. [2026-07-27] Abonnemang masthead uses the halftone Stockholm skyline, not key-adding-machine and no new asset: the wide engraving crops cleanly into a banner strip (waterline pinned to the bottom edge, same physics as the onboarding backdrop) while the machine vignette read noisy and lopsided at banner crop; screenshots of both compared before choosing. [2026-07-27] The requested Claude/OpenAI logos went into the API tab's "Anslut MCP-klient" group as a quiet works-with strip, using the marketing site's monochrome halftone marks instead of the raw colored brand tiles: full-color trademark tiles would fight the editorial monochrome chrome, and the halftone versions already exist as shared brand assets. +[2026-07-27] Dropbox added as a second cloud-backup target behind a CloudStorageProvider interface rather than by copying the Drive sync: the archive-building half of performSync (fingerprints, per-year layout, size fallback, progressive persistence) is where the compliance-relevant behaviour lives, and a fork of it would drift silently. +[2026-07-27] Dropbox uses App folder access, not full Dropbox: it matches the drive.file scope's "only what the app created" story, which is the privacy claim the backup card makes, and it is what Dropbox production approval expects for a backup app. +[2026-07-27] Each provider keeps its own extension_data keys (google_drive_* / dropbox_*) and its own schedule, failure counter and alert throttle: Emil chose independent per-provider schedules, and it means a dead Dropbox token cannot pause a healthy Drive backup. +[2026-07-27] The Google storage keys and the /oauth/callback path were left untouched: they are wire format (already-connected companies' records, plus a redirect URI registered with Google), so Dropbox got additive keys and its own /oauth/dropbox/callback. +[2026-07-27] isConfigured() gates /connect only, not /disconnect, /schedule or /sync: a deployment losing its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off. +[2026-07-27] Dropbox web links point at /home/Apps unless DROPBOX_APP_FOLDER_NAME is set: app-folder scoped calls cannot discover where the app folder sits in the user's account, and a link into the wrong folder reads as a lost backup, so the app-name deep link is opt-in rather than guessed. +[2026-07-27] performSync keeps `provider` optional, defaulting to Google Drive: it preserves the pre-Dropbox call shape (and its test suite) as the documented legacy path while all three production call sites pass a provider explicitly. diff --git a/app/(public)/privacy/page.tsx b/app/(public)/privacy/page.tsx index ed989bfb..c38ac9f3 100644 --- a/app/(public)/privacy/page.tsx +++ b/app/(public)/privacy/page.tsx @@ -12,7 +12,7 @@ export function generateMetadata(): Metadata { export default function PrivacyPolicyPage() { const { appName, legalEntity, privacyEmail } = getBranding() return ( -
+

@@ -27,7 +27,7 @@ export default function PrivacyPolicyPage() { 1. Personuppgiftsansvarig - +

{legalEntity} ("vi", "oss") är personuppgiftsansvarig för behandlingen av dina personuppgifter i samband med användningen av {appName}. Vi behandlar dina uppgifter i @@ -40,7 +40,7 @@ export default function PrivacyPolicyPage() { 2. Vilka uppgifter vi behandlar - +

Vi behandlar följande kategorier av personuppgifter:

  • Kontouppgifter: E-postadress (för inloggning)
  • @@ -59,7 +59,7 @@ export default function PrivacyPolicyPage() { 3. Rättslig grund (GDPR Art. 6) - +
    • Avtal (Art. 6.1b): Behandling som är nödvändig för att fullgöra våra @@ -85,7 +85,7 @@ export default function PrivacyPolicyPage() { 4. Underbiträden - +

      Vi använder följande underbiträden för att tillhandahålla tjänsten. Uppgifterna nedan anger vilka uppgifter som delas med respektive underbiträde, syftet samt var behandlingen sker @@ -191,7 +191,7 @@ export default function PrivacyPolicyPage() { 5. Tredjelandsöverföring - +

      Vissa underbiträden är baserade i USA. För dessa överföringar används EU-kommissionens standardavtalsklausuler (SCCs) som skyddsmekanism i enlighet med GDPR kapitel V. @@ -206,7 +206,7 @@ export default function PrivacyPolicyPage() { 6. Lagringstid - +

      • Bokföringsmaterial: 7 år från räkenskapsårets slut, i enlighet @@ -237,7 +237,7 @@ export default function PrivacyPolicyPage() { 7. Dina rättigheter - +

        Du har följande rättigheter enligt GDPR:

        • Tillgång (Art. 15): Du kan begära en kopia av alla dina personuppgifter.
        • @@ -261,7 +261,7 @@ export default function PrivacyPolicyPage() { 8. Kontaktuppgifter - +

          För frågor om behandlingen av dina personuppgifter, kontakta oss:

          diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts index 5d84f57a..196abc22 100644 --- a/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts +++ b/app/api/extensions/cloud-backup/auto-sync/cron/__tests__/route.test.ts @@ -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 = {}) { 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 = {}) { } } +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()], { diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts index e496d6a9..faa3f1ec 100644 --- a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts +++ b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts @@ -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( + 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() + 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() + // 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() 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, + }) } } diff --git a/components/dashboard/BackupHealthBanner.tsx b/components/dashboard/BackupHealthBanner.tsx index 15f49112..934e03ba 100644 --- a/components/dashboard/BackupHealthBanner.tsx +++ b/components/dashboard/BackupHealthBanner.tsx @@ -8,17 +8,35 @@ import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-exten // Local mirror of the cloud-backup status shape: core must not import from // @/extensions/, so the fields we read are declared here. -interface BackupStatus { +interface BackupProviderStatus { + provider: string connected: boolean needs_reauth: boolean schedule: { last_auto_sync_status: 'success' | 'error' | null } | null } +interface BackupStatus { + providers?: BackupProviderStatus[] + // Pre-multi-provider shape, describing Google Drive alone. + connected: boolean + needs_reauth: boolean + schedule: { last_auto_sync_status: 'success' | 'error' | null } | null +} + +/** Brand names stay untranslated; the sentence around them is localised. */ +const PROVIDER_LABELS: Record = { + google_drive: 'Google Drive', + dropbox: 'Dropbox', +} + /** - * Warning shown on the dashboard ONLY when the Google Drive backup is failing + * Warning shown on the dashboard ONLY when a connected cloud backup is failing * (dead token or errored auto-sync). A backup that silently stops is worse * than none; this makes the failure visible where the user actually is. * Renders nothing when the extension is off, disconnected, or healthy. + * + * With more than one destination connected, a failure on either one surfaces: + * a working Drive backup does not make a broken Dropbox backup acceptable. */ export default function BackupHealthBanner() { const t = useTranslations('extensions') @@ -40,19 +58,39 @@ export default function BackupHealthBanner() { } }, []) - if (!status?.connected) return null - const failing = - status.needs_reauth || status.schedule?.last_auto_sync_status === 'error' - if (!failing) return null + if (!status) return null + + const providers: BackupProviderStatus[] = status.providers ?? [ + { + provider: 'google_drive', + connected: status.connected, + needs_reauth: status.needs_reauth, + schedule: status.schedule, + }, + ] + + const failing = providers.filter( + (p) => + p.connected && + (p.needs_reauth || p.schedule?.last_auto_sync_status === 'error') + ) + if (failing.length === 0) return null + + // One sentence covering everything that is broken, so two dead connections + // do not stack two banners on the dashboard. + const names = failing + .map((p) => PROVIDER_LABELS[p.provider] ?? p.provider) + .join(' + ') + const allNeedReauth = failing.every((p) => p.needs_reauth) return (

          - {status.needs_reauth - ? t('ext_cloud_backup_banner_reauth') - : t('ext_cloud_backup_banner_failing')} + {allNeedReauth + ? t('ext_cloud_backup_banner_reauth', { provider: names }) + : t('ext_cloud_backup_banner_failing', { provider: names })}

          Molnsynkronisering

          - Koppla ditt Google Drive-konto under Importera/Exportera för att synka arkiv till - din egen molnlagring. + Koppla ditt Google Drive- eller Dropbox-konto under Importera/Exportera för att + synka arkiv till din egen molnlagring.

          @@ -297,7 +367,10 @@ export default function CloudBackupCard() {
          {status.last_sync ? ( - + ) : ( {t('ext_cloud_backup_never')} @@ -309,9 +382,11 @@ export default function CloudBackupCard() {
          @@ -352,7 +427,11 @@ export default function CloudBackupCard() { ) : ( <>

          - {t('ext_cloud_backup_connect_description')} + {t( + providerId === 'dropbox' + ? 'ext_cloud_backup_connect_description_dropbox' + : 'ext_cloud_backup_connect_description_google' + )}

          @@ -378,15 +457,26 @@ export default function CloudBackupCard() { } /** - * Last-sync cell. New records list the per-fiscal-year files and link to the - * Drive folder; legacy single-ZIP records link to the file. + * Last-sync cell. Records written since the Dropbox target landed carry their + * own `web_view_link`; older Drive records only have a folder id, so the Drive + * URL is reconstructed. Legacy single-ZIP records link to the file itself. */ -function LastSyncSummary({ lastSync }: { lastSync: GoogleDriveLastSync }) { +function LastSyncSummary({ + lastSync, + providerId, +}: { + lastSync: CloudLastSync + providerId: CloudProviderId +}) { const t = useTranslations('extensions') const files = lastSync.files - const href = files - ? `https://drive.google.com/drive/folders/${lastSync.folder_id}` - : `https://drive.google.com/file/d/${lastSync.file_id}/view` + const href = + lastSync.web_view_link ?? + (providerId === 'google_drive' + ? files + ? `https://drive.google.com/drive/folders/${lastSync.folder_id}` + : `https://drive.google.com/file/d/${lastSync.file_id}/view` + : 'https://www.dropbox.com/home/Apps') const sizeBytes = files ? lastSync.total_size_bytes ?? 0 : lastSync.file_size_bytes ?? 0 @@ -439,18 +529,26 @@ function formatDateTime(iso: string): string { } interface ScheduleSectionProps { - schedule: GoogleDriveSchedule | null + providerId: CloudProviderId + provider: string + schedule: CloudSchedule | null needsReauth: boolean onUpdated: () => Promise | void } -function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionProps) { +function ScheduleSection({ + providerId, + provider, + schedule, + needsReauth, + onUpdated, +}: ScheduleSectionProps) { const { toast } = useToast() const t = useTranslations('extensions') // Prefer the DST-stable Stockholm hour; fall back to converting the legacy // UTC hour through the browser's clock (Swedish users: same thing). - const scheduleHour = (s: GoogleDriveSchedule | null): number => + const scheduleHour = (s: CloudSchedule | null): number => typeof s?.hour_local === 'number' ? s.hour_local : utcHourToLocalHour(typeof s?.hour_utc === 'number' ? s.hour_utc : 3) @@ -459,6 +557,10 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr const [localHour, setLocalHour] = useState(scheduleHour(schedule)) const [isSaving, setIsSaving] = useState(false) + // Each provider renders its own controls, so the ids must not collide. + const toggleId = `auto-sync-toggle-${providerId}` + const hourId = `auto-sync-hour-${providerId}` + useEffect(() => { setEnabled(schedule?.enabled ?? false) setLocalHour(scheduleHour(schedule)) @@ -469,14 +571,17 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr 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_local: nextLocalHour, - }), - }) + const res = await fetch( + `${API_BASE}/schedule?provider=${encodeURIComponent(providerId)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: nextEnabled, + hour_local: nextLocalHour, + }), + } + ) if (!res.ok) { const body = await res.json().catch(() => ({})) throw new Error(body.error || t('ext_cloud_backup_schedule_save_failed')) @@ -492,7 +597,7 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr setIsSaving(false) } }, - [onUpdated, t, toast] + [onUpdated, providerId, t, toast] ) const handleToggle = useCallback( @@ -516,15 +621,15 @@ function ScheduleSection({ schedule, needsReauth, onUpdated }: ScheduleSectionPr
          -
          -