f920ca8920
- Added models for support messages, SLA policies, tickets, ticket events, and ticket links. - Created routes for help, help admin, newsletter, recommendations, and support for both customer and admin. - Developed services for help, marketing (newsletter), and recommendations. - Introduced support service for ticket management, including creation, replies, transitions, and attachments. - Added validation schemas for support recommendations and ticket management. - Implemented a cron job for support reconciliation and recommendation cleanup. - Created migration for new support and recommendation database tables. - Added unit tests for validation and policy checks related to Phase 10 features.
7 lines
4.8 KiB
JavaScript
7 lines
4.8 KiB
JavaScript
const crypto=require("crypto"),{Op,literal}=require("sequelize"),db=require("../../models"),serializer=require("../catalogue/serializer.service"),{resolveLocale}=require("../catalogue/locale.service");const include=[{model:db.ProductTranslation,as:"translations"},{model:db.Brand,as:"brand"},{model:db.ProductVariant,as:"variants",where:{status:"ACTIVE"},required:true},{model:db.ProductMedia,as:"media",required:false,include:[{model:db.Upload,as:"upload"}]}],active={status:"ACTIVE",visibility:"PUBLIC"};
|
|
async function project(rows,locale,reasons={}){return Promise.all(rows.map(async x=>({...await serializer.summary(x,locale),reasonCode:reasons[x.id]||undefined})));}async function recordView({eventId,userId,sessionKey,productId,variantId,source}){const product=await db.Product.findOne({where:{id:productId,...active}});if(!product)throw Object.assign(new Error("Product not found"),{status:404,code:"PRODUCT_NOT_FOUND"});const [row,created]=await db.ProductInteractionEvent.findOrCreate({where:{event_id:eventId},defaults:{id:crypto.randomUUID(),event_id:eventId,user_id:userId||null,session_key_hash:sessionKey?crypto.createHash("sha256").update(sessionKey).digest("hex"):null,product_id:productId,variant_id:variantId,event_type:"PRODUCT_VIEW",source,occurred_at:new Date()}});return{row,created};}
|
|
async function related(productId,locale,limit=12){const source=await db.Product.findOne({where:{id:productId,...active}});if(!source)throw Object.assign(new Error("Product not found"),{status:404,code:"PRODUCT_NOT_FOUND"});const rel=await db.ProductRelation.findAll({where:{source_product_id:productId},order:[["sort_order","ASC"]],limit});let ids=rel.map(x=>x.target_product_id);if(ids.length<limit){const fallback=await db.Product.findAll({where:{...active,id:{[Op.notIn]:[productId,...ids]},[Op.or]:[{default_category_id:source.default_category_id},{brand_id:source.brand_id}]},attributes:["id"],limit:limit-ids.length});ids.push(...fallback.map(x=>x.id));}const rows=await db.Product.findAll({where:{...active,id:ids},include,limit});const order=new Map(ids.map((id,i)=>[id,i]));rows.sort((a,b)=>order.get(a.id)-order.get(b.id));return project(rows,locale,Object.fromEntries(ids.map(id=>[id,"RELATED_PRODUCT"])));}
|
|
async function ranked({type="trending",locale,limit=12,userId}){const since=new Date(Date.now()-30*86400000);let signals=[];if(userId){signals=await db.ProductInteractionEvent.findAll({where:{user_id:userId,occurred_at:{[Op.gte]:since}},attributes:[["product_id","product_id"]],group:["product_id"],order:[[literal("MAX(occurred_at)"),"DESC"]],limit:50,raw:true});}const counts=await db.ProductInteractionEvent.findAll({where:{occurred_at:{[Op.gte]:since}},attributes:["product_id",[literal("SUM(CASE event_type WHEN 'PURCHASE' THEN 8 WHEN 'CART_ADD' THEN 4 WHEN 'WISHLIST_ADD' THEN 3 WHEN 'PRODUCT_CLICK' THEN 2 ELSE 1 END)"),"score"]],group:["product_id"],order:[[literal("score"),"DESC"]],limit:100,raw:true});let ids=[...new Set([...signals.map(x=>x.product_id),...counts.map(x=>x.product_id)])];if(type==="popular"){const orderRows=await db.OrderItem.findAll({attributes:["product_id",[literal("SUM(quantity - refunded_quantity)"),"score"]],include:[{model:db.Order,as:"order",where:{payment_status:{[Op.in]:["PAID","PARTIALLY_REFUNDED"]},status:{[Op.notIn]:["CANCELLED","REFUNDED"]}},attributes:[]}],group:["product_id"],order:[[literal("score"),"DESC"]],limit:100,raw:true}).catch(()=>[]);ids=orderRows.map(x=>x.product_id);}if(!ids.length){const fallback=await db.Product.findAll({where:{...active,featured:true},attributes:["id"],limit});ids=fallback.map(x=>x.id);}const rows=await db.Product.findAll({where:{...active,id:ids.slice(0,limit)},include,limit});const order=new Map(ids.map((id,i)=>[id,i]));rows.sort((a,b)=>order.get(a.id)-order.get(b.id));return project(rows,locale,Object.fromEntries(ids.map(id=>[id,type==="for-you"?"BASED_ON_ACTIVITY":type.toUpperCase()])));}
|
|
async function recent(userId,locale,limit=12){const events=await db.ProductInteractionEvent.findAll({where:{user_id:userId,event_type:"PRODUCT_VIEW"},order:[["occurred_at","DESC"]],attributes:["product_id"],limit:Math.min(limit*5,100)}),ids=[...new Set(events.map(x=>x.product_id))].slice(0,limit),rows=await db.Product.findAll({where:{...active,id:ids},include,limit});const order=new Map(ids.map((id,i)=>[id,i]));rows.sort((a,b)=>order.get(a.id)-order.get(b.id));return project(rows,locale,Object.fromEntries(ids.map(id=>[id,"RECENTLY_VIEWED"])));}
|
|
async function cleanup(limit=1000){const cutoff=new Date(Date.now()-Number(process.env.RECOMMENDATION_EVENT_RETENTION_DAYS||90)*86400000),rows=await db.ProductInteractionEvent.findAll({where:{occurred_at:{[Op.lt]:cutoff}},attributes:["id"],limit});if(rows.length)await db.ProductInteractionEvent.destroy({where:{id:rows.map(x=>x.id)}});return rows.length;}module.exports={recordView,related,ranked,recent,cleanup,project,active};
|