From fbd4b992f5ca1a7e9516d3d9e631b6b58b8d584a Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:49:24 +0200 Subject: [PATCH] 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 7 + app/(public)/privacy/page.tsx | 18 +- .../auto-sync/cron/__tests__/route.test.ts | 120 ++++-- .../cloud-backup/auto-sync/cron/route.ts | 162 +++++--- components/dashboard/BackupHealthBanner.tsx | 56 ++- .../general/CloudBackupWorkspace.tsx | 4 +- .../__tests__/provider-routes.test.ts | 291 ++++++++++++++ .../components/CloudBackupCard.tsx | 235 ++++++++---- extensions/general/cloud-backup/index.ts | 358 +++++++++++------- .../lib/__tests__/dropbox-client.test.ts | 246 ++++++++++++ .../lib/__tests__/dropbox-provider.test.ts | 179 +++++++++ .../lib/__tests__/sync-providers.test.ts | 284 ++++++++++++++ .../general/cloud-backup/lib/backup-alert.ts | 17 +- .../cloud-backup/lib/cloud-provider.ts | 127 +++++++ .../cloud-backup/lib/dropbox-client.ts | 273 +++++++++++++ .../general/cloud-backup/lib/dropbox-oauth.ts | 195 ++++++++++ .../cloud-backup/lib/dropbox-provider.ts | 148 ++++++++ .../general/cloud-backup/lib/folder-names.ts | 18 + .../general/cloud-backup/lib/google-oauth.ts | 31 +- .../cloud-backup/lib/google-provider.ts | 148 ++++++++ .../cloud-backup/lib/provider-registry.ts | 33 ++ extensions/general/cloud-backup/lib/sync.ts | 169 ++++----- extensions/general/cloud-backup/manifest.json | 9 +- extensions/general/cloud-backup/types.ts | 126 ++++-- .../_generated/sector-definitions.ts | 4 +- messages/en.json | 36 +- messages/sv.json | 36 +- 27 files changed, 2852 insertions(+), 478 deletions(-) create mode 100644 extensions/general/cloud-backup/__tests__/provider-routes.test.ts create mode 100644 extensions/general/cloud-backup/lib/__tests__/dropbox-client.test.ts create mode 100644 extensions/general/cloud-backup/lib/__tests__/dropbox-provider.test.ts create mode 100644 extensions/general/cloud-backup/lib/__tests__/sync-providers.test.ts create mode 100644 extensions/general/cloud-backup/lib/cloud-provider.ts create mode 100644 extensions/general/cloud-backup/lib/dropbox-client.ts create mode 100644 extensions/general/cloud-backup/lib/dropbox-oauth.ts create mode 100644 extensions/general/cloud-backup/lib/dropbox-provider.ts create mode 100644 extensions/general/cloud-backup/lib/folder-names.ts create mode 100644 extensions/general/cloud-backup/lib/google-provider.ts create mode 100644 extensions/general/cloud-backup/lib/provider-registry.ts 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
          -
          -