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:
Mattsson
2026-08-31 14:15:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 024420d655
commit 6dfaa45061
13 changed files with 1159 additions and 2 deletions
@@ -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>