fix(customers): normalize structured ai product values
This commit is contained in:
+44
-7
@@ -759,8 +759,14 @@ async function analyzeAndPersistConversation(tenantId, conversationId) {
|
||||
...customer,
|
||||
company: String(result.aiProfile?.companyName || '').trim() || customer.company,
|
||||
language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language,
|
||||
products: Array.from(new Set([...(customer.products || []), ...(result.products || [])])),
|
||||
tags: Array.from(new Set([...(customer.tags || []), ...(result.tags || [])])),
|
||||
products: Array.from(new Set(normalizedTextList(
|
||||
[...(customer.products || []), ...(Array.isArray(result.products) ? result.products : [])],
|
||||
['name', 'product', 'category'],
|
||||
))),
|
||||
tags: Array.from(new Set(normalizedTextList(
|
||||
[...(customer.tags || []), ...(Array.isArray(result.tags) ? result.tags : [])],
|
||||
['label', 'name', 'category'],
|
||||
))),
|
||||
intentScore,
|
||||
intentLevel: result.intentLevel || customer.intentLevel || 'low',
|
||||
stage: result.stage || customer.stage || 'initial',
|
||||
@@ -777,7 +783,7 @@ async function analyzeAndPersistConversation(tenantId, conversationId) {
|
||||
? result.aiProfile.relationshipEvidence.map(String).filter(Boolean).slice(0, 3)
|
||||
: [],
|
||||
summary: String(result.summary || ''),
|
||||
nextActions: Array.isArray(result.nextActions) ? result.nextActions : [],
|
||||
nextActions: normalizedTextList(result.nextActions, ['action', 'text', 'label', 'name']),
|
||||
profileVersion: 3,
|
||||
},
|
||||
aiAnalyzedAt: new Date().toISOString(),
|
||||
@@ -1125,6 +1131,18 @@ const kinds = {
|
||||
customers: 'customers', opportunities: 'opportunities', tasks: 'tasks', risks: 'risks',
|
||||
knowledge: 'knowledge',
|
||||
}
|
||||
function normalizedTextList(values, preferredKeys = []) {
|
||||
if (!Array.isArray(values)) return []
|
||||
return values.map((item) => {
|
||||
if (typeof item === 'string' || typeof item === 'number') return String(item).trim()
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
for (const key of preferredKeys) {
|
||||
const value = item[key]
|
||||
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
|
||||
}
|
||||
return ''
|
||||
}).filter(Boolean)
|
||||
}
|
||||
function withDefaults(kind, body) {
|
||||
if (kind === 'customers') {
|
||||
const source = body && typeof body === 'object' ? body : {}
|
||||
@@ -1135,15 +1153,15 @@ function withDefaults(kind, body) {
|
||||
valueLevel: 'D', isKey: false, ownerId: null,
|
||||
aiProfile: { summary: '', keyPoints: [], nextActions: [] },
|
||||
...source,
|
||||
products: Array.isArray(source.products) ? source.products : [],
|
||||
tags: Array.isArray(source.tags) ? source.tags : [],
|
||||
products: normalizedTextList(source.products, ['name', 'product', 'category']),
|
||||
tags: normalizedTextList(source.tags, ['label', 'name', 'category']),
|
||||
aiProfile: {
|
||||
summary: '',
|
||||
keyPoints: [],
|
||||
nextActions: [],
|
||||
...(source.aiProfile && typeof source.aiProfile === 'object' ? source.aiProfile : {}),
|
||||
keyPoints: Array.isArray(source.aiProfile?.keyPoints) ? source.aiProfile.keyPoints : [],
|
||||
nextActions: Array.isArray(source.aiProfile?.nextActions) ? source.aiProfile.nextActions : [],
|
||||
keyPoints: normalizedTextList(source.aiProfile?.keyPoints, ['text', 'label', 'name']),
|
||||
nextActions: normalizedTextList(source.aiProfile?.nextActions, ['action', 'text', 'label', 'name']),
|
||||
},
|
||||
}
|
||||
return {
|
||||
@@ -1155,10 +1173,26 @@ function withDefaults(kind, body) {
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function normalizeLegacyCustomerResources() {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT tenant_id, data FROM resources WHERE kind='customers'",
|
||||
)
|
||||
let updated = 0
|
||||
for (const row of rows) {
|
||||
const current = row.data
|
||||
const normalized = withDefaults('customers', current)
|
||||
if (JSON.stringify(current) === JSON.stringify(normalized)) continue
|
||||
await putResource(String(row.tenant_id), 'customers', normalized)
|
||||
updated++
|
||||
}
|
||||
if (updated) console.log(`Normalized ${updated} legacy customer records`)
|
||||
}
|
||||
for (const [path, kind] of Object.entries(kinds)) {
|
||||
app.get(`/api/${path}`, async (req, res) => {
|
||||
let list = await listResources(req.auth.tenantId, kind)
|
||||
if (kind === 'customers') {
|
||||
list = list.map((customer) => withDefaults('customers', customer))
|
||||
list = await Promise.all(list.map(async (customer) => {
|
||||
const country = resolveCustomerCountry(customer, customer)
|
||||
if (
|
||||
@@ -5650,6 +5684,9 @@ async function runMessagingIntegrityMaintenance() {
|
||||
if (weeklyDue) await reconcileConnectedMessagingChannels({ full: true, refreshProfiles: true })
|
||||
}
|
||||
|
||||
setTimeout(() => void normalizeLegacyCustomerResources().catch((error) => {
|
||||
console.error(JSON.stringify({ message: 'Customer shape normalization failed', error: error.message }))
|
||||
}), 1_000).unref?.()
|
||||
setTimeout(() => void reconcileConnectedMessagingChannels(), 15_000).unref?.()
|
||||
setInterval(
|
||||
() => void reconcileConnectedMessagingChannels(),
|
||||
|
||||
@@ -31,6 +31,7 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
|
||||
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { formatProductName } from '@/utils/format'
|
||||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||||
|
||||
@@ -151,8 +152,8 @@ export function InsightPanel({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{customer.products?.map((p) => (
|
||||
<Badge key={p} variant="primary">{p}</Badge>
|
||||
{customer.products?.map((p, index) => (
|
||||
<Badge key={`${formatProductName(p)}-${index}`} variant="primary">{formatProductName(p)}</Badge>
|
||||
))}
|
||||
{customer.tags?.map((t) => (
|
||||
<CustomerSemanticTag key={t} tag={t} />
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
INTENT_LEVELS,
|
||||
} from '@/config/constants'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { formatProductName } from '@/utils/format'
|
||||
import type { Customer } from '@/types'
|
||||
import type { ChannelType } from '@/types/messaging'
|
||||
|
||||
@@ -285,7 +286,7 @@ export function CustomersPage() {
|
||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
|
||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
|
||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
|
||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td>
|
||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{formatProductName(customer.products?.[0]) || '—'}</span></Td>
|
||||
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
|
||||
<Td><CustomerStageBadge stage={customer.stage} /></Td>
|
||||
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
|
||||
@@ -333,8 +334,8 @@ export function CustomersPage() {
|
||||
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
|
||||
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
|
||||
</div>
|
||||
{customer.products.length > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p>
|
||||
{customer.products?.length > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground truncate">{formatProductName(customer.products[0])}</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useAuthStore } from '@/app/store/auth'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { getPlan } from '@/config/pricingPlans'
|
||||
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
|
||||
import { formatDateTime, formatRelative } from '@/utils/format'
|
||||
import { formatDateTime, formatProductName, formatRelative } from '@/utils/format'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
||||
import { Card, CardHeader } from '@/components/ui/card'
|
||||
@@ -235,7 +235,7 @@ export function DashboardPage() {
|
||||
<CountryFlag code={c.country} withName={false} />
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{c.products?.[0] ?? '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
|
||||
{formatProductName(c.products?.[0]) || '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
|
||||
@@ -71,3 +71,15 @@ export function localTimeByOffset(offsetHours: number): string {
|
||||
const target = new Date(utc + offsetHours * 3600000)
|
||||
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
|
||||
}
|
||||
|
||||
export function formatProductName(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
|
||||
if (!value || typeof value !== 'object') return ''
|
||||
const product = value as Record<string, unknown>
|
||||
for (const key of ['name', 'product', 'category']) {
|
||||
if (typeof product[key] === 'string' || typeof product[key] === 'number') {
|
||||
return String(product[key]).trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user