fbd4b992f5
* 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>
160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
/**
|
|
* Failure-alert email for the cloud backup auto-sync.
|
|
*
|
|
* A backup that silently stops running is worse than no backup: the user
|
|
* believes they are covered. The cron calls this when a connection goes
|
|
* needs_reauth (retrying can never succeed) or when several consecutive
|
|
* auto-syncs have failed. Manual syncs never alert: the user is watching.
|
|
*
|
|
* Best-effort by design: an alert failure must never fail the sync loop.
|
|
* Throttling state (`last_alert_at`) lives on the schedule object and is
|
|
* persisted by the caller. The body carries no bookkeeping data, only the
|
|
* company name and the error summary.
|
|
*/
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { getEmailService } from '@/lib/email/service'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('cloud-backup-alert')
|
|
|
|
/** At most one alert email per company per throttle window. */
|
|
export const ALERT_THROTTLE_MS = 7 * 24 * 60 * 60 * 1000
|
|
/** Consecutive auto-sync failures before a repeated-failures alert fires. */
|
|
export const ALERT_FAILURE_THRESHOLD = 3
|
|
|
|
export type BackupAlertKind = 'needs_reauth' | 'repeated_failures'
|
|
|
|
export function shouldSendBackupAlert(params: {
|
|
kind: BackupAlertKind
|
|
consecutiveFailures: number
|
|
lastAlertAt: string | null | undefined
|
|
now?: Date
|
|
}): boolean {
|
|
const now = params.now ?? new Date()
|
|
if (params.lastAlertAt) {
|
|
const last = new Date(params.lastAlertAt).getTime()
|
|
if (Number.isFinite(last) && now.getTime() - last < ALERT_THROTTLE_MS) {
|
|
return false
|
|
}
|
|
}
|
|
if (params.kind === 'needs_reauth') return true
|
|
return params.consecutiveFailures >= ALERT_FAILURE_THRESHOLD
|
|
}
|
|
|
|
export interface BackupAlertInput {
|
|
companyId: string
|
|
/** The user who configured the schedule: recipient candidate. */
|
|
userId: string
|
|
kind: BackupAlertKind
|
|
consecutiveFailures: number
|
|
errorMessage: string | null
|
|
/** App origin used to build the reconnect link. */
|
|
origin: string
|
|
/**
|
|
* Destination that failed ("Google Drive", "Dropbox"). Named in the mail so
|
|
* a user backing up to both knows which one to reconnect. Defaults to Google
|
|
* Drive for callers written before the second provider existed.
|
|
*/
|
|
providerLabel?: string
|
|
}
|
|
|
|
export async function sendBackupFailureAlert(
|
|
supabase: SupabaseClient,
|
|
input: BackupAlertInput
|
|
): Promise<{ sent: boolean; reason?: string }> {
|
|
try {
|
|
const email = getEmailService()
|
|
if (!email.isConfigured()) return { sent: false, reason: 'email_not_configured' }
|
|
|
|
const recipient = await resolveMemberEmail(supabase, input.companyId, input.userId)
|
|
if (!recipient) {
|
|
log.info('no authorised recipient for backup alert', { companyId: input.companyId })
|
|
return { sent: false, reason: 'no_recipient' }
|
|
}
|
|
|
|
const companyName = await fetchCompanyName(supabase, input.companyId)
|
|
const link = `${input.origin}/import#cloud-backup`
|
|
const providerLabel = input.providerLabel || 'Google Drive'
|
|
|
|
let subject: string
|
|
let paragraphs: string[]
|
|
if (input.kind === 'needs_reauth') {
|
|
subject = `Säkerhetskopieringen till ${providerLabel} är pausad`
|
|
paragraphs = [
|
|
`Den automatiska säkerhetskopieringen för ${companyName} är pausad: åtkomsten till ditt ${providerLabel}-konto har gått ut eller återkallats.`,
|
|
`Koppla om ${providerLabel} för att återuppta säkerhetskopieringen.`,
|
|
]
|
|
} else {
|
|
subject = `Säkerhetskopieringen till ${providerLabel} misslyckas`
|
|
paragraphs = [
|
|
`Den automatiska säkerhetskopieringen för ${companyName} till ${providerLabel} har misslyckats ${input.consecutiveFailures} nätter i rad.`,
|
|
input.errorMessage ? `Senaste fel: ${input.errorMessage}` : '',
|
|
'Kontrollera anslutningen under Importera/Exportera.',
|
|
].filter(Boolean)
|
|
}
|
|
|
|
const text = [...paragraphs, '', link].join('\n\n')
|
|
const html = [
|
|
...paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`),
|
|
`<p><a href="${escapeHtml(link)}">Öppna säkerhetskopiering</a></p>`,
|
|
].join('')
|
|
|
|
const result = await email.sendEmail({ to: recipient, subject, text, html })
|
|
if (!result.success) {
|
|
log.warn('backup alert send failed', { companyId: input.companyId, error: result.error })
|
|
return { sent: false, reason: 'send_failed' }
|
|
}
|
|
return { sent: true }
|
|
} catch (err) {
|
|
log.warn('backup alert failed', {
|
|
companyId: input.companyId,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
})
|
|
return { sent: false, reason: 'error' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The recipient must still be an active member of the company: a schedule
|
|
* owner who has since been removed must not receive alerts for it.
|
|
*/
|
|
async function resolveMemberEmail(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string
|
|
): Promise<string | null> {
|
|
const { data: member } = await supabase
|
|
.from('company_members')
|
|
.select('user_id, profiles!inner(email)')
|
|
.eq('company_id', companyId)
|
|
.eq('user_id', userId)
|
|
.maybeSingle()
|
|
if (!member) return null
|
|
|
|
type ProfileRef = { email?: string | null } | { email?: string | null }[] | null
|
|
const profiles = (member as { profiles: ProfileRef }).profiles
|
|
const profile = Array.isArray(profiles) ? profiles[0] : profiles
|
|
return profile?.email ?? null
|
|
}
|
|
|
|
async function fetchCompanyName(
|
|
supabase: SupabaseClient,
|
|
companyId: string
|
|
): Promise<string> {
|
|
const { data } = await supabase
|
|
.from('company_settings')
|
|
.select('company_name')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
return (data?.company_name as string) || 'ditt företag'
|
|
}
|
|
|
|
function escapeHtml(input: string): string {
|
|
return input
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|