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
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildCustomerMetadataEnrichment } from '../customer-metadata'
|
||||
|
||||
describe('buildCustomerMetadataEnrichment', () => {
|
||||
const mapped = {
|
||||
contact_person: 'Anna Andersson',
|
||||
invoice_email_cc_addresses: ['finance@example.test'],
|
||||
invoice_email_bcc_addresses: ['archive@example.test'],
|
||||
}
|
||||
|
||||
it('fills metadata that was never configured', () => {
|
||||
expect(buildCustomerMetadataEnrichment({
|
||||
contact_person: null,
|
||||
invoice_email_cc_addresses: null,
|
||||
invoice_email_bcc_addresses: null,
|
||||
}, mapped)).toEqual(mapped)
|
||||
})
|
||||
|
||||
it('does not overwrite existing values or explicit clears', () => {
|
||||
expect(buildCustomerMetadataEnrichment({
|
||||
contact_person: '',
|
||||
invoice_email_cc_addresses: [],
|
||||
invoice_email_bcc_addresses: ['manual@example.test'],
|
||||
}, mapped)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not turn unknown provider metadata into explicit empty values', () => {
|
||||
expect(buildCustomerMetadataEnrichment({
|
||||
contact_person: null,
|
||||
invoice_email_cc_addresses: null,
|
||||
invoice_email_bcc_addresses: null,
|
||||
}, {
|
||||
contact_person: null,
|
||||
invoice_email_cc_addresses: [],
|
||||
invoice_email_bcc_addresses: null,
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CustomerDto } from '@/lib/providers/dto'
|
||||
import { mapCustomer } from '../entity-mapper'
|
||||
|
||||
describe('Arcim customer metadata mapping', () => {
|
||||
it('maps contact person and customer-specific invoice recipients', () => {
|
||||
const dto: CustomerDto = {
|
||||
id: 'customer-1',
|
||||
customerNumber: '1001',
|
||||
type: 'company',
|
||||
party: {
|
||||
name: 'Kund AB',
|
||||
identifications: [{ id: '556677-8899', schemeId: 'SE:ORGNR' }],
|
||||
contact: {
|
||||
name: 'Anna Andersson',
|
||||
email: 'invoice@example.test',
|
||||
},
|
||||
},
|
||||
invoiceEmailCcAddresses: ['finance@example.test'],
|
||||
invoiceEmailBccAddresses: ['archive@example.test'],
|
||||
active: true,
|
||||
}
|
||||
|
||||
expect(mapCustomer(dto, 'user-1', 'company-1')).toMatchObject({
|
||||
contact_person: 'Anna Andersson',
|
||||
email: 'invoice@example.test',
|
||||
invoice_email_cc_addresses: ['finance@example.test'],
|
||||
invoice_email_bcc_addresses: ['archive@example.test'],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses null for provider fields that were not supplied', () => {
|
||||
const dto: CustomerDto = {
|
||||
id: 'customer-2',
|
||||
customerNumber: '1002',
|
||||
party: { name: 'Kund Två AB', identifications: [] },
|
||||
active: true,
|
||||
}
|
||||
|
||||
expect(mapCustomer(dto, 'user-1', 'company-1')).toMatchObject({
|
||||
contact_person: null,
|
||||
invoice_email_cc_addresses: null,
|
||||
invoice_email_bcc_addresses: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface ExistingCustomerMetadata {
|
||||
contact_person: string | null
|
||||
invoice_email_cc_addresses: string[] | null
|
||||
invoice_email_bcc_addresses: string[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys restricted to the three metadata columns so callers can spell the
|
||||
* update payload as an object literal (absent keys stay undefined and are
|
||||
* dropped at serialization, leaving those columns untouched).
|
||||
*/
|
||||
export interface CustomerMetadataEnrichment {
|
||||
contact_person?: string
|
||||
invoice_email_cc_addresses?: string[]
|
||||
invoice_email_bcc_addresses?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a provider-migration enrichment without overwriting user choices.
|
||||
*
|
||||
* NULL is the only "never configured" value. Empty strings/arrays are explicit
|
||||
* clears, so a later migration rerun leaves them alone. The mapped row must
|
||||
* also contain real metadata: converting NULL to an empty value is not useful.
|
||||
*/
|
||||
export function buildCustomerMetadataEnrichment(
|
||||
existing: ExistingCustomerMetadata,
|
||||
mapped: Record<string, unknown>,
|
||||
): CustomerMetadataEnrichment | null {
|
||||
const changes: CustomerMetadataEnrichment = {}
|
||||
const contactPerson = mapped.contact_person
|
||||
const cc = mapped.invoice_email_cc_addresses
|
||||
const bcc = mapped.invoice_email_bcc_addresses
|
||||
|
||||
if (
|
||||
existing.contact_person === null
|
||||
&& typeof contactPerson === 'string'
|
||||
&& contactPerson.trim().length > 0
|
||||
) {
|
||||
changes.contact_person = contactPerson
|
||||
}
|
||||
if (
|
||||
existing.invoice_email_cc_addresses === null
|
||||
&& Array.isArray(cc)
|
||||
&& cc.length > 0
|
||||
&& cc.every((x): x is string => typeof x === 'string')
|
||||
) {
|
||||
changes.invoice_email_cc_addresses = cc
|
||||
}
|
||||
if (
|
||||
existing.invoice_email_bcc_addresses === null
|
||||
&& Array.isArray(bcc)
|
||||
&& bcc.length > 0
|
||||
&& bcc.every((x): x is string => typeof x === 'string')
|
||||
) {
|
||||
changes.invoice_email_bcc_addresses = bcc
|
||||
}
|
||||
|
||||
return Object.keys(changes).length > 0 ? changes : null
|
||||
}
|
||||
@@ -440,8 +440,11 @@ export function mapCustomer(dto: CustomerDto, userId: string, companyId: string)
|
||||
company_id: companyId,
|
||||
name: dto.party.name,
|
||||
customer_type: customerType,
|
||||
contact_person: dto.party.contact?.name || null,
|
||||
email: dto.party.contact?.email || null,
|
||||
phone: dto.party.contact?.telephone || null,
|
||||
invoice_email_cc_addresses: dto.invoiceEmailCcAddresses ?? null,
|
||||
invoice_email_bcc_addresses: dto.invoiceEmailBccAddresses ?? null,
|
||||
...addr,
|
||||
org_number: isIndividual ? null : number,
|
||||
personal_number: isIndividual ? encryptCustomerPersonalNumber(number) : null,
|
||||
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { reconcileSupplierInvoiceVouchers } from '@/lib/invoices/bulk-reconcile-supplier-vouchers'
|
||||
import {
|
||||
buildCustomerMetadataEnrichment,
|
||||
type CustomerMetadataEnrichment,
|
||||
type ExistingCustomerMetadata,
|
||||
} from './customer-metadata'
|
||||
import {
|
||||
mapCustomer,
|
||||
mapSupplier,
|
||||
@@ -66,6 +71,7 @@ export interface MigrationOptions {
|
||||
* PostgREST's practical size limit while minimising round-trips.
|
||||
*/
|
||||
const INSERT_CHUNK_SIZE = 500
|
||||
const ENRICHMENT_CONCURRENCY = 10
|
||||
|
||||
function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
|
||||
options.onProgress?.(progress)
|
||||
@@ -188,20 +194,27 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
const customers = await fetchCustomersDirect(provider, accessToken, providerCompanyId)
|
||||
|
||||
// One bulk read instead of N `.eq('org_number', ...)` lookups.
|
||||
const existingCustomers = await fetchAllRows<{ id: string; org_number: string | null; name: string | null }>(
|
||||
type ExistingCustomer = ExistingCustomerMetadata & {
|
||||
id: string
|
||||
org_number: string | null
|
||||
name: string | null
|
||||
}
|
||||
const existingCustomers = await fetchAllRows<ExistingCustomer>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('customers')
|
||||
.select('id, org_number, name')
|
||||
.select('id, org_number, name, contact_person, invoice_email_cc_addresses, invoice_email_bcc_addresses')
|
||||
.eq('company_id', companyId)
|
||||
.range(from, to)
|
||||
)
|
||||
const existingCustomerById = new Map(existingCustomers.map((row) => [row.id, row]))
|
||||
for (const row of existingCustomers) {
|
||||
if (row.org_number) orgNumberToCustomerId.set(row.org_number, row.id)
|
||||
if (row.name) nameToCustomerId.set(row.name, row.id)
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
const skipReasons: SkipReasons = {}
|
||||
|
||||
@@ -210,6 +223,7 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
row: Record<string, unknown>
|
||||
}
|
||||
const pending: PendingCustomer[] = []
|
||||
const pendingEnrichments: { id: string; changes: CustomerMetadataEnrichment }[] = []
|
||||
|
||||
for (const customer of customers) {
|
||||
if (!customer.active) {
|
||||
@@ -230,8 +244,17 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
: undefined
|
||||
if (existingCustomerId) {
|
||||
customerIdMap.set(customer.id, existingCustomerId)
|
||||
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
||||
skipped++
|
||||
const existingCustomer = existingCustomerById.get(existingCustomerId)
|
||||
const mapped = mapCustomer(customer, userId, companyId)
|
||||
const changes = existingCustomer
|
||||
? buildCustomerMetadataEnrichment(existingCustomer, mapped)
|
||||
: null
|
||||
if (changes) {
|
||||
pendingEnrichments.push({ id: existingCustomerId, changes })
|
||||
} else {
|
||||
skipReasons.duplicate = (skipReasons.duplicate ?? 0) + 1
|
||||
skipped++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -265,7 +288,41 @@ export async function executeMigration(options: MigrationOptions): Promise<Migra
|
||||
}
|
||||
}
|
||||
|
||||
results.customers = { total: customers.length, imported, skipped, skipReasons }
|
||||
// A rerun can match hundreds of legacy customers. Update only rows
|
||||
// that actually have new provider metadata, with bounded concurrency,
|
||||
// so enrichment neither overwrites edits nor serializes the migration.
|
||||
for (const batch of chunk(pendingEnrichments, ENRICHMENT_CONCURRENCY)) {
|
||||
const outcomes = await Promise.all(batch.map(async ({ id, changes }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
// Object literal, not the record itself: absent keys serialize
|
||||
// away, and the phantom-column guard can resolve the columns.
|
||||
.update({
|
||||
contact_person: changes.contact_person,
|
||||
invoice_email_cc_addresses: changes.invoice_email_cc_addresses,
|
||||
invoice_email_bcc_addresses: changes.invoice_email_bcc_addresses,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id')
|
||||
.maybeSingle()
|
||||
return { data, error }
|
||||
}))
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.error || !outcome.data) {
|
||||
if (outcome.error) {
|
||||
console.error('[migration] Customer metadata enrichment failed:', outcome.error.message)
|
||||
}
|
||||
skipReasons.failed = (skipReasons.failed ?? 0) + 1
|
||||
skipped++
|
||||
} else {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.customers = { total: customers.length, imported, updated, skipped, skipReasons }
|
||||
} catch (err) {
|
||||
console.error('Failed to import customers:', err)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface SkipReasons {
|
||||
*/
|
||||
export 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; fxUnresolved?: number }
|
||||
supplierInvoices?: { total: number; imported: number; skipped: number; skipReasons?: SkipReasons; fxUnresolved?: number }
|
||||
|
||||
Reference in New Issue
Block a user