diff --git a/server/src/index.js b/server/src/index.js index 479a4ee..32b76c9 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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(), diff --git a/src/components/inbox/InsightPanel.tsx b/src/components/inbox/InsightPanel.tsx index 4ff3074..4962857 100644 --- a/src/components/inbox/InsightPanel.tsx +++ b/src/components/inbox/InsightPanel.tsx @@ -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({
- {customer.products?.map((p) => ( - {p} + {customer.products?.map((p, index) => ( + {formatProductName(p)} ))} {customer.tags?.map((t) => ( diff --git a/src/pages/customers/CustomersPage.tsx b/src/pages/customers/CustomersPage.tsx index b43de8b..28257dd 100644 --- a/src/pages/customers/CustomersPage.tsx +++ b/src/pages/customers/CustomersPage.tsx @@ -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() { navigate(`/customers/${customer.id}`)}> navigate(`/customers/${customer.id}`)}>{customer.email || customer.phone || '—'} navigate(`/customers/${customer.id}`)}>{customer.channel} - navigate(`/customers/${customer.id}`)}>{customer.products[0] || '—'} + navigate(`/customers/${customer.id}`)}>{formatProductName(customer.products?.[0]) || '—'} @@ -333,8 +334,8 @@ export function CustomersPage() { {customer.riskLevel !== 'none' && }
- {customer.products.length > 0 && ( -

{customer.products[0]}

+ {customer.products?.length > 0 && ( +

{formatProductName(customer.products[0])}

)} ))} diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index b59843e..ff22f4c 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -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() {

- {c.products?.[0] ?? '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'} + {formatProductName(c.products?.[0]) || '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}

diff --git a/src/utils/format.ts b/src/utils/format.ts index 66d2d0e..5708c96 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -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 + for (const key of ['name', 'product', 'category']) { + if (typeof product[key] === 'string' || typeof product[key] === 'number') { + return String(product[key]).trim() + } + } + return '' +}