feat(invoices): per-recipient email delivery outcomes (#1384)
* feat(invoices): per-recipient email delivery outcomes Resend delivery webhooks identify affected addresses in data.to, so one message with CC recipients can carry independent To/CC outcomes instead of masking the failing address into the aggregate reason text. - new apply_invoice_delivery_provider_event RPC merges each reported recipient onto its immutable To/CC position with the same rank and timestamp ordering as the aggregate status (retry and out-of-order safe) - recipient map is PII-free: keyed to:N / cc:N, BCC and unmatched recipients are never represented, and the map is cleared on PII redaction - delivery summaries, API route and MCP tool expose the sanitized map; the route re-sanitizes as defense in depth - UI shows a per-recipient status list under the aggregate outcome The prod ops check in issue #1350 (webhook registered in Resend and RESEND_DELIVERY_WEBHOOK_SECRET set in Vercel) cannot be verified from the repo and remains a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(invoices): commit provider event before cross-context read The BCC-leak test applied the event inside the rollback-scoped service role helper and then asserted through a separate member context, so the applied status was rolled back before the read. Use the committing runAsServiceRole helper for the apply, matching how the summary read is performed in its own context. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
bb1eddcccf
commit
cd7d7f52b9
@@ -744,6 +744,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-01] Out-of-order SIE IB activity is bounded by the target fiscal-period end, not its start: this excludes later-first imports while preserving same-period continuation suppression; successor IB resync checks the current error state plus a real target-period entry because result.success is finalized later, keeping replacement on a new engine voucher plus storno without letting a no-op import succeed through resync alone.
|
||||
[2026-08-01] Successor SIE IB replacement uses a specialized engine RPC instead of loosening the owner-only generic relink RPC: non-viewer members and scoped service-role imports are supported, while one period-row lock and expected-pointer CAS make the replacement voucher, storno, reversal status, pointer swap, and voucher sequence increments commit or roll back together.
|
||||
[2026-08-02] Out-of-order SIE IB resync requires exact date adjacency: the nearest later fiscal period can sit beyond a missing middle year, and replacing its authoritative IB with a non-adjacent UB would make that later period temporarily wrong until the gap was imported.
|
||||
[2026-08-03] SUPERSEDES the 2026-07-24 message-only invoice delivery decision: current Resend delivery webhooks identify the affected address or addresses in data.to, so one email with CC can keep its aggregate outcome and also store independent recipient outcomes. The recipient map is keyed only by immutable To/CC positions (to:1, cc:1), never by addresses; BCC and unmatched recipients remain message-level only. This preserves the WORM evidence row, avoids duplicating recipient PII, and does not require splitting one customer email into several messages.
|
||||
[2026-08-03] Issue #1360 keeps EUR annual reports fail-closed at general eligibility rather than only digital filing: the ledger and annual-report model are SEK-denominated, so allowing a EUR profile to lock a paper version would mislabel SEK amounts; full EUR support requires a company accounting-currency model across the ledger, report builders, and iXBRL.
|
||||
[2026-08-03] Issue #1267 localizes the latest-posted-voucher label in the web views while PDF and spreadsheet exports remain Swedish: translating one export label would create mixed-language files, so full export localization stays a separate surface-wide change.
|
||||
[2026-08-03] Wise imports fail closed on refunded or unknown statuses, unknown directions, and cross-currency transaction-history rows: the available export contract cannot establish their signed balance effect, and v1 must not silently discard a business event. Ordinary balance-statement rows share the canonical wise_ID external ID with transaction history to prevent overlapping cross-format imports; only conversion legs with explicit Exchange From/To metadata add the statement currency because the same Wise ID represents one movement per balance.
|
||||
|
||||
@@ -82,6 +82,15 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
provider_status: 'delivered',
|
||||
provider_status_at: '2026-07-22T10:30:04.000Z',
|
||||
provider_status_detail: null,
|
||||
provider_recipient_statuses: {
|
||||
'to:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
|
||||
'cc:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
|
||||
'bcc:1': { status: 'bounced', status_at: '2026-07-22T10:30:04.000Z' },
|
||||
'customer@example.com': {
|
||||
status: 'bounced',
|
||||
status_at: '2026-07-22T10:30:04.000Z',
|
||||
},
|
||||
},
|
||||
error_code: null,
|
||||
document_attachment_id: 'document-1',
|
||||
attachment_filename: 'faktura-f-1001.pdf',
|
||||
@@ -111,6 +120,10 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
provider_status: 'delivered',
|
||||
provider_status_at: '2026-07-22T10:30:04.000Z',
|
||||
provider_status_detail: null,
|
||||
provider_recipient_statuses: {
|
||||
'to:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
|
||||
'cc:1': { status: 'delivered', status_at: '2026-07-22T10:30:04.000Z' },
|
||||
},
|
||||
error_code: null,
|
||||
document_attachment_id: 'document-1',
|
||||
attachment_filename: 'faktura-f-1001.pdf',
|
||||
@@ -125,6 +138,8 @@ describe('GET /api/invoices/[id]/deliveries', () => {
|
||||
expect(body.data[0]).not.toHaveProperty('body_text')
|
||||
expect(body.data[0]).not.toHaveProperty('body_html')
|
||||
expect(body.data[0]).not.toHaveProperty('provider_message_id')
|
||||
expect(JSON.stringify(body.data[0])).not.toContain('customer@example.com')
|
||||
expect(JSON.stringify(body.data[0])).not.toContain('bcc:1')
|
||||
expect(body.data[0]).not.toHaveProperty('attachment_content_type')
|
||||
expect(body.data[0]).not.toHaveProperty('attachment_sha256')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
|
||||
@@ -2,9 +2,11 @@ import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses'
|
||||
import type {
|
||||
InvoiceDeliveryChannel,
|
||||
InvoiceDeliveryProviderStatus,
|
||||
InvoiceDeliveryRecipientStatuses,
|
||||
InvoiceDeliveryStatus,
|
||||
} from '@/types'
|
||||
|
||||
@@ -18,6 +20,7 @@ interface InvoiceDeliverySummaryRow {
|
||||
provider_status: InvoiceDeliveryProviderStatus | null
|
||||
provider_status_at: string | null
|
||||
provider_status_detail: string | null
|
||||
provider_recipient_statuses: InvoiceDeliveryRecipientStatuses
|
||||
error_code: string | null
|
||||
document_attachment_id: string | null
|
||||
attachment_filename: string | null
|
||||
@@ -42,12 +45,12 @@ interface MaskedInvoiceDeliverySummaryRow
|
||||
* addresses stay server-side. The attachment filename passes through: it is
|
||||
* derived from data the invoice already exposes to every company member. The
|
||||
* database allow-list and masking boundary is defined by
|
||||
* list_invoice_delivery_summaries in migration 20260724160000; this route
|
||||
* list_invoice_delivery_summaries in the invoice delivery migrations; this route
|
||||
* masks returned addresses again as defense in depth.
|
||||
*
|
||||
* The provider delivery outcome is message-level, never per recipient: the
|
||||
* provider reports one result for the whole send, and its reason text can
|
||||
* quote the failing address, so that text is masked the same way.
|
||||
* Recipient outcomes use only stable To/CC positions. Exact addresses and BCC
|
||||
* references never cross the database boundary, and reason text that can
|
||||
* quote a failing address is masked again here.
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'invoice.deliveries.list',
|
||||
@@ -93,6 +96,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
provider_status: delivery.provider_status,
|
||||
provider_status_at: delivery.provider_status_at,
|
||||
provider_status_detail: maskAddressesInText(delivery.provider_status_detail),
|
||||
provider_recipient_statuses: sanitizeDeliveryRecipientStatuses(
|
||||
delivery.provider_recipient_statuses,
|
||||
),
|
||||
error_code: delivery.error_code,
|
||||
document_attachment_id: delivery.document_attachment_id,
|
||||
attachment_filename: delivery.attachment_filename,
|
||||
|
||||
@@ -18,6 +18,7 @@ export type InvoiceDeliveryView = Pick<
|
||||
| 'provider_status'
|
||||
| 'provider_status_at'
|
||||
| 'provider_status_detail'
|
||||
| 'provider_recipient_statuses'
|
||||
| 'error_code'
|
||||
| 'document_attachment_id'
|
||||
| 'attachment_filename'
|
||||
@@ -108,6 +109,22 @@ export function InvoiceDeliveryHistory({
|
||||
const isEmailSend = !isManual && delivery.status === 'sent'
|
||||
const recipientCount =
|
||||
delivery.to_addresses.length + delivery.cc_addresses.length
|
||||
const recipientStatuses = delivery.provider_recipient_statuses ?? {}
|
||||
const hasRecipientStatuses = Object.keys(recipientStatuses).length > 0
|
||||
const recipientRows = [
|
||||
...delivery.to_addresses.map((address, index) => ({
|
||||
address,
|
||||
label: t('delivery_to_label'),
|
||||
reference: `to:${index + 1}`,
|
||||
outcome: recipientStatuses[`to:${index + 1}`],
|
||||
})),
|
||||
...delivery.cc_addresses.map((address, index) => ({
|
||||
address,
|
||||
label: t('delivery_cc_label'),
|
||||
reference: `cc:${index + 1}`,
|
||||
outcome: recipientStatuses[`cc:${index + 1}`],
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<details key={delivery.id} className="group rounded-lg border bg-card">
|
||||
@@ -161,14 +178,60 @@ export function InvoiceDeliveryHistory({
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(`delivery_status_explanation_${outcome}`)}
|
||||
{hasRecipientStatuses
|
||||
? t('delivery_recipient_statuses_summary')
|
||||
: t(`delivery_status_explanation_${outcome}`)}
|
||||
</p>
|
||||
{hasRecipientStatuses && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<p className="mb-2 text-xs font-medium">
|
||||
{t('delivery_recipient_statuses_label')}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{recipientRows.map((recipient) => (
|
||||
<li
|
||||
key={recipient.reference}
|
||||
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
|
||||
>
|
||||
<span className="min-w-0 break-all text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{recipient.label}:
|
||||
</span>{' '}
|
||||
{recipient.address}
|
||||
</span>
|
||||
{recipient.outcome ? (
|
||||
<span className="flex items-center gap-2">
|
||||
{recipient.outcome.status === 'delivered' ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('delivery_status_delivered')}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant={outcomeVariant[recipient.outcome.status]}>
|
||||
{t(`delivery_status_${recipient.outcome.status}`)}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{formatTimestamp(recipient.outcome.status_at)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('delivery_recipient_status_unknown')}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{delivery.provider_status_detail && (
|
||||
<p className="mt-2 break-words text-xs text-muted-foreground">
|
||||
{t('delivery_provider_reason_label')}: {delivery.provider_status_detail}
|
||||
</p>
|
||||
)}
|
||||
{recipientCount > 1 && delivery.provider_status && (
|
||||
{recipientCount > 1
|
||||
&& delivery.provider_status
|
||||
&& !hasRecipientStatuses && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('delivery_status_whole_send_note')}
|
||||
</p>
|
||||
|
||||
@@ -104,6 +104,7 @@ describe('toDeliveryReport', () => {
|
||||
status: 'bounced',
|
||||
occurredAt: '2026-07-24T08:00:00.000Z',
|
||||
detail: '550 5.1.1 Recipient address rejected Permanent/General',
|
||||
recipients: ['customer@example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,9 +142,35 @@ describe('toDeliveryReport', () => {
|
||||
status: 'bounced',
|
||||
occurredAt: '2026-07-24T08:00:00.000Z',
|
||||
detail: null,
|
||||
recipients: ['customer@example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes and deduplicates impacted recipients defensively', () => {
|
||||
const event = {
|
||||
type: 'email.delivered',
|
||||
created_at: '2026-07-24T08:00:00.000Z',
|
||||
data: baseData({
|
||||
to: [' Customer@example.com ', 'customer@example.com', 42, '', 'copy@example.org'],
|
||||
}),
|
||||
} as unknown as WebhookEventPayload
|
||||
|
||||
expect(toDeliveryReport(event)?.recipients).toEqual([
|
||||
'Customer@example.com',
|
||||
'copy@example.org',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a valid outcome when the impacted-recipient list is malformed', () => {
|
||||
const event = {
|
||||
type: 'email.bounced',
|
||||
created_at: '2026-07-24T08:00:00.000Z',
|
||||
data: baseData({ to: 'customer@example.com' }),
|
||||
} as unknown as WebhookEventPayload
|
||||
|
||||
expect(toDeliveryReport(event)?.recipients).toEqual([])
|
||||
})
|
||||
|
||||
it('drops an event without a provider message id', () => {
|
||||
const event = {
|
||||
type: 'email.delivered',
|
||||
@@ -235,12 +262,13 @@ describe('POST /api/extensions/ext/email/delivery-status', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toEqual({ applied: true })
|
||||
expect(rpcMock).toHaveBeenCalledWith('apply_invoice_delivery_provider_status', {
|
||||
expect(rpcMock).toHaveBeenCalledWith('apply_invoice_delivery_provider_event', {
|
||||
p_provider: 'resend',
|
||||
p_provider_message_id: 'msg-1',
|
||||
p_status: 'bounced',
|
||||
p_occurred_at: '2026-07-24T08:00:00.000Z',
|
||||
p_detail: 'Mailbox unavailable Permanent/General',
|
||||
p_recipient_addresses: ['customer@example.com'],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -56,13 +56,14 @@ export const emailExtension: Extension = {
|
||||
}
|
||||
|
||||
const { data, error } = await createServiceClientNoCookies().rpc(
|
||||
'apply_invoice_delivery_provider_status',
|
||||
'apply_invoice_delivery_provider_event',
|
||||
{
|
||||
p_provider: 'resend',
|
||||
p_provider_message_id: report.providerMessageId,
|
||||
p_status: report.status,
|
||||
p_occurred_at: report.occurredAt,
|
||||
p_detail: report.detail,
|
||||
p_recipient_addresses: report.recipients,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
*
|
||||
* "Accepted by Resend" and "the recipient's server took it" are two different
|
||||
* facts, and only the first one is known when a send returns. Resend reports
|
||||
* the second one asynchronously, per message: one report covers every
|
||||
* recipient on that message, and the reason text names the address that
|
||||
* failed. This module verifies the signed payload and maps it onto the
|
||||
* the second one asynchronously. Resend identifies the recipient(s) affected
|
||||
* by each event in `data.to`, which lets one message carry independent To/CC
|
||||
* outcomes. This module verifies the signed payload and maps it onto the
|
||||
* provider status stored on the invoice delivery row.
|
||||
*/
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ProviderDeliveryReport {
|
||||
status: InvoiceDeliveryProviderStatus
|
||||
occurredAt: string
|
||||
detail: string | null
|
||||
recipients: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,27 @@ function text(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
function recipientAddresses(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const seen = new Set<string>()
|
||||
const recipients: string[] = []
|
||||
|
||||
for (const valueItem of value) {
|
||||
const address = text(valueItem)
|
||||
if (!address || address.length > 320) continue
|
||||
|
||||
const normalized = address.toLocaleLowerCase('en-US')
|
||||
if (seen.has(normalized)) continue
|
||||
|
||||
seen.add(normalized)
|
||||
recipients.push(address)
|
||||
if (recipients.length === 100) break
|
||||
}
|
||||
|
||||
return recipients
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason is read defensively: the payload is external input, and a
|
||||
* provider that ships a new event shape must degrade to "no reason given"
|
||||
@@ -123,16 +145,18 @@ export function toDeliveryReport(event: WebhookEventPayload): ProviderDeliveryRe
|
||||
const status = STATUS_BY_EVENT[event.type]
|
||||
if (!status) return null
|
||||
|
||||
const data = event.data as { email_id?: string }
|
||||
if (!data.email_id) return null
|
||||
const data = event.data as { email_id?: unknown; to?: unknown }
|
||||
const providerMessageId = text(data.email_id)
|
||||
if (!providerMessageId) return null
|
||||
|
||||
const occurredAt = parseTimestamp(event.created_at)
|
||||
|
||||
return {
|
||||
providerMessageId: data.email_id,
|
||||
providerMessageId,
|
||||
status,
|
||||
occurredAt,
|
||||
detail: reasonText(event),
|
||||
recipients: recipientAddresses(data.to),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ const BOUNCED_ROW = {
|
||||
provider_status: 'bounced',
|
||||
provider_status_at: '2026-07-20T10:00:00+00:00',
|
||||
provider_status_detail: 'smtp; 550 5.1.1 ***@example.com recipient rejected',
|
||||
provider_recipient_statuses: {
|
||||
'to:1': { status: 'bounced', status_at: '2026-07-20T10:00:00+00:00' },
|
||||
'cc:1': { status: 'delivered', status_at: '2026-07-20T09:59:00+00:00' },
|
||||
},
|
||||
error_code: null,
|
||||
document_attachment_id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
attachment_filename: 'faktura-1042.pdf',
|
||||
@@ -114,6 +118,10 @@ describe('gnubok_get_invoice_deliveries: execute', () => {
|
||||
expect(delivery.provider_status).toBe('bounced')
|
||||
expect(delivery.provider_status_at).toBe('2026-07-20T10:00:00+00:00')
|
||||
expect(delivery.provider_status_detail).toContain('550 5.1.1')
|
||||
expect(delivery.provider_recipient_statuses).toEqual({
|
||||
'to:1': { status: 'bounced', status_at: '2026-07-20T10:00:00+00:00' },
|
||||
'cc:1': { status: 'delivered', status_at: '2026-07-20T09:59:00+00:00' },
|
||||
})
|
||||
expect(delivery.error_code).toBeNull()
|
||||
expect(delivery.to_addresses).toEqual(['***@example.com'])
|
||||
expect(delivery.cc_addresses).toEqual(['***@example.org'])
|
||||
@@ -160,6 +168,14 @@ describe('gnubok_get_invoice_deliveries: execute', () => {
|
||||
body_text: 'LEAKED-BODY-TEXT',
|
||||
body_html: '<p>LEAKED-BODY-HTML</p>',
|
||||
bcc_addresses: ['leaked.bcc@example.net'],
|
||||
provider_recipient_statuses: {
|
||||
...BOUNCED_ROW.provider_recipient_statuses,
|
||||
'bcc:1': { status: 'bounced', status_at: '2026-07-20T10:00:00+00:00' },
|
||||
'leaked.bcc@example.net': {
|
||||
status: 'bounced',
|
||||
status_at: '2026-07-20T10:00:00+00:00',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -178,6 +194,7 @@ describe('gnubok_get_invoice_deliveries: execute', () => {
|
||||
expect(serialized).not.toContain('LEAKED-BODY-HTML')
|
||||
expect(serialized).not.toContain('leaked.bcc')
|
||||
expect(serialized).not.toContain('bcc_addresses')
|
||||
expect(serialized).not.toContain('bcc:1')
|
||||
})
|
||||
|
||||
it('throws Invoice not found for an invoice outside the routed company', async () => {
|
||||
|
||||
@@ -138,6 +138,7 @@ import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliatio
|
||||
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
|
||||
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
|
||||
import { sanitizeDeliveryRecipientStatuses } from '@/lib/invoices/delivery-recipient-statuses'
|
||||
import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
|
||||
import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import'
|
||||
import { RotRutBeslutFileSchema } from '@/lib/api/schemas'
|
||||
@@ -5447,6 +5448,19 @@ export const tools: McpTool[] = [
|
||||
type: ['string', 'null'],
|
||||
description: 'Provider reason text for a failure, with address local parts masked.',
|
||||
},
|
||||
provider_recipient_statuses: {
|
||||
type: 'object',
|
||||
description: 'PII-free outcomes keyed by stable To/CC positions such as to:1 and cc:1. BCC is never included.',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
status_at: { type: 'string' },
|
||||
},
|
||||
required: ['status', 'status_at'],
|
||||
},
|
||||
},
|
||||
error_code: { type: ['string', 'null'] },
|
||||
to_addresses: {
|
||||
type: 'array',
|
||||
@@ -5513,6 +5527,7 @@ export const tools: McpTool[] = [
|
||||
provider_status: string | null
|
||||
provider_status_at: string | null
|
||||
provider_status_detail: string | null
|
||||
provider_recipient_statuses: Record<string, { status: string; status_at: string }> | null
|
||||
error_code: string | null
|
||||
attachment_filename: string | null
|
||||
sent_at: string | null
|
||||
@@ -5527,6 +5542,9 @@ export const tools: McpTool[] = [
|
||||
provider_status: row.provider_status ?? null,
|
||||
provider_status_at: row.provider_status_at ?? null,
|
||||
provider_status_detail: row.provider_status_detail ?? null,
|
||||
provider_recipient_statuses: sanitizeDeliveryRecipientStatuses(
|
||||
row.provider_recipient_statuses,
|
||||
),
|
||||
error_code: row.error_code ?? null,
|
||||
to_addresses: row.to_addresses ?? [],
|
||||
cc_addresses: row.cc_addresses ?? [],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PoolClient } from 'pg'
|
||||
import { getPool, withUserContext } from '@/tests/pg/setup'
|
||||
import { getPool, runAsServiceRole, withUserContext } from '@/tests/pg/setup'
|
||||
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
|
||||
|
||||
async function withServiceRoleContext<T>(
|
||||
@@ -88,6 +88,9 @@ async function insertPendingEmailDelivery(params: {
|
||||
invoiceId: string
|
||||
documentId: string
|
||||
retentionExpiresAt?: string
|
||||
toAddresses?: string[]
|
||||
ccAddresses?: string[]
|
||||
bccAddresses?: string[]
|
||||
}): Promise<string> {
|
||||
const deliveryId = randomUUID()
|
||||
await getPool().query(
|
||||
@@ -97,7 +100,7 @@ async function insertPendingEmailDelivery(params: {
|
||||
body_text, body_html, document_attachment_id, attachment_filename,
|
||||
attachment_content_type, attachment_sha256, retention_expires_at)
|
||||
VALUES ($1, $2, $3, $4, 'email', 'pending',
|
||||
ARRAY['customer@example.com'], ARRAY['copy@example.com'], ARRAY['archive@example.com'],
|
||||
$8::text[], $9::text[], $10::text[],
|
||||
'sender@example.com', 'Example AB', 'Faktura F-1001',
|
||||
'Exact plain text', '<p>Exact HTML</p>', $5,
|
||||
'invoice.pdf', 'application/pdf', $6, $7)`,
|
||||
@@ -109,6 +112,9 @@ async function insertPendingEmailDelivery(params: {
|
||||
params.documentId,
|
||||
'a'.repeat(64),
|
||||
params.retentionExpiresAt ?? null,
|
||||
params.toAddresses ?? ['customer@example.com'],
|
||||
params.ccAddresses ?? ['copy@example.com'],
|
||||
params.bccAddresses ?? ['archive@example.com'],
|
||||
],
|
||||
)
|
||||
return deliveryId
|
||||
@@ -125,6 +131,9 @@ async function insertSentEmailDelivery(params: {
|
||||
invoiceId: string
|
||||
documentId: string
|
||||
retentionExpiresAt?: string
|
||||
toAddresses?: string[]
|
||||
ccAddresses?: string[]
|
||||
bccAddresses?: string[]
|
||||
}): Promise<{ deliveryId: string; providerMessageId: string }> {
|
||||
const deliveryId = await insertPendingEmailDelivery(params)
|
||||
const providerMessageId = `provider-${randomUUID()}`
|
||||
@@ -453,6 +462,24 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
[companyId, invoiceId, userId],
|
||||
)),
|
||||
).rejects.toThrow(/permission denied/i)
|
||||
|
||||
await expect(
|
||||
withServiceRoleContext(userId, (client) => client.query(
|
||||
`SELECT public.apply_invoice_delivery_provider_event(
|
||||
'resend', 'msg-1', 'opened', now(), NULL,
|
||||
ARRAY['customer@example.com']
|
||||
)`,
|
||||
)),
|
||||
).rejects.toThrow(/unsupported invoice delivery provider status/i)
|
||||
|
||||
await expect(
|
||||
withUserContext(memberId, (client) => client.query(
|
||||
`SELECT public.apply_invoice_delivery_provider_event(
|
||||
'resend', 'msg-1', 'delivered', now(), NULL,
|
||||
ARRAY['customer@example.com']
|
||||
)`,
|
||||
)),
|
||||
).rejects.toThrow(/permission denied/i)
|
||||
})
|
||||
|
||||
it('uses server-only RPCs for reservation, payload capture, and finalization', async () => {
|
||||
@@ -753,6 +780,139 @@ describe('invoice_deliveries.pg: immutable delivery evidence', () => {
|
||||
})
|
||||
|
||||
describe('invoice_deliveries.pg: provider delivery outcome', () => {
|
||||
it('tracks independent To and CC outcomes on one provider message', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const documentId = await insertDocument(userId, companyId)
|
||||
const { deliveryId, providerMessageId } = await insertSentEmailDelivery({
|
||||
userId,
|
||||
companyId,
|
||||
invoiceId,
|
||||
documentId,
|
||||
toAddresses: ['primary@example.com', 'secondary@example.com'],
|
||||
ccAddresses: ['copy@example.org'],
|
||||
})
|
||||
|
||||
await withServiceRoleContext(userId, async (client) => {
|
||||
await client.query(
|
||||
`SELECT public.apply_invoice_delivery_provider_event(
|
||||
'resend', $1, 'delivered', '2026-08-03T08:00:00Z', NULL,
|
||||
ARRAY['secondary@example.com', 'COPY@example.org']
|
||||
)`,
|
||||
[providerMessageId],
|
||||
)
|
||||
await client.query(
|
||||
`SELECT public.apply_invoice_delivery_provider_event(
|
||||
'resend', $1, 'bounced', '2026-08-03T08:01:00Z', 'Mailbox unavailable',
|
||||
ARRAY['primary@example.com']
|
||||
)`,
|
||||
[providerMessageId],
|
||||
)
|
||||
|
||||
const row = await client.query<{
|
||||
provider_status: string
|
||||
provider_recipient_statuses: Record<string, { status: string; status_at: string }>
|
||||
}>(
|
||||
`SELECT provider_status, provider_recipient_statuses
|
||||
FROM public.invoice_deliveries
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
|
||||
expect(row.rows[0].provider_status).toBe('bounced')
|
||||
expect(row.rows[0].provider_recipient_statuses).toMatchObject({
|
||||
'to:1': { status: 'bounced' },
|
||||
'to:2': { status: 'delivered' },
|
||||
'cc:1': { status: 'delivered' },
|
||||
})
|
||||
expect(Object.keys(row.rows[0].provider_recipient_statuses).sort()).toEqual([
|
||||
'cc:1',
|
||||
'to:1',
|
||||
'to:2',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('never downgrades one recipient on late or repeated reports', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const documentId = await insertDocument(userId, companyId)
|
||||
const { deliveryId, providerMessageId } = await insertSentEmailDelivery({
|
||||
userId,
|
||||
companyId,
|
||||
invoiceId,
|
||||
documentId,
|
||||
})
|
||||
|
||||
await withServiceRoleContext(userId, async (client) => {
|
||||
const apply = (status: string, occurredAt: string) => client.query(
|
||||
`SELECT public.apply_invoice_delivery_provider_event(
|
||||
'resend', $1, $2, $3::timestamptz, NULL,
|
||||
ARRAY['customer@example.com']
|
||||
)`,
|
||||
[providerMessageId, status, occurredAt],
|
||||
)
|
||||
|
||||
await apply('bounced', '2026-08-03T08:02:00Z')
|
||||
await apply('delivered', '2026-08-03T08:03:00Z')
|
||||
await apply('bounced', '2026-08-03T08:01:00Z')
|
||||
|
||||
const row = await client.query<{
|
||||
provider_recipient_statuses: Record<string, { status: string; status_at: string }>
|
||||
}>(
|
||||
`SELECT provider_recipient_statuses
|
||||
FROM public.invoice_deliveries
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
expect(row.rows[0].provider_recipient_statuses['to:1']).toMatchObject({
|
||||
status: 'bounced',
|
||||
status_at: '2026-08-03T08:02:00+00:00',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps unknown and BCC recipients out of visible recipient state', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const memberId = await insertAuthUser()
|
||||
await insertCompanyMember({ companyId, userId: memberId, role: 'member' })
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const documentId = await insertDocument(userId, companyId)
|
||||
const { providerMessageId } = await insertSentEmailDelivery({
|
||||
userId,
|
||||
companyId,
|
||||
invoiceId,
|
||||
documentId,
|
||||
bccAddresses: ['archive.secret@example.net'],
|
||||
})
|
||||
|
||||
// The applied event must survive into the member's read below, so this
|
||||
// uses the committing service-role helper: the rollback-scoped
|
||||
// withServiceRoleContext only supports asserting inside its own callback.
|
||||
await runAsServiceRole((client) => client.query(
|
||||
`SELECT public.apply_invoice_delivery_provider_event(
|
||||
'resend', $1, 'bounced', now(), NULL,
|
||||
ARRAY['archive.secret@example.net', 'unknown@example.org']
|
||||
)`,
|
||||
[providerMessageId],
|
||||
))
|
||||
|
||||
const summary = await withUserContext(memberId, (client) => client.query<{
|
||||
provider_status: string
|
||||
provider_recipient_statuses: Record<string, unknown>
|
||||
}>(
|
||||
`SELECT provider_status, provider_recipient_statuses
|
||||
FROM public.list_invoice_delivery_summaries($1, $2)`,
|
||||
[companyId, invoiceId],
|
||||
))
|
||||
|
||||
expect(summary.rows[0]).toEqual({
|
||||
provider_status: 'bounced',
|
||||
provider_recipient_statuses: {},
|
||||
})
|
||||
expect(JSON.stringify(summary.rows[0])).not.toContain('archive.secret')
|
||||
})
|
||||
|
||||
it('records the provider outcome on a sent email and keeps the rest locked', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
@@ -892,6 +1052,50 @@ describe('invoice_deliveries.pg: provider delivery outcome', () => {
|
||||
),
|
||||
).rejects.toThrow(/terminal invoice delivery.*immutable/i)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.invoice_deliveries
|
||||
SET provider_status = 'delivered', provider_status_at = now(),
|
||||
provider_recipient_statuses = jsonb_build_object(
|
||||
'to:1', jsonb_build_object('status', 'delivered', 'status_at', now()),
|
||||
'bcc:1', jsonb_build_object('status', 'bounced', 'status_at', now())
|
||||
)
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
),
|
||||
).rejects.toThrow(/invoice_deliveries_recipient_statuses_shape/i)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.invoice_deliveries
|
||||
SET provider_status = 'delivered', provider_status_at = now(),
|
||||
provider_recipient_statuses = jsonb_build_object(
|
||||
'to:999', jsonb_build_object(
|
||||
'status', 'delivered',
|
||||
'status_at', now()
|
||||
)
|
||||
)
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
),
|
||||
).rejects.toThrow(/invoice_deliveries_recipient_statuses_shape/i)
|
||||
|
||||
await expect(
|
||||
getPool().query(
|
||||
`UPDATE public.invoice_deliveries
|
||||
SET provider_status = 'delivered', provider_status_at = now(),
|
||||
provider_recipient_statuses = jsonb_build_object(
|
||||
'to:1', jsonb_build_object(
|
||||
'status', 'delivered',
|
||||
'status_at', now(),
|
||||
'unexpected', 'not allowed'
|
||||
)
|
||||
)
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
),
|
||||
).rejects.toThrow(/invoice_deliveries_recipient_statuses_shape/i)
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE public.invoice_deliveries
|
||||
SET provider_status = 'bounced', provider_status_at = now()
|
||||
@@ -932,7 +1136,7 @@ describe('invoice_deliveries.pg: provider delivery outcome', () => {
|
||||
).rejects.toThrow(/invoice delivery payload is immutable/i)
|
||||
})
|
||||
|
||||
it('redacts the provider reason text with the rest of the expired PII', async () => {
|
||||
it('redacts provider recipient details with the rest of the expired PII', async () => {
|
||||
const { userId, companyId } = await seedCompany()
|
||||
const invoiceId = await insertInvoice(userId, companyId)
|
||||
const documentId = await insertDocument(userId, companyId)
|
||||
@@ -946,7 +1150,10 @@ describe('invoice_deliveries.pg: provider delivery outcome', () => {
|
||||
await getPool().query(
|
||||
`UPDATE public.invoice_deliveries
|
||||
SET provider_status = 'bounced', provider_status_at = now(),
|
||||
provider_status_detail = '550 5.1.1 <customer@example.com> rejected'
|
||||
provider_status_detail = '550 5.1.1 <customer@example.com> rejected',
|
||||
provider_recipient_statuses = jsonb_build_object(
|
||||
'to:1', jsonb_build_object('status', 'bounced', 'status_at', now())
|
||||
)
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
@@ -956,15 +1163,18 @@ describe('invoice_deliveries.pg: provider delivery outcome', () => {
|
||||
const delivery = await getPool().query<{
|
||||
provider_status: string
|
||||
provider_status_detail: string | null
|
||||
provider_recipient_statuses: Record<string, unknown>
|
||||
pii_redacted_at: string | null
|
||||
}>(
|
||||
`SELECT provider_status, provider_status_detail, pii_redacted_at
|
||||
`SELECT provider_status, provider_status_detail, provider_recipient_statuses,
|
||||
pii_redacted_at
|
||||
FROM public.invoice_deliveries
|
||||
WHERE id = $1`,
|
||||
[deliveryId],
|
||||
)
|
||||
expect(delivery.rows[0].provider_status).toBe('bounced')
|
||||
expect(delivery.rows[0].provider_status_detail).toBeNull()
|
||||
expect(delivery.rows[0].provider_recipient_statuses).toEqual({})
|
||||
expect(delivery.rows[0].pii_redacted_at).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
InvoiceDeliveryProviderStatus,
|
||||
InvoiceDeliveryRecipientStatuses,
|
||||
} from '@/types'
|
||||
|
||||
const PROVIDER_STATUSES = new Set<InvoiceDeliveryProviderStatus>([
|
||||
'delayed',
|
||||
'delivered',
|
||||
'complained',
|
||||
'bounced',
|
||||
'failed',
|
||||
'suppressed',
|
||||
])
|
||||
|
||||
/**
|
||||
* Keeps the public recipient-outcome shape PII-free even if an upstream RPC
|
||||
* is widened accidentally. Only stable To/CC positions and known outcomes
|
||||
* survive; raw addresses and BCC references are discarded.
|
||||
*/
|
||||
export function sanitizeDeliveryRecipientStatuses(
|
||||
value: unknown,
|
||||
): InvoiceDeliveryRecipientStatuses {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
|
||||
const sanitized: Record<
|
||||
string,
|
||||
{ status: InvoiceDeliveryProviderStatus; status_at: string }
|
||||
> = {}
|
||||
|
||||
for (const [reference, outcome] of Object.entries(value)) {
|
||||
if (!/^(to|cc):[1-9][0-9]*$/.test(reference)) continue
|
||||
if (!outcome || typeof outcome !== 'object' || Array.isArray(outcome)) continue
|
||||
|
||||
const candidate = outcome as { status?: unknown; status_at?: unknown }
|
||||
if (
|
||||
typeof candidate.status !== 'string'
|
||||
|| !PROVIDER_STATUSES.has(candidate.status as InvoiceDeliveryProviderStatus)
|
||||
|| typeof candidate.status_at !== 'string'
|
||||
|| Number.isNaN(new Date(candidate.status_at).getTime())
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
sanitized[reference] = {
|
||||
status: candidate.status as InvoiceDeliveryProviderStatus,
|
||||
status_at: candidate.status_at,
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized as InvoiceDeliveryRecipientStatuses
|
||||
}
|
||||
+4
-1
@@ -3306,7 +3306,10 @@
|
||||
"delivery_status_explanation_suppressed": "The email provider has blocked this address after an earlier bounce or spam complaint, so the message was never sent.",
|
||||
"delivery_status_whole_send_note": "This applies to the whole send, not to individual recipients.",
|
||||
"delivery_provider_status_label": "Delivery status",
|
||||
"delivery_provider_reason_label": "Reason from the recipient"
|
||||
"delivery_provider_reason_label": "Reason from the recipient",
|
||||
"delivery_recipient_statuses_label": "Status per recipient",
|
||||
"delivery_recipient_statuses_summary": "The overall status shows the most serious reported outcome. Each recipient's result is shown below.",
|
||||
"delivery_recipient_status_unknown": "No delivery report yet"
|
||||
},
|
||||
"invoice_credit": {
|
||||
"back": "Back",
|
||||
|
||||
+4
-1
@@ -3306,7 +3306,10 @@
|
||||
"delivery_status_explanation_suppressed": "Adressen är spärrad hos e-posttjänsten efter tidigare studs eller spam-anmälan, så mailet skickades aldrig.",
|
||||
"delivery_status_whole_send_note": "Beskedet gäller hela utskicket, inte enskilda mottagare.",
|
||||
"delivery_provider_status_label": "Leveransstatus",
|
||||
"delivery_provider_reason_label": "Besked från mottagaren"
|
||||
"delivery_provider_reason_label": "Besked från mottagaren",
|
||||
"delivery_recipient_statuses_label": "Status per mottagare",
|
||||
"delivery_recipient_statuses_summary": "Den övergripande statusen visar det allvarligaste beskedet. Resultatet för varje mottagare visas nedan.",
|
||||
"delivery_recipient_status_unknown": "Inget leveransbesked ännu"
|
||||
},
|
||||
"invoice_credit": {
|
||||
"back": "Tillbaka",
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
-- Per-recipient provider outcomes for invoice email delivery.
|
||||
--
|
||||
-- Resend's delivery webhooks identify the affected addresses in data.to. The
|
||||
-- addresses already live in immutable To/CC arrays, so outcomes can be stored
|
||||
-- without duplicating PII: keys such as to:1 and cc:2 refer to array positions.
|
||||
-- BCC recipients and unmatched addresses are deliberately never represented.
|
||||
|
||||
ALTER TABLE public.invoice_deliveries
|
||||
ADD COLUMN provider_recipient_statuses jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.invoice_delivery_recipient_statuses_valid(
|
||||
p_statuses jsonb,
|
||||
p_to_addresses text[],
|
||||
p_cc_addresses text[]
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
recipient_reference text;
|
||||
recipient_outcome jsonb;
|
||||
status_at_text text;
|
||||
BEGIN
|
||||
IF p_statuses IS NULL OR jsonb_typeof(p_statuses) <> 'object' THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
FOR recipient_reference, recipient_outcome IN
|
||||
SELECT entry.key, entry.value
|
||||
FROM jsonb_each(p_statuses) AS entry
|
||||
LOOP
|
||||
IF recipient_reference !~ '^(to|cc):[1-9][0-9]*$'
|
||||
OR jsonb_typeof(recipient_outcome) <> 'object'
|
||||
OR NOT recipient_outcome ? 'status'
|
||||
OR NOT recipient_outcome ? 'status_at'
|
||||
OR (recipient_outcome - 'status' - 'status_at') <> '{}'::jsonb
|
||||
OR jsonb_typeof(recipient_outcome -> 'status') <> 'string'
|
||||
OR jsonb_typeof(recipient_outcome -> 'status_at') <> 'string'
|
||||
OR recipient_outcome ->> 'status' NOT IN (
|
||||
'delayed', 'delivered', 'complained', 'bounced', 'failed', 'suppressed'
|
||||
)
|
||||
THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
IF recipient_reference LIKE 'to:%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM generate_subscripts(p_to_addresses, 1) AS positions(position)
|
||||
WHERE recipient_reference = 'to:' || positions.position
|
||||
)
|
||||
THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
IF recipient_reference LIKE 'cc:%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM generate_subscripts(p_cc_addresses, 1) AS positions(position)
|
||||
WHERE recipient_reference = 'cc:' || positions.position
|
||||
)
|
||||
THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
status_at_text := recipient_outcome ->> 'status_at';
|
||||
IF status_at_text !~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$' THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
PERFORM status_at_text::timestamptz;
|
||||
EXCEPTION WHEN datetime_field_overflow OR invalid_datetime_format THEN
|
||||
RETURN false;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
RETURN true;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.invoice_delivery_recipient_statuses_valid(jsonb, text[], text[])
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
|
||||
ALTER TABLE public.invoice_deliveries
|
||||
ADD CONSTRAINT invoice_deliveries_recipient_statuses_shape CHECK (
|
||||
public.invoice_delivery_recipient_statuses_valid(
|
||||
provider_recipient_statuses,
|
||||
to_addresses,
|
||||
cc_addresses
|
||||
)
|
||||
AND (
|
||||
provider_recipient_statuses = '{}'::jsonb
|
||||
OR (channel = 'email' AND status = 'sent')
|
||||
)
|
||||
) NOT VALID;
|
||||
|
||||
ALTER TABLE public.invoice_deliveries
|
||||
VALIDATE CONSTRAINT invoice_deliveries_recipient_statuses_shape;
|
||||
|
||||
COMMENT ON COLUMN public.invoice_deliveries.provider_recipient_statuses IS
|
||||
'PII-free provider outcomes keyed by immutable To/CC position (for example to:1). Exact addresses, BCC recipients, and unmatched webhook recipients are never stored here.';
|
||||
|
||||
COMMENT ON COLUMN public.invoice_deliveries.provider_status IS
|
||||
'Highest-ranked delivery outcome reported by the email provider for the message. Per-recipient detail, when available, is stored separately by PII-free To/CC position.';
|
||||
|
||||
-- Recipient outcomes are metadata and contain no address or reason text, so
|
||||
-- they may be included in the minimized audit image alongside aggregate state.
|
||||
CREATE OR REPLACE FUNCTION public.invoice_delivery_audit_state(
|
||||
delivery public.invoice_deliveries
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT jsonb_build_object(
|
||||
'id', delivery.id,
|
||||
'company_id', delivery.company_id,
|
||||
'user_id', delivery.user_id,
|
||||
'invoice_id', delivery.invoice_id,
|
||||
'channel', delivery.channel,
|
||||
'status', delivery.status,
|
||||
'document_attachment_id', delivery.document_attachment_id,
|
||||
'provider', delivery.provider,
|
||||
'provider_status', delivery.provider_status,
|
||||
'provider_status_at', delivery.provider_status_at,
|
||||
'provider_recipient_statuses', delivery.provider_recipient_statuses,
|
||||
'error_code', delivery.error_code,
|
||||
'sent_at', delivery.sent_at,
|
||||
'failed_at', delivery.failed_at,
|
||||
'retention_expires_at', delivery.retention_expires_at,
|
||||
'pii_redacted_at', delivery.pii_redacted_at,
|
||||
'created_at', delivery.created_at
|
||||
)
|
||||
$$;
|
||||
|
||||
-- Preserve the WORM evidence contract. A sent, unredacted row may change only
|
||||
-- the provider outcome fields; every other column remains immutable.
|
||||
CREATE OR REPLACE FUNCTION public.enforce_invoice_delivery_immutability()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
IF OLD.status = 'preparing'
|
||||
AND OLD.created_at <= now() - interval '15 minutes'
|
||||
THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.audit_log (
|
||||
user_id, company_id, action, table_name, record_id, actor_id,
|
||||
old_state, description
|
||||
) VALUES (
|
||||
OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'invoice_deliveries',
|
||||
OLD.id, auth.uid(), public.invoice_delivery_audit_state(OLD),
|
||||
'Blocked deletion of immutable invoice delivery history.'
|
||||
);
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'preparing' THEN
|
||||
IF NEW.status <> 'pending'
|
||||
OR NEW.company_id IS DISTINCT FROM OLD.company_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
|
||||
OR NEW.channel IS DISTINCT FROM OLD.channel
|
||||
OR NEW.provider IS NOT NULL
|
||||
OR NEW.provider_message_id IS NOT NULL
|
||||
OR NEW.provider_status IS NOT NULL
|
||||
OR NEW.provider_status_at IS NOT NULL
|
||||
OR NEW.provider_status_detail IS NOT NULL
|
||||
OR NEW.provider_recipient_statuses <> '{}'::jsonb
|
||||
OR NEW.error_code IS NOT NULL
|
||||
OR NEW.sent_at IS NOT NULL
|
||||
OR NEW.failed_at IS NOT NULL
|
||||
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
|
||||
OR NEW.pii_redacted_at IS NOT NULL
|
||||
OR NEW.created_at IS DISTINCT FROM OLD.created_at
|
||||
THEN
|
||||
RAISE EXCEPTION 'preparing invoice delivery may only capture its pending payload'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'pending' THEN
|
||||
IF NEW.status NOT IN ('sent', 'failed') THEN
|
||||
RAISE EXCEPTION 'pending invoice delivery may only transition to sent or failed'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
|
||||
IF NEW.company_id IS DISTINCT FROM OLD.company_id
|
||||
OR NEW.user_id IS DISTINCT FROM OLD.user_id
|
||||
OR NEW.invoice_id IS DISTINCT FROM OLD.invoice_id
|
||||
OR NEW.channel IS DISTINCT FROM OLD.channel
|
||||
OR NEW.to_addresses IS DISTINCT FROM OLD.to_addresses
|
||||
OR NEW.cc_addresses IS DISTINCT FROM OLD.cc_addresses
|
||||
OR NEW.bcc_addresses IS DISTINCT FROM OLD.bcc_addresses
|
||||
OR NEW.reply_to IS DISTINCT FROM OLD.reply_to
|
||||
OR NEW.from_name IS DISTINCT FROM OLD.from_name
|
||||
OR NEW.subject IS DISTINCT FROM OLD.subject
|
||||
OR NEW.body_text IS DISTINCT FROM OLD.body_text
|
||||
OR NEW.body_html IS DISTINCT FROM OLD.body_html
|
||||
OR NEW.attachment_filename IS DISTINCT FROM OLD.attachment_filename
|
||||
OR NEW.attachment_content_type IS DISTINCT FROM OLD.attachment_content_type
|
||||
OR NEW.attachment_sha256 IS DISTINCT FROM OLD.attachment_sha256
|
||||
OR NEW.retention_expires_at IS DISTINCT FROM OLD.retention_expires_at
|
||||
OR NEW.pii_redacted_at IS DISTINCT FROM OLD.pii_redacted_at
|
||||
OR NEW.created_at IS DISTINCT FROM OLD.created_at
|
||||
OR NEW.provider_status IS NOT NULL
|
||||
OR NEW.provider_status_at IS NOT NULL
|
||||
OR NEW.provider_status_detail IS NOT NULL
|
||||
OR NEW.provider_recipient_statuses <> '{}'::jsonb
|
||||
OR (
|
||||
NEW.status = 'sent'
|
||||
AND NEW.document_attachment_id IS DISTINCT FROM OLD.document_attachment_id
|
||||
)
|
||||
OR (NEW.status = 'failed' AND NEW.document_attachment_id IS NOT NULL)
|
||||
THEN
|
||||
RAISE EXCEPTION 'invoice delivery payload is immutable'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.status = 'sent'
|
||||
AND OLD.pii_redacted_at IS NULL
|
||||
AND NEW.provider_status IS NOT NULL
|
||||
AND (to_jsonb(NEW)
|
||||
- 'provider_status'
|
||||
- 'provider_status_at'
|
||||
- 'provider_status_detail'
|
||||
- 'provider_recipient_statuses'
|
||||
- 'updated_at')
|
||||
IS NOT DISTINCT FROM
|
||||
(to_jsonb(OLD)
|
||||
- 'provider_status'
|
||||
- 'provider_status_at'
|
||||
- 'provider_status_detail'
|
||||
- 'provider_recipient_statuses'
|
||||
- 'updated_at')
|
||||
THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF OLD.status IN ('sent', 'failed')
|
||||
AND OLD.pii_redacted_at IS NULL
|
||||
AND CURRENT_DATE >= OLD.retention_expires_at
|
||||
AND NEW.pii_redacted_at IS NOT NULL
|
||||
AND NEW.company_id IS NOT DISTINCT FROM OLD.company_id
|
||||
AND NEW.user_id IS NOT DISTINCT FROM OLD.user_id
|
||||
AND NEW.invoice_id IS NOT DISTINCT FROM OLD.invoice_id
|
||||
AND NEW.channel IS NOT DISTINCT FROM OLD.channel
|
||||
AND NEW.status IS NOT DISTINCT FROM OLD.status
|
||||
AND cardinality(NEW.to_addresses) = 0
|
||||
AND cardinality(NEW.cc_addresses) = 0
|
||||
AND cardinality(NEW.bcc_addresses) = 0
|
||||
AND NEW.reply_to IS NULL
|
||||
AND NEW.from_name IS NULL
|
||||
AND NEW.subject IS NULL
|
||||
AND NEW.body_text IS NULL
|
||||
AND NEW.body_html IS NULL
|
||||
AND NEW.provider IS NOT DISTINCT FROM OLD.provider
|
||||
AND NEW.provider_message_id IS NULL
|
||||
AND NEW.provider_status IS NOT DISTINCT FROM OLD.provider_status
|
||||
AND NEW.provider_status_at IS NOT DISTINCT FROM OLD.provider_status_at
|
||||
AND NEW.provider_status_detail IS NULL
|
||||
AND NEW.provider_recipient_statuses = '{}'::jsonb
|
||||
AND NEW.error_code IS NOT DISTINCT FROM OLD.error_code
|
||||
AND NEW.document_attachment_id IS NOT DISTINCT FROM OLD.document_attachment_id
|
||||
AND NEW.attachment_filename IS NULL
|
||||
AND NEW.attachment_content_type IS NOT DISTINCT FROM OLD.attachment_content_type
|
||||
AND NEW.attachment_sha256 IS NULL
|
||||
AND NEW.sent_at IS NOT DISTINCT FROM OLD.sent_at
|
||||
AND NEW.failed_at IS NOT DISTINCT FROM OLD.failed_at
|
||||
AND NEW.retention_expires_at IS NOT DISTINCT FROM OLD.retention_expires_at
|
||||
AND NEW.created_at IS NOT DISTINCT FROM OLD.created_at
|
||||
THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'terminal invoice delivery (%) is immutable', OLD.status
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Recipient references stop being useful once the address arrays expire, and
|
||||
-- their keys would otherwise retain the old recipient counts. Clear them with
|
||||
-- the rest of the delivery PII while retaining only the aggregate outcome.
|
||||
CREATE OR REPLACE FUNCTION public.redact_expired_invoice_delivery_pii()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
redacted_count integer;
|
||||
BEGIN
|
||||
UPDATE public.invoice_deliveries
|
||||
SET to_addresses = '{}',
|
||||
cc_addresses = '{}',
|
||||
bcc_addresses = '{}',
|
||||
reply_to = NULL,
|
||||
from_name = NULL,
|
||||
subject = NULL,
|
||||
body_text = NULL,
|
||||
body_html = NULL,
|
||||
provider_message_id = NULL,
|
||||
provider_status_detail = NULL,
|
||||
provider_recipient_statuses = '{}'::jsonb,
|
||||
attachment_filename = NULL,
|
||||
attachment_sha256 = NULL,
|
||||
pii_redacted_at = now()
|
||||
WHERE channel = 'email'
|
||||
AND status IN ('sent', 'failed')
|
||||
AND pii_redacted_at IS NULL
|
||||
AND retention_expires_at <= CURRENT_DATE;
|
||||
|
||||
GET DIAGNOSTICS redacted_count = ROW_COUNT;
|
||||
RETURN redacted_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.redact_expired_invoice_delivery_pii() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.redact_expired_invoice_delivery_pii() TO service_role;
|
||||
|
||||
-- Apply the aggregate outcome first for backward compatibility, then merge
|
||||
-- each impacted To/CC position independently. Both layers use the same rank
|
||||
-- and provider timestamp ordering, making retries and out-of-order events safe.
|
||||
CREATE OR REPLACE FUNCTION public.apply_invoice_delivery_provider_event(
|
||||
p_provider text,
|
||||
p_provider_message_id text,
|
||||
p_status text,
|
||||
p_occurred_at timestamptz,
|
||||
p_detail text,
|
||||
p_recipient_addresses text[]
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
DECLARE
|
||||
applied_id uuid;
|
||||
target public.invoice_deliveries%ROWTYPE;
|
||||
observed_at timestamptz := COALESCE(p_occurred_at, now());
|
||||
new_rank integer;
|
||||
normalized_address text;
|
||||
recipient_address text;
|
||||
recipient_position integer;
|
||||
recipient_reference text;
|
||||
existing_outcome jsonb;
|
||||
existing_rank integer;
|
||||
existing_at timestamptz;
|
||||
next_statuses jsonb;
|
||||
BEGIN
|
||||
applied_id := public.apply_invoice_delivery_provider_status(
|
||||
p_provider,
|
||||
p_provider_message_id,
|
||||
p_status,
|
||||
observed_at,
|
||||
p_detail
|
||||
);
|
||||
|
||||
IF applied_id IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
SELECT * INTO STRICT target
|
||||
FROM public.invoice_deliveries AS delivery
|
||||
WHERE delivery.id = applied_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF p_recipient_addresses IS NULL OR cardinality(p_recipient_addresses) = 0 THEN
|
||||
RETURN applied_id;
|
||||
END IF;
|
||||
|
||||
new_rank := public.invoice_delivery_provider_status_rank(p_status);
|
||||
next_statuses := target.provider_recipient_statuses;
|
||||
|
||||
FOREACH recipient_address IN ARRAY p_recipient_addresses
|
||||
LOOP
|
||||
normalized_address := lower(btrim(recipient_address));
|
||||
IF normalized_address IS NULL
|
||||
OR normalized_address = ''
|
||||
OR length(normalized_address) > 320
|
||||
THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
FOR recipient_reference, recipient_position IN
|
||||
SELECT 'to:' || positions.position, positions.position
|
||||
FROM generate_subscripts(target.to_addresses, 1) AS positions(position)
|
||||
WHERE lower(btrim(target.to_addresses[positions.position])) = normalized_address
|
||||
UNION ALL
|
||||
SELECT 'cc:' || positions.position, positions.position
|
||||
FROM generate_subscripts(target.cc_addresses, 1) AS positions(position)
|
||||
WHERE lower(btrim(target.cc_addresses[positions.position])) = normalized_address
|
||||
LOOP
|
||||
existing_outcome := next_statuses -> recipient_reference;
|
||||
existing_rank := public.invoice_delivery_provider_status_rank(
|
||||
existing_outcome ->> 'status'
|
||||
);
|
||||
existing_at := NULLIF(existing_outcome ->> 'status_at', '')::timestamptz;
|
||||
|
||||
IF new_rank < existing_rank
|
||||
OR (new_rank = existing_rank AND existing_at IS NOT NULL AND observed_at <= existing_at)
|
||||
THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
next_statuses := jsonb_set(
|
||||
next_statuses,
|
||||
ARRAY[recipient_reference],
|
||||
jsonb_build_object('status', p_status, 'status_at', observed_at),
|
||||
true
|
||||
);
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
IF next_statuses IS DISTINCT FROM target.provider_recipient_statuses THEN
|
||||
UPDATE public.invoice_deliveries
|
||||
SET provider_recipient_statuses = next_statuses
|
||||
WHERE id = applied_id;
|
||||
END IF;
|
||||
|
||||
RETURN applied_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.apply_invoice_delivery_provider_event(
|
||||
text, text, text, timestamptz, text, text[]
|
||||
) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.apply_invoice_delivery_provider_event(
|
||||
text, text, text, timestamptz, text, text[]
|
||||
) TO service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.apply_invoice_delivery_provider_event(
|
||||
text, text, text, timestamptz, text, text[]
|
||||
) IS
|
||||
'Applies one signed provider event to aggregate invoice delivery state and independently to matching immutable To/CC positions. Service role only; BCC and unknown recipients are ignored.';
|
||||
|
||||
-- Both read surfaces return the same minimized PII-free status map alongside
|
||||
-- their already-masked To/CC arrays.
|
||||
DROP FUNCTION IF EXISTS public.list_invoice_delivery_summaries(uuid, uuid);
|
||||
|
||||
CREATE FUNCTION public.list_invoice_delivery_summaries(
|
||||
p_company_id uuid,
|
||||
p_invoice_id uuid
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
channel text,
|
||||
status text,
|
||||
to_addresses text[],
|
||||
cc_addresses text[],
|
||||
provider text,
|
||||
provider_status text,
|
||||
provider_status_at timestamptz,
|
||||
provider_status_detail text,
|
||||
provider_recipient_statuses jsonb,
|
||||
error_code text,
|
||||
document_attachment_id uuid,
|
||||
attachment_filename text,
|
||||
sent_at timestamptz,
|
||||
failed_at timestamptz,
|
||||
created_at timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL
|
||||
OR p_company_id IS DISTINCT FROM public.current_active_company_id()
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM public.company_members AS member
|
||||
WHERE member.company_id = p_company_id AND member.user_id = auth.uid()
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'not authorized to list invoice delivery summaries'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
delivery.id,
|
||||
delivery.channel,
|
||||
delivery.status,
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(delivery.to_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(delivery.cc_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
delivery.provider,
|
||||
delivery.provider_status,
|
||||
delivery.provider_status_at,
|
||||
regexp_replace(delivery.provider_status_detail, '[A-Za-z0-9._%+-]+@', '***@', 'g'),
|
||||
delivery.provider_recipient_statuses,
|
||||
delivery.error_code,
|
||||
delivery.document_attachment_id,
|
||||
delivery.attachment_filename,
|
||||
delivery.sent_at,
|
||||
delivery.failed_at,
|
||||
delivery.created_at
|
||||
FROM public.invoice_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.invoice_id = p_invoice_id
|
||||
AND delivery.status <> 'preparing'
|
||||
ORDER BY delivery.created_at DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) TO authenticated;
|
||||
|
||||
COMMENT ON FUNCTION public.list_invoice_delivery_summaries(uuid, uuid) IS
|
||||
'Returns active-company invoice delivery status with masked To/CC addresses and PII-free recipient outcomes keyed by their positions. Exact payload and BCC remain server-side.';
|
||||
|
||||
DROP FUNCTION IF EXISTS public.list_invoice_delivery_summaries_for_service(uuid, uuid, uuid);
|
||||
|
||||
CREATE FUNCTION public.list_invoice_delivery_summaries_for_service(
|
||||
p_company_id uuid,
|
||||
p_user_id uuid,
|
||||
p_invoice_id uuid
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
channel text,
|
||||
status text,
|
||||
to_addresses text[],
|
||||
cc_addresses text[],
|
||||
provider text,
|
||||
provider_status text,
|
||||
provider_status_at timestamptz,
|
||||
provider_status_detail text,
|
||||
provider_recipient_statuses jsonb,
|
||||
error_code text,
|
||||
document_attachment_id uuid,
|
||||
attachment_filename text,
|
||||
sent_at timestamptz,
|
||||
failed_at timestamptz,
|
||||
created_at timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF auth.role() IS DISTINCT FROM 'service_role' THEN
|
||||
RAISE EXCEPTION 'invoice delivery summaries require a server-controlled service role'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF p_user_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM public.company_members AS member
|
||||
WHERE member.company_id = p_company_id AND member.user_id = p_user_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invoice delivery reader is not a company member'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
delivery.id,
|
||||
delivery.channel,
|
||||
delivery.status,
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(delivery.to_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
ARRAY(
|
||||
SELECT CASE
|
||||
WHEN recipient.address ~ '^[^@]+@[^@]+$'
|
||||
THEN '***@' || split_part(recipient.address, '@', 2)
|
||||
ELSE '***'
|
||||
END
|
||||
FROM unnest(delivery.cc_addresses) WITH ORDINALITY AS recipient(address, position)
|
||||
ORDER BY recipient.position
|
||||
),
|
||||
delivery.provider,
|
||||
delivery.provider_status,
|
||||
delivery.provider_status_at,
|
||||
regexp_replace(delivery.provider_status_detail, '[A-Za-z0-9._%+-]+@', '***@', 'g'),
|
||||
delivery.provider_recipient_statuses,
|
||||
delivery.error_code,
|
||||
delivery.document_attachment_id,
|
||||
delivery.attachment_filename,
|
||||
delivery.sent_at,
|
||||
delivery.failed_at,
|
||||
delivery.created_at
|
||||
FROM public.invoice_deliveries AS delivery
|
||||
WHERE delivery.company_id = p_company_id
|
||||
AND delivery.invoice_id = p_invoice_id
|
||||
AND delivery.status <> 'preparing'
|
||||
ORDER BY delivery.created_at DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.list_invoice_delivery_summaries_for_service(uuid, uuid, uuid)
|
||||
FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.list_invoice_delivery_summaries_for_service(uuid, uuid, uuid)
|
||||
TO service_role;
|
||||
|
||||
COMMENT ON FUNCTION public.list_invoice_delivery_summaries_for_service(uuid, uuid, uuid) IS
|
||||
'Service-role read of invoice delivery status for a named company member. Same masked To/CC and PII-free recipient outcome shape as the cookie-session function; BCC remains server-side.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -225,6 +225,7 @@ describe('list_invoice_delivery_summaries_for_service: masked result', () => {
|
||||
expect(row.provider).toBe('resend')
|
||||
expect(row.provider_status).toBe('bounced')
|
||||
expect(row.provider_status_at).not.toBeNull()
|
||||
expect(row.provider_recipient_statuses).toEqual({})
|
||||
expect(row.attachment_filename).toBe('faktura-1042.pdf')
|
||||
expect(row.sent_at).not.toBeNull()
|
||||
expect(row.failed_at).toBeNull()
|
||||
@@ -250,6 +251,7 @@ describe('list_invoice_delivery_summaries_for_service: masked result', () => {
|
||||
'provider_status',
|
||||
'provider_status_at',
|
||||
'provider_status_detail',
|
||||
'provider_recipient_statuses',
|
||||
'error_code',
|
||||
'document_attachment_id',
|
||||
'attachment_filename',
|
||||
|
||||
+18
-3
@@ -1048,9 +1048,9 @@ export type InvoiceDeliveryStatus = 'preparing' | 'pending' | 'sent' | 'failed'
|
||||
|
||||
/**
|
||||
* Delivery outcome reported by the email provider after the send itself
|
||||
* succeeded. Reported per message, never per recipient: a message with several
|
||||
* recipients gets one outcome, and the reason text names the address that
|
||||
* failed. `null` means no report has arrived yet.
|
||||
* succeeded. The delivery keeps an aggregate outcome and, when the provider
|
||||
* identifies affected recipients, outcomes keyed by stable To/CC positions.
|
||||
* `null` means no report has arrived yet.
|
||||
*/
|
||||
export type InvoiceDeliveryProviderStatus =
|
||||
| 'delayed'
|
||||
@@ -1060,6 +1060,20 @@ export type InvoiceDeliveryProviderStatus =
|
||||
| 'failed'
|
||||
| 'suppressed'
|
||||
|
||||
export interface InvoiceDeliveryRecipientStatus {
|
||||
status: InvoiceDeliveryProviderStatus
|
||||
status_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PII-free recipient references. `to:1` is the first immutable To address and
|
||||
* `cc:1` the first immutable CC address. BCC recipients are never exposed.
|
||||
*/
|
||||
export type InvoiceDeliveryRecipientStatuses = Partial<Record<
|
||||
`to:${number}` | `cc:${number}`,
|
||||
InvoiceDeliveryRecipientStatus
|
||||
>>
|
||||
|
||||
export interface InvoiceDelivery {
|
||||
id: string
|
||||
company_id: string
|
||||
@@ -1080,6 +1094,7 @@ export interface InvoiceDelivery {
|
||||
provider_status: InvoiceDeliveryProviderStatus | null
|
||||
provider_status_at: string | null
|
||||
provider_status_detail: string | null
|
||||
provider_recipient_statuses: InvoiceDeliveryRecipientStatuses
|
||||
error_code: string | null
|
||||
document_attachment_id: string | null
|
||||
attachment_filename: string | null
|
||||
|
||||
Reference in New Issue
Block a user