cd7d7f52b9
* 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>
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useFormatter, useTranslations } from 'next-intl'
|
|
import { ExternalLink, Mail, Send } from 'lucide-react'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
|
|
|
|
export type InvoiceDeliveryView = Pick<
|
|
InvoiceDelivery,
|
|
| 'id'
|
|
| 'channel'
|
|
| 'to_addresses'
|
|
| 'cc_addresses'
|
|
| 'provider'
|
|
| 'provider_status'
|
|
| 'provider_status_at'
|
|
| 'provider_status_detail'
|
|
| 'provider_recipient_statuses'
|
|
| 'error_code'
|
|
| 'document_attachment_id'
|
|
| 'attachment_filename'
|
|
| 'sent_at'
|
|
| 'failed_at'
|
|
| 'created_at'
|
|
> & {
|
|
status: 'pending' | 'sent' | 'failed' | 'marked_sent'
|
|
}
|
|
|
|
interface InvoiceDeliveryHistoryProps {
|
|
deliveries: InvoiceDeliveryView[]
|
|
showLegacyEmptyState: boolean
|
|
}
|
|
|
|
/**
|
|
* What the row actually says happened. The send status alone stops at
|
|
* "handed to the provider", which is why an accepted-but-bounced invoice used
|
|
* to read as a plain success. When the provider has reported back, its
|
|
* outcome is what the row shows.
|
|
*/
|
|
type DeliveryOutcome =
|
|
| 'pending'
|
|
| 'sent'
|
|
| 'failed'
|
|
| 'marked_sent'
|
|
| InvoiceDeliveryProviderStatus
|
|
|
|
function outcomeOf(delivery: InvoiceDeliveryView): DeliveryOutcome {
|
|
if (delivery.status === 'sent' && delivery.provider_status) return delivery.provider_status
|
|
return delivery.status
|
|
}
|
|
|
|
// Green is reserved for a confirmed arrival. "Skickad" without a delivery
|
|
// report is a neutral, honest in-between state.
|
|
const outcomeVariant: Record<DeliveryOutcome, 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
|
|
pending: 'secondary',
|
|
sent: 'secondary',
|
|
delivered: 'success',
|
|
delayed: 'warning',
|
|
complained: 'warning',
|
|
bounced: 'destructive',
|
|
failed: 'destructive',
|
|
suppressed: 'destructive',
|
|
marked_sent: 'outline',
|
|
}
|
|
|
|
export function InvoiceDeliveryHistory({
|
|
deliveries,
|
|
showLegacyEmptyState,
|
|
}: InvoiceDeliveryHistoryProps) {
|
|
const t = useTranslations('invoice_detail')
|
|
const format = useFormatter()
|
|
|
|
if (deliveries.length === 0 && !showLegacyEmptyState) return null
|
|
|
|
const formatTimestamp = (value: string) =>
|
|
format.dateTime(new Date(value), {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
})
|
|
|
|
return (
|
|
<Card className="lg:col-span-2">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Mail className="h-5 w-5" />
|
|
{t('delivery_history_title')}
|
|
</CardTitle>
|
|
<CardDescription>{t('delivery_history_description')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{deliveries.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
<p className="font-medium text-foreground">{t('delivery_history_legacy_title')}</p>
|
|
<p className="mt-1">{t('delivery_history_legacy_description')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{deliveries.map((delivery) => {
|
|
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
|
|
const isManual = delivery.channel === 'manual'
|
|
const outcome = outcomeOf(delivery)
|
|
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">
|
|
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden">
|
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
|
{isManual ? <Send className="h-4 w-4" /> : <Mail className="h-4 w-4" />}
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-sm font-medium">
|
|
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
|
|
</span>
|
|
<span className="block text-xs text-muted-foreground tabular-nums">
|
|
{formatTimestamp(occurredAt)}
|
|
</span>
|
|
</span>
|
|
<Badge variant={outcomeVariant[outcome]}>
|
|
{t(`delivery_status_${outcome}`)}
|
|
</Badge>
|
|
</summary>
|
|
|
|
<div className="px-4 pb-4">
|
|
<Separator className="mb-4" />
|
|
{isManual ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('delivery_manual_unknown_details')}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-4 text-sm">
|
|
<dl className="grid gap-3 sm:grid-cols-[8rem_minmax(0,1fr)]">
|
|
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
|
|
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
|
|
{delivery.cc_addresses.length > 0 && (
|
|
<>
|
|
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
|
|
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
|
|
{isEmailSend && (
|
|
<div className="rounded-lg border bg-muted/30 p-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium">
|
|
{t('delivery_provider_status_label')}
|
|
</span>
|
|
<span>{t(`delivery_status_${outcome}`)}</span>
|
|
{delivery.provider_status_at && (
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
{formatTimestamp(delivery.provider_status_at)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{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
|
|
&& !hasRecipientStatuses && (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
{t('delivery_status_whole_send_note')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{delivery.document_attachment_id && (
|
|
<Button asChild variant="outline" size="sm" className="max-w-full">
|
|
<a
|
|
href={`/api/documents/${delivery.document_attachment_id}/inline`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<ExternalLink className="mr-2 h-4 w-4" />
|
|
<span className="truncate">
|
|
{t('delivery_open_pdf', {
|
|
filename: delivery.attachment_filename || t('delivery_pdf_fallback'),
|
|
})}
|
|
</span>
|
|
</a>
|
|
</Button>
|
|
)}
|
|
|
|
{(delivery.provider || delivery.error_code) && (
|
|
<dl className="grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-[8rem_minmax(0,1fr)]">
|
|
{delivery.provider && (
|
|
<>
|
|
<dt>{t('delivery_provider_label')}</dt>
|
|
<dd>{delivery.provider}</dd>
|
|
</>
|
|
)}
|
|
{delivery.error_code && (
|
|
<>
|
|
<dt>{t('delivery_error_label')}</dt>
|
|
<dd>{delivery.error_code}</dd>
|
|
</>
|
|
)}
|
|
</dl>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|