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
@@ -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
|
||||
Reference in New Issue
Block a user