feat(customers): carry contact person and invoice copy recipients through migration (#1392)
* feat(customers): carry contact person and invoice copy recipients through migration Extends the arcim-migration entity mapper, Fortnox provider mapper, canonical DTOs, customer APIs (web + v1) and invoice send flows so contact person and customer-level invoice CC/BCC addresses survive provider migrations. NULL means unconfigured and empty means an explicit clear, so re-syncs enrich legacy gaps without resurrecting deliberately removed values. Fortnox fixed assets are split into a dedicated follow-up issue. Fixes #1345 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(db): bump customer metadata migration past pack-slug version Main already contains 20260803230000; keep new versions strictly newest so Supabase branching applies them in order. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(customers): complete Customer type consumers and make enrichment payload resolvable The preview-pdf mock customer and the makeCustomer fixture now carry the three new metadata fields, fixing the type-check failure in Build (zero extensions) and Vercel. The enrichment update in the migration orchestrator now spells its payload as an object literal typed CustomerMetadataEnrichment (absent keys drop at serialization), so the phantom-column guard resolves the columns instead of counting another unresolvable dynamic payload past its ceiling. The cc/bcc guards also verify element types instead of casting. 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
cb3ef45f14
commit
00ae3540db
@@ -15,6 +15,11 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, CheckCircle, XCircle, Lock } from 'lucide-react'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
EMAIL_PATTERN,
|
||||
MAX_INVOICE_EMAIL_COPY_RECIPIENTS,
|
||||
parseInvoiceRecipientText,
|
||||
} from '@/lib/invoices/email-recipients'
|
||||
import {
|
||||
PERSONAL_NUMBER_INPUT_RE,
|
||||
UNDECRYPTABLE_PERSONAL_NUMBER_MASK,
|
||||
@@ -46,8 +51,11 @@ export default function CustomerForm({
|
||||
name: z.string().min(1, t('name_required')),
|
||||
customer_type: z.enum(['individual', 'swedish_business', 'eu_business', 'non_eu_business']),
|
||||
customer_number: z.string().trim().max(32, t('customer_number_too_long')).optional(),
|
||||
contact_person: z.string().max(200, t('contact_person_too_long')).optional(),
|
||||
email: z.string().email(t('email_invalid')).optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
invoice_email_cc_addresses: z.string().optional(),
|
||||
invoice_email_bcc_addresses: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
@@ -68,6 +76,29 @@ export default function CustomerForm({
|
||||
language: z.enum(['sv', 'en']).optional(),
|
||||
default_payment_terms: z.number().min(1).optional(),
|
||||
notes: z.string().optional(),
|
||||
}).superRefine((customer, ctx) => {
|
||||
const cc = parseInvoiceRecipientText(customer.invoice_email_cc_addresses ?? '')
|
||||
const bcc = parseInvoiceRecipientText(customer.invoice_email_bcc_addresses ?? '')
|
||||
for (const [field, addresses] of [
|
||||
['invoice_email_cc_addresses', cc],
|
||||
['invoice_email_bcc_addresses', bcc],
|
||||
] as const) {
|
||||
const invalid = addresses.find((address) => !EMAIL_PATTERN.test(address))
|
||||
if (invalid) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: [field],
|
||||
message: t('invoice_email_invalid', { address: invalid }),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (cc.length + bcc.length > MAX_INVOICE_EMAIL_COPY_RECIPIENTS) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['invoice_email_cc_addresses'],
|
||||
message: t('invoice_email_too_many', { count: MAX_INVOICE_EMAIL_COPY_RECIPIENTS }),
|
||||
})
|
||||
}
|
||||
}), [t])
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
@@ -84,8 +115,11 @@ export default function CustomerForm({
|
||||
name: initialData?.name || '',
|
||||
customer_type: initialData?.customer_type || 'swedish_business',
|
||||
customer_number: initialData?.customer_number || '',
|
||||
contact_person: initialData?.contact_person ?? '',
|
||||
email: initialData?.email || '',
|
||||
phone: initialData?.phone || '',
|
||||
invoice_email_cc_addresses: initialData?.invoice_email_cc_addresses?.join('\n') ?? '',
|
||||
invoice_email_bcc_addresses: initialData?.invoice_email_bcc_addresses?.join('\n') ?? '',
|
||||
address_line1: initialData?.address_line1 || '',
|
||||
postal_code: initialData?.postal_code || '',
|
||||
city: initialData?.city || '',
|
||||
@@ -161,10 +195,25 @@ export default function CustomerForm({
|
||||
}
|
||||
|
||||
const onFormSubmit = (data: FormData) => {
|
||||
const {
|
||||
invoice_email_cc_addresses: ccText,
|
||||
invoice_email_bcc_addresses: bccText,
|
||||
...customerData
|
||||
} = data
|
||||
const isEditing = initialData !== undefined
|
||||
const payload: CreateCustomerInput = {
|
||||
...data,
|
||||
...customerData,
|
||||
// NULL means never configured and lets a migration enrich the row.
|
||||
// Empty values on an existing row are explicit clears and survive sync.
|
||||
contact_person: data.contact_person?.trim() || (isEditing ? '' : null),
|
||||
email: data.email || undefined,
|
||||
personal_number: data.personal_number || null,
|
||||
invoice_email_cc_addresses: ccText
|
||||
? parseInvoiceRecipientText(ccText)
|
||||
: isEditing ? [] : null,
|
||||
invoice_email_bcc_addresses: bccText
|
||||
? parseInvoiceRecipientText(bccText)
|
||||
: isEditing ? [] : null,
|
||||
}
|
||||
// A mask means "unchanged", whichever form it is. Sending it would be
|
||||
// harmless (the route ignores masks too) but omitting it keeps the intent
|
||||
@@ -231,29 +280,74 @@ export default function CustomerForm({
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('email_label')}</Label>
|
||||
<Label htmlFor="contact_person">{t('contact_person_label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t('email_placeholder')}
|
||||
{...register('email')}
|
||||
id="contact_person"
|
||||
placeholder={t('contact_person_placeholder')}
|
||||
{...register('contact_person')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
{errors.contact_person && (
|
||||
<p className="text-sm text-destructive">{errors.contact_person.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">{t('phone_label')}</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder={t('phone_placeholder')}
|
||||
{...register('phone')}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('email_label')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t('email_placeholder')}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">{t('phone_label')}</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder={t('phone_placeholder')}
|
||||
{...register('phone')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer-specific invoice recipients */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t('invoice_email_section')}</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_email_cc_addresses">{t('invoice_email_cc_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_email_cc_addresses"
|
||||
rows={3}
|
||||
placeholder={t('invoice_email_placeholder')}
|
||||
{...register('invoice_email_cc_addresses')}
|
||||
/>
|
||||
{errors.invoice_email_cc_addresses && (
|
||||
<p className="text-sm text-destructive">{errors.invoice_email_cc_addresses.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_email_bcc_addresses">{t('invoice_email_bcc_label')}</Label>
|
||||
<Textarea
|
||||
id="invoice_email_bcc_addresses"
|
||||
rows={3}
|
||||
placeholder={t('invoice_email_placeholder')}
|
||||
{...register('invoice_email_bcc_addresses')}
|
||||
/>
|
||||
{errors.invoice_email_bcc_addresses && (
|
||||
<p className="text-sm text-destructive">{errors.invoice_email_bcc_addresses.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('invoice_email_hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">{t('address_section')}</h3>
|
||||
|
||||
@@ -89,7 +89,7 @@ interface SkipReasons {
|
||||
|
||||
interface MigrationResults {
|
||||
companyInfo?: { imported: boolean }
|
||||
customers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
|
||||
customers?: { total: number; imported: number; updated?: number; skipped: number; skipReasons?: SkipReasons }
|
||||
suppliers?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
|
||||
salesInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
|
||||
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons }
|
||||
@@ -1497,7 +1497,7 @@ function ResultStep({
|
||||
// Check if anything meaningful was imported via entities
|
||||
// Company info is always re-fetched (upsert) so it doesn't count as "new"
|
||||
const entityImported = results && (
|
||||
(results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)) ||
|
||||
(results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 0 || results.customers.skipped > 0)) ||
|
||||
(results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)) ||
|
||||
(results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)) ||
|
||||
(results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0))
|
||||
@@ -1559,7 +1559,7 @@ function ResultStep({
|
||||
{/* ── API import results (company info, customers, etc.) ── */}
|
||||
{results && (() => {
|
||||
const hasCompanyInfo = results.companyInfo?.imported
|
||||
const hasCustomers = results.customers && (results.customers.imported > 0 || results.customers.skipped > 0)
|
||||
const hasCustomers = results.customers && (results.customers.imported > 0 || (results.customers.updated ?? 0) > 0 || results.customers.skipped > 0)
|
||||
const hasSuppliers = results.suppliers && (results.suppliers.imported > 0 || results.suppliers.skipped > 0)
|
||||
const hasSalesInvoices = results.salesInvoices && (results.salesInvoices.imported > 0 || results.salesInvoices.skipped > 0)
|
||||
const hasSupplierInvoices = results.supplierInvoices && (results.supplierInvoices.imported > 0 || results.supplierInvoices.skipped > 0)
|
||||
@@ -1587,7 +1587,9 @@ function ResultStep({
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Kunder"
|
||||
status="success"
|
||||
statusText={`${results.customers!.imported} importerade`}
|
||||
statusText={results.customers!.updated
|
||||
? `${results.customers!.imported} importerade, ${results.customers!.updated} kompletterade`
|
||||
: `${results.customers!.imported} importerade`}
|
||||
detail={results.customers!.skipped > 0 ? formatSkipReasons(results.customers!.skipReasons, 'customer') ?? `${results.customers!.skipped} hoppades över` : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -178,11 +178,7 @@ export default function SendInvoiceDialog({
|
||||
settingsResult.data?.invoice_email_cc_addresses
|
||||
?? (legacyCc ? [legacyCc] : []),
|
||||
)
|
||||
setFixedBcc(
|
||||
canCustomizeRecipients
|
||||
? settingsResult.data?.invoice_email_bcc_addresses ?? []
|
||||
: [],
|
||||
)
|
||||
setFixedBcc(settingsResult.data?.invoice_email_bcc_addresses ?? [])
|
||||
setPeriodName(periodResult.data?.name || '')
|
||||
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
|
||||
setShouldBookOnIssue(bookOnIssue)
|
||||
@@ -235,10 +231,19 @@ export default function SendInvoiceDialog({
|
||||
)
|
||||
const invalidAdditionalRecipient = [...additionalCc, ...additionalBcc]
|
||||
.find((address) => !EMAIL_PATTERN.test(address))
|
||||
const fixedRecipients = resolveInvoiceEmailRecipients({
|
||||
to: invoice.customer.email ?? '',
|
||||
configuredCc: fixedCc,
|
||||
configuredBcc: fixedBcc,
|
||||
customerCc: invoice.customer.invoice_email_cc_addresses,
|
||||
customerBcc: invoice.customer.invoice_email_bcc_addresses,
|
||||
})
|
||||
const resolvedRecipients = resolveInvoiceEmailRecipients({
|
||||
to: invoice.customer.email ?? '',
|
||||
configuredCc: fixedCc,
|
||||
configuredBcc: fixedBcc,
|
||||
customerCc: invoice.customer.invoice_email_cc_addresses,
|
||||
customerBcc: invoice.customer.invoice_email_bcc_addresses,
|
||||
additionalCc,
|
||||
additionalBcc,
|
||||
})
|
||||
@@ -494,12 +499,12 @@ export default function SendInvoiceDialog({
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t('recipient_fixed_cc_label')}:</span>{' '}
|
||||
{fixedCc.length > 0 ? fixedCc.join(', ') : t('recipient_none')}
|
||||
{fixedRecipients.cc.length > 0 ? fixedRecipients.cc.join(', ') : t('recipient_none')}
|
||||
</p>
|
||||
{canCustomizeRecipients && (
|
||||
<p className="text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t('recipient_fixed_bcc_label')}:</span>{' '}
|
||||
{fixedBcc.length > 0 ? fixedBcc.join(', ') : t('recipient_none')}
|
||||
{fixedRecipients.bcc.length > 0 ? fixedRecipients.bcc.join(', ') : t('recipient_none')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user