feat(notifications): opt-in daily "nytt att bokföra" email digest (#2078)
* feat(notifications): opt-in daily "nytt att bokföra" email digest Users asked for an email when new work arrives: bank transactions that synced overnight and documents that landed in the inbox. Adds a daily 05:45 UTC cron (after the 05:00 bank sync) that emails opted-in users a per-company summary with counts only, no amounts (data-minimization stance of the kvittens/skattekonto mails). - notification_settings.email_digest_enabled, NOT NULL DEFAULT false: strictly opt-in via a new toggle in the notification settings panel - notification_log type 'bookkeeping_digest' with the claim-then-send partial unique index pattern: one mail per user per company per day - counts unbooked transactions and unprocessed inbox items created in the last 24h; empty digests are never sent - brand-aware sender + link base via lib/email/brand-sender - docker crontabs regenerated from vercel.json Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 * fix(notifications): digest review findings in one pass Skeptic + bot findings on PR #2078, resolved together: - Count queries now match the canonical worklist anchors: ignored transactions excluded; inbox items already booked directly or matched to a transaction (created_journal_entry_id / matched_transaction_id) no longer counted (skeptic: spurious digests). - Memberships sweep and member-email lookups chunk .in() id lists at 150 ids to stay under proxy URL limits (HTTP 414 at ~350 opted-in users). - Recoverable claim lifecycle (CodeRabbit): claim inserts as 'pending', flips to 'sent' only after the provider accepted the mail; a stale pending claim is atomically taken over by a later run, so a worker death mid-send no longer swallows the day's digest. New migration 20260831110000 admits 'pending' to the delivery_status CHECK. - Company name sanitized against CRLF header injection before the mail subject (compliance swarm ASVS V1.2.5), with test. - RoPA entry for the new processing activity in .compliance/ropa.yaml (compliance swarm GDPR Art. 30). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 * fix(types): admit 'pending' to NotificationLog delivery_status union Matches migration 20260831110000; surfaced by fix re-verification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0122MznXxrLRyT96fGhfyzD4 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
024420d655
commit
6dfaa45061
@@ -815,3 +815,44 @@ processing_activities:
|
||||
- opt_out_stop_keyword_honored
|
||||
- muted_senders_no_content_persistence
|
||||
- account_deletion_revokes_and_shreds_whatsapp_link
|
||||
|
||||
- id: notifications.bookkeeping_digest
|
||||
name: Daglig e-postsammanfattning "nytt att bokföra"
|
||||
purpose: >-
|
||||
Skicka ett dagligt opt-in-mejl till företagsmedlemmar när nya
|
||||
banktransaktioner eller inkorgsunderlag har kommit in och väntar på
|
||||
bokföring. Mejlet innehåller endast antal per kategori och en länk till
|
||||
appen: aldrig belopp, motparter eller transaktionsinnehåll.
|
||||
lawful_basis: art_6_1_a
|
||||
special_category_basis: null
|
||||
controller: gnubok-tenant
|
||||
processor: supabase_and_resend
|
||||
data_subjects:
|
||||
- company_member
|
||||
data_categories:
|
||||
- user.contact.email
|
||||
- user.financial_activity_counts
|
||||
recipients:
|
||||
- name: Supabase
|
||||
country: EU
|
||||
role: processor
|
||||
- name: Resend
|
||||
country: US
|
||||
role: processor
|
||||
international_transfers:
|
||||
applicable: true
|
||||
mechanism: scc_2021_c2p
|
||||
note: Resend processes the outbound delivery under SCC Module 2.
|
||||
retention:
|
||||
duration: notification_log_row_retained_with_table
|
||||
basis: gdpr_storage_limitation
|
||||
stored_in:
|
||||
- notification_log
|
||||
- notification_settings
|
||||
security_measures:
|
||||
- opt_in_default_false_user_toggle_only
|
||||
- counts_only_no_amounts_or_counterparties_in_body
|
||||
- recipient_restricted_to_active_company_members
|
||||
- claim_based_once_per_day_dedup
|
||||
- company_name_header_injection_sanitized
|
||||
- cron_secret_authenticated_trigger
|
||||
|
||||
@@ -1384,3 +1384,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-31] Login/register methods come from GoTrue (/auth/v1/settings + admin customProviders) instead of app-side flags; NEXT_PUBLIC_GOOGLE_AUTH_ENABLED removed (PR #1869): the Supabase dashboard becomes the single switch, an allowlist of auth-js provider ids filters non-login entries like anonymous_users, and hosted rendering is unchanged because Google is enabled in prod GoTrue. The Vercel env var stays set for old-build rollback safety; delete it after a few deploys.
|
||||
[2026-08-31] Single prominent amount is PROMOTED into editable totals.total (totalSource='prominent') instead of living in a read-only Belopp row: Emil's call, an uncorrectable load-bearing value violated the prefill-override-editors rule. Provenance keeps matching fallback-grade (discount, date guard, hunt exclusion); a user edit of TOTALT clears the stamp. Multi-amount docs keep the Belopp row: promoting one of several figures would invent a total.
|
||||
[2026-08-31] Image-scan red fixed by bumping the node:22-alpine digest (alpine 3.23 to 3.24.1), not by widening the gate: the Dockerfile's apk-upgrade layer is frozen by the GHCR buildx layer cache, so a fix published after the last cache-busting change (libssl3 3.5.8-r0 for CVE-2026-14456) never reaches the published image until the FROM digest moves; the red scheduled scan is the designed alarm for exactly this bump. cron.Dockerfile gained the same apk upgrade (it had none).
|
||||
[2026-08-31] Bookkeeping digest email is per-user per-COMPANY per-day (not one aggregated mail across companies): notification_log.company_id anchors the claim, subject lines stay unambiguous, and most users have one company; consultants can opt in and get one short mail per client. Window is a fixed last-24h (cron cadence) rather than tracking last-sent state. Settings toggle stays hardcoded Swedish like the rest of the push-notifications extension UI (no next-intl wiring in extension components); revisit if that surface is ever translated.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The cron shell around the digest: authorization and summary aggregation.
|
||||
* The digest logic itself is covered by
|
||||
* lib/notifications/__tests__/bookkeeping-digest.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
const mockRunDigest = vi.fn()
|
||||
vi.mock('@/lib/notifications/bookkeeping-digest', () => ({
|
||||
runBookkeepingDigest: (...args: unknown[]) => mockRunDigest(...args),
|
||||
}))
|
||||
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { GET } from '../route'
|
||||
|
||||
function request(): Request {
|
||||
return new Request('https://app.testbrand.example/api/notifications/bookkeeping-digest/cron')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(verifyCronSecret).mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('GET /api/notifications/bookkeeping-digest/cron', () => {
|
||||
it('rejects an unauthorized caller without touching the database', async () => {
|
||||
vi.mocked(verifyCronSecret).mockReturnValueOnce(
|
||||
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
)
|
||||
|
||||
const response = await GET(request())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(vi.mocked(createServiceClient)).not.toHaveBeenCalled()
|
||||
expect(mockRunDigest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the digest and returns its summary', async () => {
|
||||
mockRunDigest.mockResolvedValueOnce({
|
||||
optedInUsers: 2,
|
||||
companiesConsidered: 1,
|
||||
sent: 2,
|
||||
skippedEmpty: 0,
|
||||
skippedDuplicate: 0,
|
||||
failed: 0,
|
||||
})
|
||||
|
||||
const response = await GET(request())
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body).toMatchObject({ success: true, sent: 2, optedInUsers: 2 })
|
||||
expect(mockRunDigest).toHaveBeenCalledTimes(1)
|
||||
expect(mockRunDigest.mock.calls[0][1]).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('maps a thrown digest failure to the canonical error envelope', async () => {
|
||||
mockRunDigest.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const response = await GET(request())
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(500)
|
||||
const body = await response.json()
|
||||
expect(body.error).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { runBookkeepingDigest } from '@/lib/notifications/bookkeeping-digest'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/notifications/bookkeeping-digest/cron, daily 05:45 UTC.
|
||||
*
|
||||
* Emails opted-in users a "nytt att bokföra" summary: bank transactions and
|
||||
* inbox documents that arrived in the last 24 hours. Runs after the 05:00
|
||||
* bank sync so the night's imports are in the counts. Strictly opt-in via
|
||||
* notification_settings.email_digest_enabled (default false); with nobody
|
||||
* opted in the run is a single cheap query.
|
||||
*/
|
||||
export const GET = withCronContext('cron.bookkeeping_digest', async (_request, ctx) => {
|
||||
const supabase = createServiceClient()
|
||||
const summary = await runBookkeepingDigest(supabase, new Date())
|
||||
|
||||
ctx.log.info('bookkeeping digest summary', { ...summary })
|
||||
|
||||
return NextResponse.json({ success: true, ...summary })
|
||||
})
|
||||
|
||||
export const POST = GET
|
||||
|
||||
/** Sequential per-company counting plus a handful of emails. */
|
||||
export const maxDuration = 300
|
||||
@@ -47,5 +47,6 @@
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/underlag-reconcile/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/bookkeeping-digest/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
|
||||
|
||||
@@ -47,5 +47,6 @@
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/extensions/invoice-inbox/underlag-reconcile/cron
|
||||
15 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/bookkeeping/accruals/post-due/cron
|
||||
30 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/receipt-hunt/cron
|
||||
45 5 * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/notifications/bookkeeping-digest/cron
|
||||
*/10 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/inbound/cron
|
||||
5,20,35,50 * * * * curl -sf -H "Authorization: Bearer ${CRON_SECRET}" ${APP_URL}/api/peppol/outbound/status/cron
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
|
||||
import { Bell, BellOff, Loader2, Mail, Moon } from 'lucide-react'
|
||||
import type { NotificationSettings as NotificationSettingsType } from '@/types'
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
@@ -298,6 +298,37 @@ export function NotificationSettings({ onSettingsChange }: NotificationSettingsP
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email digest */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
E-post
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Sammanfattningar via e-post
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="email-digest">Nytt att bokföra</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dagligt mejl när nya banktransaktioner eller underlag har kommit in
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="email-digest"
|
||||
checked={settings.email_digest_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('email_digest_enabled', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quiet hours */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* The "nytt att bokföra" digest: opt-in sweep, empty-skip, the recoverable
|
||||
* claim lifecycle (pending -> send -> sent, stale takeover), and the
|
||||
* counts-only email body.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const mockIsConfigured = vi.fn()
|
||||
const mockSendEmail = vi.fn()
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({ isConfigured: mockIsConfigured, sendEmail: mockSendEmail }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/branding/resolve', () => ({
|
||||
resolveBrandForCompany: vi.fn(async () => null),
|
||||
}))
|
||||
vi.mock('@/lib/branding/service', () => ({
|
||||
getBranding: () => ({ appUrl: 'https://app.testbrand.example' }),
|
||||
}))
|
||||
|
||||
import { runBookkeepingDigest, buildDigestEmail } from '../bookkeeping-digest'
|
||||
|
||||
interface TableFixture {
|
||||
optedIn?: Array<{ user_id: string }>
|
||||
members?: Array<{ user_id: string; company_id: string }>
|
||||
txCount?: number
|
||||
inboxCount?: number
|
||||
companyName?: string
|
||||
claimError?: { code?: string; message: string } | null
|
||||
/** Row returned when acquireClaim inspects an existing claim after 23505. */
|
||||
existingClaim?: { id: string; delivery_status: string; sent_at: string | null } | null
|
||||
/** Whether the stale-takeover lease UPDATE wins (returns the row). */
|
||||
leaseWon?: boolean
|
||||
}
|
||||
|
||||
interface RecordedWrite {
|
||||
table: string
|
||||
op: 'insert' | 'update' | 'delete'
|
||||
payload?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled mock: the assertions need recorded write payloads and order
|
||||
* (claim insert before send, sent-update after), which
|
||||
* createQueuedMockSupabase cannot key by table.
|
||||
*/
|
||||
function makeSupabase(fx: TableFixture) {
|
||||
const writes: RecordedWrite[] = []
|
||||
const countBuilder = (count: number) => {
|
||||
const b: Record<string, unknown> = {}
|
||||
Object.assign(b, {
|
||||
eq: () => b,
|
||||
gte: () => b,
|
||||
not: () => b,
|
||||
is: () => b,
|
||||
in: () => b,
|
||||
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null, count }),
|
||||
})
|
||||
return b
|
||||
}
|
||||
const from = (table: string) => {
|
||||
const builder: Record<string, unknown> = {}
|
||||
const finish = () => {
|
||||
if (table === 'notification_settings') return { data: fx.optedIn ?? [], error: null }
|
||||
if (table === 'company_members') return { data: fx.members ?? [], error: null }
|
||||
if (table === 'profiles') {
|
||||
const ids = new Set((fx.members ?? []).map((m) => m.user_id))
|
||||
return {
|
||||
data: [...ids].map((id) => ({ id, email: `${id}@testbrand.example` })),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
return { data: [], error: null }
|
||||
}
|
||||
Object.assign(builder, {
|
||||
select: (_cols?: string, opts?: { head?: boolean }) => {
|
||||
if (table === 'transactions' && opts?.head) return countBuilder(fx.txCount ?? 0)
|
||||
if (table === 'invoice_inbox_items' && opts?.head) return countBuilder(fx.inboxCount ?? 0)
|
||||
return builder
|
||||
},
|
||||
insert: (payload: Record<string, unknown>) => {
|
||||
writes.push({ table, op: 'insert', payload })
|
||||
return Promise.resolve({ data: null, error: fx.claimError ?? null })
|
||||
},
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
writes.push({ table, op: 'update', payload })
|
||||
const ub: Record<string, unknown> = {}
|
||||
Object.assign(ub, {
|
||||
eq: () => ub,
|
||||
lte: () => ub,
|
||||
// The lease-renewal UPDATE ends in .select('id'): winning returns
|
||||
// the row, losing returns [].
|
||||
select: () =>
|
||||
Promise.resolve({ data: fx.leaseWon ? [{ id: 'claim-1' }] : [], error: null }),
|
||||
then: (resolve: (v: unknown) => void) => resolve({ data: null, error: null }),
|
||||
})
|
||||
return ub
|
||||
},
|
||||
delete: () => {
|
||||
writes.push({ table, op: 'delete' })
|
||||
return builder
|
||||
},
|
||||
eq: () => builder,
|
||||
in: () => builder,
|
||||
gte: () => builder,
|
||||
not: () => builder,
|
||||
is: () => builder,
|
||||
order: () => builder,
|
||||
range: () => builder,
|
||||
maybeSingle: async () => {
|
||||
if (table === 'companies') {
|
||||
return { data: { name: fx.companyName ?? 'Testbolaget AB' }, error: null }
|
||||
}
|
||||
if (table === 'notification_log') {
|
||||
return { data: fx.existingClaim ?? null, error: null }
|
||||
}
|
||||
return { data: null, error: null }
|
||||
},
|
||||
then: (resolve: (v: unknown) => void) => resolve(finish()),
|
||||
})
|
||||
return builder
|
||||
}
|
||||
return { supabase: { from } as unknown as SupabaseClient, writes }
|
||||
}
|
||||
|
||||
const NOW = new Date('2026-08-31T05:45:00Z')
|
||||
|
||||
const oneUserOneCompany = {
|
||||
optedIn: [{ user_id: 'u1' }],
|
||||
members: [{ user_id: 'u1', company_id: 'c1' }],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsConfigured.mockReturnValue(true)
|
||||
mockSendEmail.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
describe('runBookkeepingDigest', () => {
|
||||
it('does nothing when email is not configured', async () => {
|
||||
mockIsConfigured.mockReturnValue(false)
|
||||
const { supabase } = makeSupabase({ optedIn: [{ user_id: 'u1' }] })
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.sent).toBe(0)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when nobody opted in', async () => {
|
||||
const { supabase, writes } = makeSupabase({ optedIn: [] })
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary).toMatchObject({ optedInUsers: 0, sent: 0 })
|
||||
expect(writes).toHaveLength(0)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips companies with nothing new instead of sending empty mail', async () => {
|
||||
const { supabase } = makeSupabase({ ...oneUserOneCompany, txCount: 0, inboxCount: 0 })
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.skippedEmpty).toBe(1)
|
||||
expect(summary.sent).toBe(0)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims as pending BEFORE sending, marks sent only after the provider accepted', async () => {
|
||||
const { supabase, writes } = makeSupabase({ ...oneUserOneCompany, txCount: 3, inboxCount: 1 })
|
||||
let claimsAtSendTime = -1
|
||||
mockSendEmail.mockImplementation(async () => {
|
||||
claimsAtSendTime = writes.filter((w) => w.op === 'insert').length
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.sent).toBe(1)
|
||||
expect(claimsAtSendTime).toBe(1)
|
||||
const insert = writes.find((w) => w.op === 'insert')
|
||||
expect(insert?.table).toBe('notification_log')
|
||||
expect(insert?.payload).toMatchObject({
|
||||
user_id: 'u1',
|
||||
company_id: 'c1',
|
||||
notification_type: 'bookkeeping_digest',
|
||||
delivery_status: 'pending',
|
||||
})
|
||||
// Flipped to 'sent' only after sendEmail resolved successfully.
|
||||
const sentUpdate = writes.find(
|
||||
(w) => w.op === 'update' && w.payload?.delivery_status === 'sent',
|
||||
)
|
||||
expect(sentUpdate).toBeDefined()
|
||||
const mail = mockSendEmail.mock.calls[0][0]
|
||||
expect(mail.to).toBe('u1@testbrand.example')
|
||||
expect(mail.subject).toContain('Nytt att bokföra')
|
||||
expect(mail.text).toContain('3 nya banktransaktioner')
|
||||
expect(mail.text).toContain('1 nytt underlag')
|
||||
// Data minimization: counts only, never amounts.
|
||||
expect(mail.text).not.toMatch(/\d+[.,]\d{2}\s*(kr|SEK)/i)
|
||||
})
|
||||
|
||||
it('a 23505 with an already-sent claim means another run delivered today: no email', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
...oneUserOneCompany,
|
||||
txCount: 2,
|
||||
claimError: { code: '23505', message: 'duplicate key' },
|
||||
existingClaim: { id: 'claim-1', delivery_status: 'sent', sent_at: NOW.toISOString() },
|
||||
})
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.skippedDuplicate).toBe(1)
|
||||
expect(summary.sent).toBe(0)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a fresh pending claim belongs to a run in flight: no takeover, no email', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
...oneUserOneCompany,
|
||||
txCount: 2,
|
||||
claimError: { code: '23505', message: 'duplicate key' },
|
||||
existingClaim: {
|
||||
id: 'claim-1',
|
||||
delivery_status: 'pending',
|
||||
sent_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.skippedDuplicate).toBe(1)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('takes over a STALE pending claim (a run that died before sending) and sends', async () => {
|
||||
const { supabase, writes } = makeSupabase({
|
||||
...oneUserOneCompany,
|
||||
txCount: 2,
|
||||
claimError: { code: '23505', message: 'duplicate key' },
|
||||
existingClaim: {
|
||||
id: 'claim-1',
|
||||
delivery_status: 'pending',
|
||||
sent_at: new Date(Date.now() - 60 * 60_000).toISOString(),
|
||||
},
|
||||
leaseWon: true,
|
||||
})
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.sent).toBe(1)
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(1)
|
||||
// Lease renewal update happened before the send-completion update.
|
||||
expect(writes.filter((w) => w.op === 'update').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('loses the stale-takeover race cleanly: no email', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
...oneUserOneCompany,
|
||||
txCount: 2,
|
||||
claimError: { code: '23505', message: 'duplicate key' },
|
||||
existingClaim: {
|
||||
id: 'claim-1',
|
||||
delivery_status: 'pending',
|
||||
sent_at: new Date(Date.now() - 60 * 60_000).toISOString(),
|
||||
},
|
||||
leaseWon: false,
|
||||
})
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.skippedDuplicate).toBe(1)
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the claim pending when the send fails, so a later run can retry', async () => {
|
||||
mockSendEmail.mockResolvedValue({ success: false, error: 'smtp down' })
|
||||
const { supabase, writes } = makeSupabase({ ...oneUserOneCompany, txCount: 2 })
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.failed).toBe(1)
|
||||
expect(summary.sent).toBe(0)
|
||||
// The claim is neither deleted nor marked sent: it stays 'pending' and
|
||||
// becomes takeover-eligible once stale.
|
||||
expect(writes.some((w) => w.op === 'delete')).toBe(false)
|
||||
expect(
|
||||
writes.some((w) => w.op === 'update' && w.payload?.delivery_status === 'sent'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('sends to every opted-in member of the same company', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
optedIn: [{ user_id: 'u1' }, { user_id: 'u2' }],
|
||||
members: [
|
||||
{ user_id: 'u1', company_id: 'c1' },
|
||||
{ user_id: 'u2', company_id: 'c1' },
|
||||
],
|
||||
txCount: 5,
|
||||
})
|
||||
|
||||
const summary = await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
expect(summary.sent).toBe(2)
|
||||
const recipients = mockSendEmail.mock.calls.map((c) => c[0].to).sort()
|
||||
expect(recipients).toEqual(['u1@testbrand.example', 'u2@testbrand.example'])
|
||||
})
|
||||
|
||||
it('strips CRLF from the company name so it cannot inject mail headers', async () => {
|
||||
const { supabase } = makeSupabase({
|
||||
...oneUserOneCompany,
|
||||
txCount: 1,
|
||||
companyName: 'Evil AB\r\nBcc: attacker@testbrand.example',
|
||||
})
|
||||
|
||||
await runBookkeepingDigest(supabase, NOW)
|
||||
|
||||
const mail = mockSendEmail.mock.calls[0][0]
|
||||
expect(mail.subject).not.toMatch(/[\r\n]/)
|
||||
expect(mail.subject).toBe('Nytt att bokföra i Evil AB Bcc: attacker@testbrand.example')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDigestEmail', () => {
|
||||
it('renders singular and plural Swedish correctly', () => {
|
||||
const one = buildDigestEmail({
|
||||
companyName: 'Testbolaget AB',
|
||||
counts: { newTransactions: 1, newInboxItems: 1 },
|
||||
baseUrl: 'https://app.testbrand.example',
|
||||
appName: 'Accounted',
|
||||
})
|
||||
expect(one.text).toContain('1 ny banktransaktion att bokföra')
|
||||
expect(one.text).toContain('1 nytt underlag i inkorgen')
|
||||
expect(one.subject).toBe('Nytt att bokföra i Testbolaget AB')
|
||||
|
||||
const many = buildDigestEmail({
|
||||
companyName: null,
|
||||
counts: { newTransactions: 4, newInboxItems: 2 },
|
||||
baseUrl: 'https://app.testbrand.example',
|
||||
appName: 'Accounted',
|
||||
})
|
||||
expect(many.subject).toBe('Nytt att bokföra')
|
||||
expect(many.text).toContain('4 nya banktransaktioner att bokföra')
|
||||
expect(many.text).toContain('2 nya underlag i inkorgen')
|
||||
})
|
||||
|
||||
it('omits the zero category', () => {
|
||||
const mail = buildDigestEmail({
|
||||
companyName: null,
|
||||
counts: { newTransactions: 2, newInboxItems: 0 },
|
||||
baseUrl: 'https://app.testbrand.example',
|
||||
appName: 'Accounted',
|
||||
})
|
||||
expect(mail.text).not.toContain('underlag i inkorgen')
|
||||
})
|
||||
|
||||
it('escapes html in the company name', () => {
|
||||
const mail = buildDigestEmail({
|
||||
companyName: '<b>Evil & Co</b>',
|
||||
counts: { newTransactions: 1, newInboxItems: 0 },
|
||||
baseUrl: 'https://app.testbrand.example',
|
||||
appName: 'Accounted',
|
||||
})
|
||||
expect(mail.html).not.toContain('<b>Evil')
|
||||
expect(mail.html).toContain('<b>Evil & Co</b>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Daily "nytt att bokföra" email digest.
|
||||
*
|
||||
* Users asked for an email when there is new work to book: bank transactions
|
||||
* that synced overnight and documents that landed in the inbox. The digest is
|
||||
* strictly opt-in (notification_settings.email_digest_enabled, default false)
|
||||
* and runs daily after the 05:00 bank sync so the night's imports are counted
|
||||
* the same morning.
|
||||
*
|
||||
* One email per user per company per day: dedup goes through notification_log
|
||||
* under type 'bookkeeping_digest', guarded by a partial unique index on
|
||||
* (user_id, reference_id) (migration 20260831100000). reference_id is a
|
||||
* deterministic uuid derived from (company, digest date), so overlapping cron
|
||||
* invocations race on the insert and exactly one wins. The claim is
|
||||
* recoverable: it is inserted as delivery_status 'pending', flipped to 'sent'
|
||||
* only after the provider accepted the mail, and a 'pending' claim older than
|
||||
* STALE_CLAIM_MS (a run that died before sending, or a send that failed) can
|
||||
* be atomically taken over by a later run, so an interruption never silently
|
||||
* swallows that day's digest.
|
||||
*
|
||||
* The body carries counts only, never amounts or counterparties: the mere
|
||||
* existence of company mail is sensitive financial signal, so the details
|
||||
* live behind login (same data-minimization stance as the skattekonto drift
|
||||
* and kvittens emails).
|
||||
*
|
||||
* Best-effort by design: a digest failure must never fail the cron run for
|
||||
* other users or companies.
|
||||
*/
|
||||
import { createHash } from 'crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { getSenderForBrand, getBaseUrlForBrand } from '@/lib/email/brand-sender'
|
||||
import { resolveBrandForCompany } from '@/lib/branding/resolve'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
const log = createLogger('bookkeeping-digest')
|
||||
|
||||
/** The digest counts changes in the last 24h: one cron cadence back. */
|
||||
const WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* A 'pending' claim older than this is considered abandoned (the run that
|
||||
* inserted it died before sending) and may be taken over by a later run.
|
||||
* The cron is daily, so anything measured in minutes is safely stale.
|
||||
*/
|
||||
const STALE_CLAIM_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Max ids per PostgREST .in() filter: ids travel in the GET query string,
|
||||
* and an unchunked list 414s past proxy URL limits (same limit and
|
||||
* rationale as lib/worklist/categories.ts).
|
||||
*/
|
||||
const IN_CLAUSE_CHUNK = 150
|
||||
|
||||
export interface DigestCounts {
|
||||
newTransactions: number
|
||||
newInboxItems: number
|
||||
}
|
||||
|
||||
export interface DigestRunSummary {
|
||||
optedInUsers: number
|
||||
companiesConsidered: number
|
||||
sent: number
|
||||
skippedEmpty: number
|
||||
skippedDuplicate: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Count what arrived in the window and is still unhandled, using the same
|
||||
* anchors as the worklist counts (lib/worklist/categories.ts). Transactions:
|
||||
* created in the window, not yet booked (journal_entry_id anchor only; rows
|
||||
* junction-linked via samlingsverifikat are a rounding error a few hours
|
||||
* after import), not marked private, and not ignored. Inbox items: created
|
||||
* in the window and not yet terminal on any of the three markers
|
||||
* (supplier invoice created, booked directly, or matched to a transaction;
|
||||
* status stays 'received' in all three cases).
|
||||
*/
|
||||
export async function countNewToBook(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
sinceIso: string
|
||||
): Promise<DigestCounts> {
|
||||
const [txHead, inboxHead] = await Promise.all([
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.gte('created_at', sinceIso)
|
||||
.is('journal_entry_id', null)
|
||||
.not('is_business', 'is', false)
|
||||
.eq('is_ignored', false),
|
||||
supabase
|
||||
.from('invoice_inbox_items')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.gte('created_at', sinceIso)
|
||||
.in('status', ['received', 'processing'])
|
||||
.is('created_supplier_invoice_id', null)
|
||||
.is('created_journal_entry_id', null)
|
||||
.is('matched_transaction_id', null),
|
||||
])
|
||||
if (txHead.error) throw new Error(`transactions count failed: ${txHead.error.message}`)
|
||||
if (inboxHead.error) throw new Error(`inbox count failed: ${inboxHead.error.message}`)
|
||||
return {
|
||||
newTransactions: txHead.count ?? 0,
|
||||
newInboxItems: inboxHead.count ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the digest for every opted-in user. Requires a SERVICE-ROLE client:
|
||||
* both the settings sweep and the profile email lookups cross user
|
||||
* boundaries that RLS would silently empty out.
|
||||
*/
|
||||
export async function runBookkeepingDigest(
|
||||
supabase: SupabaseClient,
|
||||
now: Date
|
||||
): Promise<DigestRunSummary> {
|
||||
const summary: DigestRunSummary = {
|
||||
optedInUsers: 0,
|
||||
companiesConsidered: 0,
|
||||
sent: 0,
|
||||
skippedEmpty: 0,
|
||||
skippedDuplicate: 0,
|
||||
failed: 0,
|
||||
}
|
||||
|
||||
const email = getEmailService()
|
||||
if (!email.isConfigured()) {
|
||||
log.info('bookkeeping digest skipped: email service not configured')
|
||||
return summary
|
||||
}
|
||||
|
||||
const optedIn = await fetchAllRows<{ user_id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('notification_settings')
|
||||
.select('user_id')
|
||||
.eq('email_digest_enabled', true)
|
||||
.order('user_id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
summary.optedInUsers = optedIn.length
|
||||
if (optedIn.length === 0) return summary
|
||||
|
||||
const userIds = optedIn.map((r) => r.user_id)
|
||||
const memberships: Array<{ user_id: string; company_id: string }> = []
|
||||
for (const idChunk of chunk(userIds, IN_CLAUSE_CHUNK)) {
|
||||
const page = await fetchAllRows<{ user_id: string; company_id: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select('user_id, company_id')
|
||||
.in('user_id', idChunk)
|
||||
// (company_id, user_id) is unique per membership: stable total order
|
||||
// for range paging.
|
||||
.order('company_id', { ascending: true })
|
||||
.order('user_id', { ascending: true })
|
||||
.range(from, to)
|
||||
)
|
||||
memberships.push(...page)
|
||||
}
|
||||
|
||||
const usersByCompany = new Map<string, string[]>()
|
||||
for (const m of memberships) {
|
||||
if (!m.user_id || !m.company_id) continue
|
||||
const list = usersByCompany.get(m.company_id) ?? []
|
||||
list.push(m.user_id)
|
||||
usersByCompany.set(m.company_id, list)
|
||||
}
|
||||
summary.companiesConsidered = usersByCompany.size
|
||||
|
||||
const sinceIso = new Date(now.getTime() - WINDOW_MS).toISOString()
|
||||
const digestDate = now.toISOString().slice(0, 10)
|
||||
|
||||
for (const [companyId, companyUserIds] of usersByCompany) {
|
||||
try {
|
||||
const counts = await countNewToBook(supabase, companyId, sinceIso)
|
||||
if (counts.newTransactions === 0 && counts.newInboxItems === 0) {
|
||||
summary.skippedEmpty += companyUserIds.length
|
||||
continue
|
||||
}
|
||||
const result = await sendDigestForCompany(supabase, {
|
||||
companyId,
|
||||
userIds: companyUserIds,
|
||||
counts,
|
||||
digestDate,
|
||||
})
|
||||
summary.sent += result.sent
|
||||
summary.skippedDuplicate += result.skippedDuplicate
|
||||
summary.failed += result.failed
|
||||
} catch (err) {
|
||||
summary.failed += companyUserIds.length
|
||||
log.warn('bookkeeping digest failed for company', {
|
||||
companyId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
interface CompanyDigestInput {
|
||||
companyId: string
|
||||
userIds: string[]
|
||||
counts: DigestCounts
|
||||
digestDate: string
|
||||
}
|
||||
|
||||
async function sendDigestForCompany(
|
||||
supabase: SupabaseClient,
|
||||
input: CompanyDigestInput
|
||||
): Promise<{ sent: number; skippedDuplicate: number; failed: number }> {
|
||||
const out = { sent: 0, skippedDuplicate: 0, failed: 0 }
|
||||
const email = getEmailService()
|
||||
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', input.companyId)
|
||||
.maybeSingle()
|
||||
const rawCompanyName = (company as { name?: string } | null)?.name ?? null
|
||||
// The name reaches the Subject header: sanitize against header injection.
|
||||
const companyName = rawCompanyName ? sanitizeHeaderText(rawCompanyName) || null : null
|
||||
|
||||
// One brand resolution per company; sender identity and link base follow
|
||||
// the company's brand (white-label rule: mail goes out in the brand of the
|
||||
// company it concerns).
|
||||
const brand = await resolveBrandForCompany(input.companyId)
|
||||
const sender = getSenderForBrand(brand)
|
||||
const baseUrl = getBaseUrlForBrand(brand)
|
||||
|
||||
// The recipient allowlist is the set of active members: an opted-in user
|
||||
// removed from the company must not keep receiving its digest.
|
||||
const memberEmails = await resolveCompanyMemberEmails(supabase, input.companyId, input.userIds)
|
||||
|
||||
const referenceUuid = toReferenceUuid(
|
||||
`bookkeeping_digest:${input.companyId}:${input.digestDate}`
|
||||
)
|
||||
|
||||
for (const userId of input.userIds) {
|
||||
const recipient = memberEmails.get(userId)
|
||||
if (!recipient) continue
|
||||
|
||||
const claim = await acquireClaim(supabase, userId, input.companyId, referenceUuid)
|
||||
if (claim === 'duplicate') {
|
||||
out.skippedDuplicate++
|
||||
continue
|
||||
}
|
||||
if (claim === 'error') {
|
||||
// Without a claim we cannot guarantee once-per-day: fail closed.
|
||||
out.failed++
|
||||
continue
|
||||
}
|
||||
|
||||
const message = buildDigestEmail({
|
||||
companyName,
|
||||
counts: input.counts,
|
||||
baseUrl,
|
||||
appName: brand?.appName ?? 'Accounted',
|
||||
})
|
||||
|
||||
let sendResult: Awaited<ReturnType<typeof email.sendEmail>>
|
||||
try {
|
||||
sendResult = await email.sendEmail({
|
||||
to: recipient,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
...(sender.fromName ? { fromName: sender.fromName } : {}),
|
||||
...(sender.fromAddress ? { fromAddress: sender.fromAddress } : {}),
|
||||
...(sender.replyTo ? { replyTo: sender.replyTo } : {}),
|
||||
})
|
||||
} catch (err) {
|
||||
// The claim stays 'pending': a later run takes it over once stale.
|
||||
out.failed++
|
||||
log.warn('digest email send threw', {
|
||||
companyId: input.companyId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!sendResult.success) {
|
||||
out.failed++
|
||||
log.warn('digest email send failed', {
|
||||
companyId: input.companyId,
|
||||
error: sendResult.error,
|
||||
})
|
||||
continue
|
||||
}
|
||||
await markClaimSent(supabase, userId, referenceUuid)
|
||||
out.sent++
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
type ClaimOutcome = 'acquired' | 'duplicate' | 'error'
|
||||
|
||||
/**
|
||||
* Acquire the once-per-day send claim. The insert (delivery_status
|
||||
* 'pending') is made atomic by the partial unique index on
|
||||
* (user_id, reference_id) for this notification_type; the loser of two
|
||||
* overlapping runs gets 23505. A 'pending' claim whose sent_at lease is
|
||||
* older than STALE_CLAIM_MS belonged to a run that died before sending (or
|
||||
* whose send failed): it is taken over by atomically renewing the lease,
|
||||
* conditioned on the row still being the same stale 'pending', so exactly
|
||||
* one contender wins.
|
||||
*/
|
||||
async function acquireClaim(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
referenceUuid: string
|
||||
): Promise<ClaimOutcome> {
|
||||
const { error: claimError } = await supabase.from('notification_log').insert({
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
notification_type: 'bookkeeping_digest',
|
||||
reference_id: referenceUuid,
|
||||
days_before: 0,
|
||||
delivery_status: 'pending',
|
||||
})
|
||||
if (!claimError) return 'acquired'
|
||||
if (claimError.code !== '23505') {
|
||||
log.warn('digest claim insert failed', { companyId, error: claimError.message })
|
||||
return 'error'
|
||||
}
|
||||
|
||||
const { data: existing, error: readError } = await supabase
|
||||
.from('notification_log')
|
||||
.select('id, delivery_status, sent_at')
|
||||
.eq('user_id', userId)
|
||||
.eq('notification_type', 'bookkeeping_digest')
|
||||
.eq('reference_id', referenceUuid)
|
||||
.maybeSingle()
|
||||
if (readError || !existing) return 'duplicate'
|
||||
const row = existing as { id: string; delivery_status: string; sent_at: string | null }
|
||||
if (row.delivery_status !== 'pending') return 'duplicate'
|
||||
const staleCutoffIso = new Date(Date.now() - STALE_CLAIM_MS).toISOString()
|
||||
if (row.sent_at && row.sent_at > staleCutoffIso) return 'duplicate'
|
||||
|
||||
const { data: taken, error: takeError } = await supabase
|
||||
.from('notification_log')
|
||||
.update({ sent_at: new Date().toISOString() })
|
||||
.eq('id', row.id)
|
||||
.eq('delivery_status', 'pending')
|
||||
.lte('sent_at', staleCutoffIso)
|
||||
.select('id')
|
||||
if (takeError || !taken || taken.length === 0) return 'duplicate'
|
||||
return 'acquired'
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the claim to 'sent' after the provider accepted the mail. Best-effort:
|
||||
* if this update fails the claim stays 'pending' and a stale takeover could
|
||||
* resend, which is the accepted trade against silently losing the digest.
|
||||
*/
|
||||
async function markClaimSent(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
referenceUuid: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('notification_log')
|
||||
.update({ delivery_status: 'sent', sent_at: new Date().toISOString() })
|
||||
.eq('user_id', userId)
|
||||
.eq('notification_type', 'bookkeeping_digest')
|
||||
.eq('reference_id', referenceUuid)
|
||||
if (error) {
|
||||
log.warn('could not mark digest claim sent', { userId, referenceUuid, error: error.message })
|
||||
}
|
||||
}
|
||||
|
||||
interface DigestEmailContent {
|
||||
subject: string
|
||||
text: string
|
||||
html: string
|
||||
}
|
||||
|
||||
/** Exported for tests: counts only, no amounts or counterparties. */
|
||||
export function buildDigestEmail(input: {
|
||||
companyName: string | null
|
||||
counts: DigestCounts
|
||||
baseUrl: string
|
||||
appName: string
|
||||
}): DigestEmailContent {
|
||||
const inCompany = input.companyName ? ` i ${input.companyName}` : ''
|
||||
const subject = `Nytt att bokföra${inCompany}`
|
||||
|
||||
const lines: string[] = []
|
||||
if (input.counts.newTransactions > 0) {
|
||||
lines.push(
|
||||
input.counts.newTransactions === 1
|
||||
? '1 ny banktransaktion att bokföra'
|
||||
: `${input.counts.newTransactions} nya banktransaktioner att bokföra`
|
||||
)
|
||||
}
|
||||
if (input.counts.newInboxItems > 0) {
|
||||
lines.push(
|
||||
input.counts.newInboxItems === 1
|
||||
? '1 nytt underlag i inkorgen'
|
||||
: `${input.counts.newInboxItems} nya underlag i inkorgen`
|
||||
)
|
||||
}
|
||||
|
||||
const intro = `Sedan igår har det kommit in nytt${inCompany ? ` till ${input.companyName}` : ''}:`
|
||||
const text = [
|
||||
intro,
|
||||
'',
|
||||
...lines.map((l) => `- ${l}`),
|
||||
'',
|
||||
`Logga in för att bokföra: ${input.baseUrl}`,
|
||||
'',
|
||||
`Du får det här mejlet för att du har slagit på daglig sammanfattning i ${input.appName}. Stäng av under Inställningar > Aviseringar.`,
|
||||
].join('\n')
|
||||
|
||||
const html = [
|
||||
`<p>${escapeHtml(intro)}</p>`,
|
||||
`<ul>${lines.map((l) => `<li>${escapeHtml(l)}</li>`).join('')}</ul>`,
|
||||
`<p><a href="${escapeHtml(input.baseUrl)}">Logga in för att bokföra</a></p>`,
|
||||
`<p style="color:#6b7280;font-size:12px">Du får det här mejlet för att du har slagit på daglig sammanfattning i ${escapeHtml(input.appName)}. Stäng av under Inställningar > Aviseringar.</p>`,
|
||||
].join('')
|
||||
|
||||
return { subject, text, html }
|
||||
}
|
||||
|
||||
/**
|
||||
* Member allowlist + emails for the given users, batched (not per-user
|
||||
* round-trips like resolveMemberEmail). Same two-step shape as
|
||||
* lib/notifications/member-email.ts and for the same reason: there is no FK
|
||||
* from company_members.user_id to profiles, so a PostgREST embed 400s.
|
||||
*/
|
||||
async function resolveCompanyMemberEmails(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userIds: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const emails = new Map<string, string>()
|
||||
const { data: members, error: memberError } = await supabase
|
||||
.from('company_members')
|
||||
.select('user_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('user_id', userIds)
|
||||
if (memberError) {
|
||||
log.warn('could not read company members for digest recipients', {
|
||||
companyId,
|
||||
error: memberError.message,
|
||||
})
|
||||
return emails
|
||||
}
|
||||
const memberIds = (members ?? [])
|
||||
.map((m) => (m as { user_id: string | null }).user_id)
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
if (memberIds.length === 0) return emails
|
||||
|
||||
const { data: profiles, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, email')
|
||||
.in('id', memberIds)
|
||||
if (profileError) {
|
||||
log.warn('could not read profile emails for digest recipients', {
|
||||
companyId,
|
||||
error: profileError.message,
|
||||
})
|
||||
return emails
|
||||
}
|
||||
for (const row of (profiles ?? []) as Array<{ id: string; email: string | null }>) {
|
||||
if (row.email) emails.set(row.id, row.email)
|
||||
}
|
||||
return emails
|
||||
}
|
||||
|
||||
/**
|
||||
* notification_log.reference_id is a uuid column, but the digest's natural
|
||||
* key is (company, date). Same deterministic SHA-256-to-uuid mapping as the
|
||||
* kvittens notification.
|
||||
*/
|
||||
function toReferenceUuid(referenceKey: string): string {
|
||||
const hex = createHash('sha256').update(referenceKey).digest('hex')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
|
||||
}
|
||||
|
||||
/** Split ids into .in()-safe chunks (see IN_CLAUSE_CHUNK). */
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Company names are user-controlled and reach the Subject header: strip
|
||||
* CR/LF and other control characters so the value can never smuggle extra
|
||||
* mail headers.
|
||||
*/
|
||||
function sanitizeHeaderText(input: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return input.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
-- "Nytt att bokföra" daily email digest (user request 2026-08-31).
|
||||
--
|
||||
-- Extends notification_log's notification_type CHECK with
|
||||
-- 'bookkeeping_digest': a per-user, per-company, per-day email summarizing
|
||||
-- new bank transactions and new inbox documents since the previous day.
|
||||
--
|
||||
-- Opt-in: notification_settings gains email_digest_enabled, default false,
|
||||
-- so nobody receives mail without flipping the toggle in
|
||||
-- /settings/extensions/push-notifications.
|
||||
|
||||
ALTER TABLE public.notification_log
|
||||
DROP CONSTRAINT IF EXISTS notification_log_notification_type_check;
|
||||
|
||||
ALTER TABLE public.notification_log
|
||||
ADD CONSTRAINT notification_log_notification_type_check
|
||||
CHECK (notification_type IN (
|
||||
'tax_deadline',
|
||||
'invoice_due',
|
||||
'invoice_overdue',
|
||||
'period_locked',
|
||||
'period_year_closed',
|
||||
'invoice_sent',
|
||||
'receipt_extracted',
|
||||
'receipt_matched',
|
||||
'missing_underlag',
|
||||
'skv_kvittens',
|
||||
'skv_connection_expired',
|
||||
'bookkeeping_digest'
|
||||
)) NOT VALID;
|
||||
|
||||
ALTER TABLE public.notification_log
|
||||
VALIDATE CONSTRAINT notification_log_notification_type_check;
|
||||
|
||||
-- Atomic claim-then-send dedup, same mechanism as the kvittens index
|
||||
-- (20260712113000): the sender inserts the log row FIRST and only sends
|
||||
-- when the insert won; an overlapping cron invocation gets a 23505 and
|
||||
-- skips. reference_id is a deterministic uuid derived from
|
||||
-- (company, digest date), so the scope is one mail per user per company
|
||||
-- per day. Scoped per type: other notification types legitimately log
|
||||
-- multiple rows per reference.
|
||||
--
|
||||
-- No defensive duplicate cleanup needed: the type is new in this migration,
|
||||
-- so the CHECK above guarantees no existing rows can carry it.
|
||||
--
|
||||
-- Plain CREATE INDEX (not CONCURRENTLY): Supabase branching applies
|
||||
-- migrations inside a transaction, where CONCURRENTLY is not allowed.
|
||||
-- notification_log is small and append-only; the brief lock is fine.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_log_bookkeeping_digest_dedup
|
||||
ON public.notification_log (user_id, reference_id)
|
||||
WHERE notification_type = 'bookkeeping_digest';
|
||||
|
||||
-- NOT NULL DEFAULT false, same rationale as missing_underlag_enabled
|
||||
-- (20260726174500): a toggle has no meaningful null state, and on
|
||||
-- Postgres 11+ a constant default adds without a table rewrite. Default
|
||||
-- false because this is a new outbound email channel: opt-in only.
|
||||
ALTER TABLE public.notification_settings
|
||||
ADD COLUMN IF NOT EXISTS email_digest_enabled boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.notification_settings.email_digest_enabled IS
|
||||
'Opt-in for the daily "nytt att bokföra" email digest (new bank transactions and inbox documents).';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Admit 'pending' to notification_log's delivery_status CHECK.
|
||||
--
|
||||
-- The bookkeeping digest (20260831100000) uses a recoverable claim: the row
|
||||
-- is inserted as 'pending' BEFORE the email is handed to the provider and
|
||||
-- flipped to 'sent' only after the provider accepted it. A 'pending' claim
|
||||
-- whose sent_at lease has gone stale marks a run that died mid-send and can
|
||||
-- be taken over by a later run. The prior senders (kvittens,
|
||||
-- connection-expired) insert 'sent' up front and lose the day's mail if the
|
||||
-- worker dies between claim and send; the digest closes that gap, which
|
||||
-- needs the intermediate state to be representable.
|
||||
--
|
||||
-- The original CHECK was defined inline in 20240101000008
|
||||
-- (('sent','delivered','failed')); same drop-and-recreate pattern as the
|
||||
-- notification_type constraint migrations.
|
||||
|
||||
ALTER TABLE public.notification_log
|
||||
DROP CONSTRAINT IF EXISTS notification_log_delivery_status_check;
|
||||
|
||||
ALTER TABLE public.notification_log
|
||||
ADD CONSTRAINT notification_log_delivery_status_check
|
||||
CHECK (delivery_status IN ('pending', 'sent', 'delivered', 'failed')) NOT VALID;
|
||||
|
||||
ALTER TABLE public.notification_log
|
||||
VALIDATE CONSTRAINT notification_log_delivery_status_check;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
+3
-1
@@ -2881,6 +2881,7 @@ export interface NotificationSettings {
|
||||
receipt_extracted_enabled: boolean
|
||||
receipt_matched_enabled: boolean
|
||||
missing_underlag_enabled: boolean
|
||||
email_digest_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -2898,6 +2899,7 @@ export type NotificationType =
|
||||
| 'missing_underlag'
|
||||
| 'skv_kvittens'
|
||||
| 'skv_connection_expired'
|
||||
| 'bookkeeping_digest'
|
||||
|
||||
// Notification log entry
|
||||
export interface NotificationLog {
|
||||
@@ -2908,7 +2910,7 @@ export interface NotificationLog {
|
||||
reference_id: string
|
||||
days_before: number
|
||||
sent_at: string
|
||||
delivery_status: 'sent' | 'delivered' | 'failed'
|
||||
delivery_status: 'pending' | 'sent' | 'delivered' | 'failed'
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
"path": "/api/receipt-hunt/cron",
|
||||
"schedule": "30 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/notifications/bookkeeping-digest/cron",
|
||||
"schedule": "45 5 * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/peppol/inbound/cron",
|
||||
"schedule": "*/10 * * * *"
|
||||
|
||||
Reference in New Issue
Block a user