Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion lib/customer-360.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,36 @@ export type Customer360Record={customerId:string;name:string;primaryPhone:string
const rows=<T=Row>(result:{results?:unknown[]})=>(result.results||[]) as T[];const normPhone=(value:unknown)=>String(value||'').replace(/\D/g,'').slice(-10);const normEmail=(value:unknown)=>String(value||'').trim().toLowerCase();
export async function ensureCustomer360Tables(db:Db){await db.batch([db.prepare("CREATE TABLE IF NOT EXISTS customer_contact_preferences (customer_id TEXT PRIMARY KEY, marketing_consent INTEGER NOT NULL DEFAULT 0, service_consent INTEGER NOT NULL DEFAULT 1, whatsapp_consent INTEGER NOT NULL DEFAULT 0, sms_consent INTEGER NOT NULL DEFAULT 0, email_consent INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT 'customer', updated_by TEXT NOT NULL, updated_at INTEGER NOT NULL)"),db.prepare("CREATE TABLE IF NOT EXISTS customer_merge_reviews (id TEXT PRIMARY KEY, primary_customer_id TEXT NOT NULL, duplicate_customer_id TEXT NOT NULL, match_reason TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', reviewed_by TEXT, reviewed_at INTEGER, created_at INTEGER NOT NULL)"),db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS customer_merge_pair_idx ON customer_merge_reviews(primary_customer_id,duplicate_customer_id)")]);}
async function safeAll(db:Db,sql:string,bindings:unknown[]=[]){try{let statement=db.prepare(sql);if(bindings.length)statement=statement.bind(...bindings);return rows(await statement.all<Row>());}catch{return[] as Row[];}}
export async function buildCustomer360(db:Db,customerId?:string):Promise<Customer360Record[]>{await ensureCustomer360Tables(db);const crm=await safeAll(db,"SELECT id,name,primary_phone,email,area,stage,owner,source,lifetime_value,updated_at FROM crm_contacts ORDER BY updated_at DESC LIMIT 500"),canonical=await safeAll(db,"SELECT id,name,primary_phone,email,city_id,consent_json,updated_at FROM canonical_customers ORDER BY updated_at DESC LIMIT 500"),source=new Map<string,Row>();for(const row of crm)source.set(String(row.id),row);for(const row of canonical){const id=String(row.id);source.set(id,{...(source.get(id)||{}),...row});}const selected=[...source.entries()].filter(([id])=>!customerId||id===customerId),result:Customer360Record[]=[];for(const[id,base]of selected){const[addresses,pets,bookings,foodOrders,coupons,supportCases,tickets,prefs]=await Promise.all([safeAll(db,"SELECT id,label,line1,line2,area,city,postal_code,is_default FROM customer_addresses WHERE customer_id=? ORDER BY is_default DESC,created_at",[id]),safeAll(db,"SELECT id,name,species,breed,vaccination_status FROM canonical_pets WHERE customer_id=? ORDER BY created_at",[id]),safeAll(db,"SELECT id,service_code,package_name,status,scheduled_start,scheduled_end,total_amount,currency FROM canonical_bookings WHERE customer_id=? ORDER BY scheduled_start DESC LIMIT 100",[id]),safeAll(db,"SELECT o.id,o.status,o.total_amount,o.currency,o.created_at,l.item_name,l.quantity FROM food_orders o LEFT JOIN food_order_lines l ON l.order_id=o.id WHERE o.customer_id=? ORDER BY o.created_at DESC LIMIT 100",[id]),safeAll(db,"SELECT id,code,booking_id,discount_amount,status,created_at FROM coupon_redemptions WHERE customer_id=? ORDER BY created_at DESC LIMIT 50",[id]),safeAll(db,"SELECT id,case_type,severity,status,title,updated_at FROM unified_cases WHERE customer_id=? ORDER BY updated_at DESC LIMIT 50",[id]),safeAll(db,"SELECT id,category,priority,status,subject,updated_at FROM customer_experience_tickets WHERE customer_id=? ORDER BY updated_at DESC LIMIT 50",[id]),safeAll(db,"SELECT * FROM customer_contact_preferences WHERE customer_id=?",[id])]),pref=prefs[0]||{},phone=normPhone(base.primary_phone),email=normEmail(base.email),duplicates=[...source.entries()].filter(([otherId,row])=>otherId!==id&&((phone&&normPhone(row.primary_phone)===phone)||(email&&normEmail(row.email)===email))).map(([otherId])=>otherId),issues:string[]=[];if(!phone)issues.push('missing_primary_phone');if(!String(base.name||'').trim())issues.push('missing_name');if(!pets.length)issues.push('no_canonical_pet');if(duplicates.length)issues.push('possible_duplicate');const foodAsBookings=foodOrders.map(row=>{const at=new Date(Number(row.created_at||0)).toISOString();return{id:String(row.id),serviceCode:'pet_food',packageName:row.item_name?`${String(row.item_name)} × ${Number(row.quantity||1)}`:'Pet food order',status:String(row.status),scheduledStart:at,scheduledEnd:at,totalAmount:Number(row.total_amount||0),currency:String(row.currency||'INR')}});const bookingRecords=[...bookings.map(row=>({id:String(row.id),serviceCode:String(row.service_code),packageName:String(row.package_name),status:String(row.status),scheduledStart:String(row.scheduled_start),scheduledEnd:String(row.scheduled_end),totalAmount:Number(row.total_amount||0),currency:String(row.currency||'INR')})),...foodAsBookings].sort((a,b)=>b.scheduledStart.localeCompare(a.scheduledStart));result.push({customerId:id,name:String(base.name||'Customer'),primaryPhone:String(base.primary_phone||''),email:base.email?String(base.email):null,area:base.area?String(base.area):base.city_id?String(base.city_id):null,crmStage:String(base.stage||'Active customer'),owner:String(base.owner||'Unassigned'),source:String(base.source||'canonical'),consent:{marketing:Boolean(Number(pref.marketing_consent||0)),service:pref.service_consent===undefined?true:Boolean(Number(pref.service_consent)),whatsapp:Boolean(Number(pref.whatsapp_consent||0)),sms:Boolean(Number(pref.sms_consent||0)),email:Boolean(Number(pref.email_consent||0)),updatedAt:pref.updated_at?Number(pref.updated_at):null},addresses:addresses.map(row=>({id:String(row.id),label:String(row.label||'Address'),line1:String(row.line1||''),line2:row.line2?String(row.line2):null,area:row.area?String(row.area):null,city:String(row.city||''),postalCode:row.postal_code?String(row.postal_code):null,isDefault:Boolean(Number(row.is_default||0))})),pets:pets.map(row=>({id:String(row.id),name:String(row.name),species:String(row.species||'other'),breed:row.breed?String(row.breed):null,vaccinationStatus:String(row.vaccination_status||'not_provided')})),bookings:bookingRecords,coupons:coupons.map(row=>({id:String(row.id),code:String(row.code),bookingId:row.booking_id?String(row.booking_id):null,discountAmount:Number(row.discount_amount||0),status:String(row.status),createdAt:Number(row.created_at||0)})),supportCases:supportCases.map(row=>({id:String(row.id),caseType:String(row.case_type),severity:String(row.severity),status:String(row.status),title:String(row.title),updatedAt:Number(row.updated_at||0)})),tickets:tickets.map(row=>({id:String(row.id),category:String(row.category),priority:String(row.priority),status:String(row.status),subject:String(row.subject),updatedAt:Number(row.updated_at||0)})),// 'draft' joins cancelled/refunded here: a draft booking is not recognized revenue in
// Batched section fetch: ONE query per table per chunk of customer IDs instead of one query per
// customer per table. The per-customer fan-out (8 queries x up to 500 customers) blew straight
// past Cloudflare's per-invocation subrequest limit on real data ("Too many API requests by
// single Worker invocation"), so /team/sales rendered nothing. D1 allows ~100 bound parameters
// per statement, so IDs are chunked at 80. Per-customer ORDER BY/LIMIT semantics from the old
// per-row queries are reproduced in JS so the output shape is byte-identical.
const CHUNK=80;
async function fetchGrouped(db:Db,ids:string[],sqlFor:(placeholders:string)=>string,keyColumn="customer_id"){
const grouped=new Map<string,Row[]>();
for(let index=0;index<ids.length;index+=CHUNK){
const chunk=ids.slice(index,index+CHUNK);
const rowsChunk=await safeAll(db,sqlFor(chunk.map(()=>"?").join(",")),chunk);
for(const row of rowsChunk){const key=String(row[keyColumn]);const list=grouped.get(key)??[];list.push(row);grouped.set(key,list);}
}
return grouped;
}
const sortBy=(list:Row[],compare:(a:Row,b:Row)=>number,limit:number)=>[...list].sort(compare).slice(0,limit);
export async function buildCustomer360(db:Db,customerId?:string):Promise<Customer360Record[]>{await ensureCustomer360Tables(db);const crm=await safeAll(db,"SELECT id,name,primary_phone,email,area,stage,owner,source,lifetime_value,updated_at FROM crm_contacts ORDER BY updated_at DESC LIMIT 500"),canonical=await safeAll(db,"SELECT id,name,primary_phone,email,city_id,consent_json,updated_at FROM canonical_customers ORDER BY updated_at DESC LIMIT 500"),source=new Map<string,Row>();for(const row of crm)source.set(String(row.id),row);for(const row of canonical){const id=String(row.id);source.set(id,{...(source.get(id)||{}),...row});}const selected=[...source.entries()].filter(([id])=>!customerId||id===customerId),result:Customer360Record[]=[];
const ids=selected.map(([id])=>id);
const[addressesBy,petsBy,bookingsBy,foodOrdersBy,couponsBy,casesBy,ticketsBy,prefsBy]=await Promise.all([
fetchGrouped(db,ids,placeholders=>`SELECT customer_id,id,label,line1,line2,area,city,postal_code,is_default,created_at FROM customer_addresses WHERE customer_id IN (${placeholders})`),
fetchGrouped(db,ids,placeholders=>`SELECT customer_id,id,name,species,breed,vaccination_status,created_at FROM canonical_pets WHERE customer_id IN (${placeholders})`),
fetchGrouped(db,ids,placeholders=>`SELECT customer_id,id,service_code,package_name,status,scheduled_start,scheduled_end,total_amount,currency FROM canonical_bookings WHERE customer_id IN (${placeholders})`),
fetchGrouped(db,ids,placeholders=>`SELECT o.customer_id,o.id,o.status,o.total_amount,o.currency,o.created_at,l.item_name,l.quantity FROM food_orders o LEFT JOIN food_order_lines l ON l.order_id=o.id WHERE o.customer_id IN (${placeholders})`),
fetchGrouped(db,ids,placeholders=>`SELECT customer_id,id,code,booking_id,discount_amount,status,created_at FROM coupon_redemptions WHERE customer_id IN (${placeholders})`),
fetchGrouped(db,ids,placeholders=>`SELECT customer_id,id,case_type,severity,status,title,updated_at FROM unified_cases WHERE customer_id IN (${placeholders})`),
fetchGrouped(db,ids,placeholders=>`SELECT customer_id,id,category,priority,status,subject,updated_at FROM customer_experience_tickets WHERE customer_id IN (${placeholders})`),
fetchGrouped(db,ids,placeholders=>`SELECT * FROM customer_contact_preferences WHERE customer_id IN (${placeholders})`),
]);
for(const[id,base]of selected){const addresses=sortBy(addressesBy.get(id)??[],(a,b)=>Number(b.is_default||0)-Number(a.is_default||0)||Number(a.created_at||0)-Number(b.created_at||0),1000),pets=sortBy(petsBy.get(id)??[],(a,b)=>Number(a.created_at||0)-Number(b.created_at||0),1000),bookings=sortBy(bookingsBy.get(id)??[],(a,b)=>String(b.scheduled_start||"").localeCompare(String(a.scheduled_start||"")),100),foodOrders=sortBy(foodOrdersBy.get(id)??[],(a,b)=>Number(b.created_at||0)-Number(a.created_at||0),100),coupons=sortBy(couponsBy.get(id)??[],(a,b)=>Number(b.created_at||0)-Number(a.created_at||0),50),supportCases=sortBy(casesBy.get(id)??[],(a,b)=>Number(b.updated_at||0)-Number(a.updated_at||0),50),tickets=sortBy(ticketsBy.get(id)??[],(a,b)=>Number(b.updated_at||0)-Number(a.updated_at||0),50),pref=(prefsBy.get(id)??[])[0]||{},phone=normPhone(base.primary_phone),email=normEmail(base.email),duplicates=[...source.entries()].filter(([otherId,row])=>otherId!==id&&((phone&&normPhone(row.primary_phone)===phone)||(email&&normEmail(row.email)===email))).map(([otherId])=>otherId),issues:string[]=[];if(!phone)issues.push('missing_primary_phone');if(!String(base.name||'').trim())issues.push('missing_name');if(!pets.length)issues.push('no_canonical_pet');if(duplicates.length)issues.push('possible_duplicate');const foodAsBookings=foodOrders.map(row=>{const at=new Date(Number(row.created_at||0)).toISOString();return{id:String(row.id),serviceCode:'pet_food',packageName:row.item_name?`${String(row.item_name)} × ${Number(row.quantity||1)}`:'Pet food order',status:String(row.status),scheduledStart:at,scheduledEnd:at,totalAmount:Number(row.total_amount||0),currency:String(row.currency||'INR')}});const bookingRecords=[...bookings.map(row=>({id:String(row.id),serviceCode:String(row.service_code),packageName:String(row.package_name),status:String(row.status),scheduledStart:String(row.scheduled_start),scheduledEnd:String(row.scheduled_end),totalAmount:Number(row.total_amount||0),currency:String(row.currency||'INR')})),...foodAsBookings].sort((a,b)=>b.scheduledStart.localeCompare(a.scheduledStart));result.push({customerId:id,name:String(base.name||'Customer'),primaryPhone:String(base.primary_phone||''),email:base.email?String(base.email):null,area:base.area?String(base.area):base.city_id?String(base.city_id):null,crmStage:String(base.stage||'Active customer'),owner:String(base.owner||'Unassigned'),source:String(base.source||'canonical'),consent:{marketing:Boolean(Number(pref.marketing_consent||0)),service:pref.service_consent===undefined?true:Boolean(Number(pref.service_consent)),whatsapp:Boolean(Number(pref.whatsapp_consent||0)),sms:Boolean(Number(pref.sms_consent||0)),email:Boolean(Number(pref.email_consent||0)),updatedAt:pref.updated_at?Number(pref.updated_at):null},addresses:addresses.map(row=>({id:String(row.id),label:String(row.label||'Address'),line1:String(row.line1||''),line2:row.line2?String(row.line2):null,area:row.area?String(row.area):null,city:String(row.city||''),postalCode:row.postal_code?String(row.postal_code):null,isDefault:Boolean(Number(row.is_default||0))})),pets:pets.map(row=>({id:String(row.id),name:String(row.name),species:String(row.species||'other'),breed:row.breed?String(row.breed):null,vaccinationStatus:String(row.vaccination_status||'not_provided')})),bookings:bookingRecords,coupons:coupons.map(row=>({id:String(row.id),code:String(row.code),bookingId:row.booking_id?String(row.booking_id):null,discountAmount:Number(row.discount_amount||0),status:String(row.status),createdAt:Number(row.created_at||0)})),supportCases:supportCases.map(row=>({id:String(row.id),caseType:String(row.case_type),severity:String(row.severity),status:String(row.status),title:String(row.title),updatedAt:Number(row.updated_at||0)})),tickets:tickets.map(row=>({id:String(row.id),category:String(row.category),priority:String(row.priority),status:String(row.status),subject:String(row.subject),updatedAt:Number(row.updated_at||0)})),// 'draft' joins cancelled/refunded here: a draft booking is not recognized revenue in
// lib/pnl-reporting.ts or lib/company-analytics.ts, so counting it as lifetime value made the
// customer record disagree with every money report about the same customer (found by the Task-24
// cross-module journey gate).
Expand Down
Loading
Loading