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,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';
|
||||
Reference in New Issue
Block a user