Files
accounted/extensions/general/cloud-backup/lib/backup-alert.ts
T
Jakob Wennberg f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

131 lines
4.9 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'
import { resolveMemberEmail } from '@/lib/notifications/member-email'
import { escapeHtml } from '@/lib/email/user-text'
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' }
}
}
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'
}