feat: Implement Phase 10 support and recommendations features
CI / test (push) Successful in 10m22s
CI / test (pull_request) Successful in 10m53s

- 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.
This commit is contained in:
Sathira Sri Sathara
2026-09-09 16:07:31 +05:30
parent abb760cc19
commit f920ca8920
46 changed files with 251 additions and 2 deletions
+6
View File
@@ -0,0 +1,6 @@
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),{resolveLocale}=require("../catalogue/locale.service");const clean=s=>String(s).replace(/<\/?[a-z][^>]*>/gi,"").trim(),pick=(rows,locale)=>rows.find(x=>x.locale===locale)||rows.find(x=>x.locale==="en")||rows[0];
async function categories(locale){const rows=await db.HelpCategory.findAll({where:{status:"ACTIVE"},order:[["sort_order","ASC"]]});return Promise.all(rows.map(async x=>{const tr=pick(await db.HelpCategoryTranslation.findAll({where:{category_id:x.id}}),locale);return{id:x.id,slug:x.slug,iconKey:x.icon_key,name:tr?.name||null,description:tr?.description||null,locale:tr?.locale||locale};}));}
async function articles({locale,categoryId,q,featured,limit=20,offset=0}){const base={status:"PUBLISHED",published_at:{[Op.lte]:new Date()},...(categoryId&&{category_id:categoryId}),...(featured!==undefined&&{is_featured:featured})},translations=q?await db.HelpArticleTranslation.findAll({where:{locale,[Op.or]:[{title:{[Op.like]:`%${q}%`}},{summary:{[Op.like]:`%${q}%`}},{body:{[Op.like]:`%${q}%`}}]},attributes:["article_id"],limit:100}):null;if(q)base.id={[Op.in]:translations.map(x=>x.article_id)};const rows=await db.HelpArticle.findAll({where:base,order:[["is_featured","DESC"],["sort_order","ASC"],["published_at","DESC"]],limit,offset});return Promise.all(rows.map(async x=>{const tr=pick(await db.HelpArticleTranslation.findAll({where:{article_id:x.id}}),locale);return{id:x.id,slug:x.slug,categoryId:x.category_id,type:x.article_type,title:tr?.title||null,summary:tr?.summary||null,body:q?undefined:tr?.body||null,locale:tr?.locale||locale,isFeatured:x.is_featured,publishedAt:x.published_at};}));}
async function createCategory(body){return db.sequelize.transaction(async t=>{const row=await db.HelpCategory.create({id:crypto.randomUUID(),slug:body.slug,status:body.status,sort_order:body.sortOrder,icon_key:body.iconKey},{transaction:t});await db.HelpCategoryTranslation.bulkCreate(body.translations.map(x=>({id:crypto.randomUUID(),category_id:row.id,locale:x.locale,name:clean(x.name),description:x.description&&clean(x.description)})),{transaction:t});return row;});}
async function createArticle(actor,body){return db.sequelize.transaction(async t=>{const category=await db.HelpCategory.findByPk(body.categoryId,{transaction:t});if(!category)throw Object.assign(new Error("Help category not found"),{status:404,code:"CATEGORY_NOT_FOUND"});const row=await db.HelpArticle.create({id:crypto.randomUUID(),slug:body.slug,category_id:body.categoryId,article_type:body.articleType,sort_order:body.sortOrder,is_featured:body.isFeatured,created_by:actor.id,updated_by:actor.id},{transaction:t});await db.HelpArticleTranslation.bulkCreate(body.translations.map(x=>({id:crypto.randomUUID(),article_id:row.id,locale:x.locale,title:clean(x.title),summary:x.summary&&clean(x.summary),body:clean(x.body),seo_title:x.seoTitle&&clean(x.seoTitle),seo_description:x.seoDescription&&clean(x.seoDescription)})),{transaction:t});return row;});}
async function publish(id,actor,status){const row=await db.HelpArticle.findByPk(id);if(!row)throw Object.assign(new Error("Help article not found"),{status:404,code:"ARTICLE_NOT_FOUND"});if(status==="PUBLISHED"&&!await db.HelpArticleTranslation.findOne({where:{article_id:id,locale:"en"}}))throw Object.assign(new Error("English translation required"),{status:409,code:"PUBLISH_REQUIREMENTS_NOT_MET"});await row.update({status,published_at:status==="PUBLISHED"?new Date():row.published_at,updated_by:actor.id});return row;}module.exports={categories,articles,createCategory,createArticle,publish,clean,pick,resolveLocale};
@@ -0,0 +1 @@
const crypto=require("crypto"),db=require("../../models");const hash=x=>crypto.createHash("sha256").update(x).digest("hex"),normalize=x=>x.trim().toLowerCase();async function subscribe({email,locale,source,userId}){const normalized=normalize(email),token=crypto.randomBytes(32).toString("base64url");return db.sequelize.transaction(async t=>{let row=await db.NewsletterSubscription.findOne({where:{email_normalized:normalized},transaction:t,lock:t.LOCK.UPDATE});if(row?.status==="SUBSCRIBED")return{subscription:row,idempotent:true};if(row)await row.update({status:"SUBSCRIBED",locale,source,user_id:row.user_id||userId||null,consented_at:new Date(),confirmed_at:new Date(),unsubscribed_at:null,unsubscribe_token_hash:hash(token)},{transaction:t});else row=await db.NewsletterSubscription.create({id:crypto.randomUUID(),email_normalized:normalized,user_id:userId||null,status:"SUBSCRIBED",locale,source,consented_at:new Date(),confirmed_at:new Date(),unsubscribe_token_hash:hash(token)},{transaction:t});return{subscription:row,idempotent:false,unsubscribeToken:token};});}async function unsubscribe(token){const row=await db.NewsletterSubscription.findOne({where:{unsubscribe_token_hash:hash(token)}});if(!row)return false;if(row.status!=="UNSUBSCRIBED")await row.update({status:"UNSUBSCRIBED",unsubscribed_at:new Date()});return true;}module.exports={subscribe,unsubscribe,normalize,hash};
@@ -0,0 +1,6 @@
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};
+16
View File
@@ -0,0 +1,16 @@
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),{nextSequence,pad}=require("../../utils/referenceNumber.util"),storage=require("../storage/storage.service"),activity=require("../activity.service");
const fail=(message,code,status=400)=>{throw Object.assign(new Error(message),{code,status});},now=()=>new Date(),event=(ticketId,type,actorUserId,t,extra={})=>db.SupportTicketEvent.create({id:crypto.randomUUID(),event_id:crypto.randomUUID(),ticket_id:ticketId,actor_user_id:actorUserId,type,occurred_at:now(),...extra},{transaction:t});
const customerContext=async(userId,t)=>{const business=await db.BusinessCustomer.findOne({where:{user_id:userId,status:"ACTIVE"},transaction:t});return{userId,businessId:business?.business_customer_id||null};};
const ownTicket=async(id,userId,t,lock=false)=>{const row=await db.SupportTicket.findOne({where:{id,customer_user_id:userId},transaction:t,...(lock&&{lock:t.LOCK.UPDATE})});if(!row)fail("Support ticket not found","TICKET_NOT_FOUND",404);return row;};
async function assertResourceOwner(type,id,ctx,t){let ok=false;if(type==="ORDER")ok=await db.Order.findOne({where:{id,user_id:ctx.userId},transaction:t});if(type==="PAYMENT")ok=await db.Payment.findOne({where:{id},include:[{model:db.Order,as:"order",where:{user_id:ctx.userId},attributes:["id"]}],transaction:t});if(type==="REFUND"){const refund=await db.Refund.findByPk(id,{transaction:t});ok=refund&&await db.Order.findOne({where:{id:refund.order_id,user_id:ctx.userId},transaction:t});}if(type==="RETURN")ok=await db.ReturnRequest.findOne({where:{id,user_id:ctx.userId},transaction:t});if(type==="SHIPMENT")ok=await db.Shipment.findOne({where:{id},include:[{model:db.Order,as:"order",where:{user_id:ctx.userId},attributes:["id"]}],transaction:t});if(type==="LOYALTY_REDEMPTION"){const a=await db.LoyaltyAccount.findOne({where:{user_id:ctx.userId},transaction:t});ok=a&&await db.LoyaltyRedemption.findOne({where:{id,loyalty_account_id:a.id},transaction:t});}if(type==="BUSINESS_SETTLEMENT"&&ctx.businessId)ok=await db.BusinessSettlement.findOne({where:{id,business_customer_id:ctx.businessId},transaction:t});if(!ok)fail("Related resource not found or not owned","RELATED_RESOURCE_FORBIDDEN",403);}
async function validateUploads(ids,userId,t){if(!ids.length)return[];const rows=await db.Upload.findAll({where:{id:ids,status:"AVAILABLE",uploaded_by:userId},transaction:t,lock:t.LOCK.UPDATE});const allowed=new Set(["image/jpeg","image/png","image/webp","application/pdf"]);if(rows.length!==new Set(ids).size||rows.some(x=>!allowed.has(x.file_type)))fail("Invalid support attachment","ATTACHMENT_FORBIDDEN",403);return rows;}
async function attach(rows,ticketId,messageId,userId,visibility,t){for(const u of rows){await db.SupportAttachment.create({id:crypto.randomUUID(),ticket_id:ticketId,message_id:messageId,upload_id:u.id,uploaded_by:userId,visibility},{transaction:t});await u.update({owner_type:"SUPPORT_TICKET",owner_id:ticketId,use_for:"SUPPORT_ATTACHMENT",visibility:"PRIVATE"},{transaction:t});}}
async function createTicket(actor,body){const result=await db.sequelize.transaction(async t=>{const ctx=await customerContext(actor.id,t),category=body.categoryId&&await db.SupportCategory.findOne({where:{id:body.categoryId,status:"ACTIVE"},transaction:t});if(body.categoryId&&!category)fail("Support category not found","CATEGORY_NOT_FOUND",404);if(body.relatedResource)await assertResourceOwner(body.relatedResource.type,body.relatedResource.id,ctx,t);const uploads=await validateUploads(body.attachmentIds,actor.id,t),policy=await db.SupportSlaPolicy.findOne({where:{status:"ACTIVE",priority:"NORMAL"},transaction:t}),n=await nextSequence(`SUPPORT-${now().getUTCFullYear()}`,t),created=now();const ticket=await db.SupportTicket.create({id:crypto.randomUUID(),ticket_number:`SUP-${created.getUTCFullYear()}-${pad(n,6)}`,customer_user_id:ctx.userId,business_customer_id:ctx.businessId,subject:body.subject,category_id:body.categoryId,priority:"NORMAL",status:"OPEN",source:body.source,sla_policy_id:policy?.id,first_response_due_at:policy&&new Date(created.getTime()+policy.first_response_minutes*60000),resolution_due_at:policy&&new Date(created.getTime()+policy.resolution_minutes*60000),last_customer_message_at:created},{transaction:t});const message=await db.SupportMessage.create({id:crypto.randomUUID(),ticket_id:ticket.id,sender_user_id:actor.id,sender_type:"CUSTOMER",body:body.message,visibility:"CUSTOMER_VISIBLE"},{transaction:t});if(body.relatedResource)await db.SupportTicketLink.create({id:crypto.randomUUID(),ticket_id:ticket.id,resource_type:body.relatedResource.type,resource_id:body.relatedResource.id},{transaction:t});await attach(uploads,ticket.id,message.id,actor.id,"CUSTOMER_VISIBLE",t);await event(ticket.id,"TICKET_CREATED",actor.id,t);return ticket;});activity.logActivity({user:actor,description:"SUPPORT_TICKET_CREATED",module:"SUPPORT",targetType:"SUPPORT_TICKET",targetId:result.id});return result;}
async function customerReply(actor,id,body){return db.sequelize.transaction(async t=>{const ticket=await ownTicket(id,actor.id,t,true);if(["CLOSED","CANCELLED"].includes(ticket.status))fail("Ticket cannot receive replies","TICKET_NOT_REPLYABLE",409);const uploads=await validateUploads(body.attachmentIds,actor.id,t),m=await db.SupportMessage.create({id:crypto.randomUUID(),ticket_id:id,sender_user_id:actor.id,sender_type:"CUSTOMER",body:body.message,visibility:"CUSTOMER_VISIBLE"},{transaction:t});await attach(uploads,id,m.id,actor.id,"CUSTOMER_VISIBLE",t);const reopened=ticket.status==="RESOLVED";await ticket.update({status:reopened?"WAITING_FOR_SUPPORT":ticket.status,last_customer_message_at:now(),resolved_at:reopened?null:ticket.resolved_at},{transaction:t});await event(id,reopened?"TICKET_REOPENED":"CUSTOMER_REPLIED",actor.id,t);return m;});}
const transitions={OPEN:["RESOLVED","CLOSED","CANCELLED"],ASSIGNED:["WAITING_FOR_CUSTOMER","WAITING_FOR_SUPPORT","RESOLVED","CLOSED"],WAITING_FOR_CUSTOMER:["WAITING_FOR_SUPPORT","RESOLVED","CLOSED"],WAITING_FOR_SUPPORT:["WAITING_FOR_CUSTOMER","RESOLVED","CLOSED"],RESOLVED:["WAITING_FOR_SUPPORT","CLOSED"],CLOSED:[],CANCELLED:[]};
async function transition(id,to,actor){return db.sequelize.transaction(async t=>{const row=await db.SupportTicket.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)fail("Ticket not found","TICKET_NOT_FOUND",404);if(!transitions[row.status].includes(to))fail(`Cannot transition ${row.status} to ${to}`,"INVALID_TICKET_TRANSITION",409);const from=row.status;await row.update({status:to,...(to==="RESOLVED"&&{resolved_at:now()}),...(to==="CLOSED"&&{closed_at:now()})},{transaction:t});await event(id,to==="RESOLVED"?"TICKET_RESOLVED":to==="CLOSED"?"TICKET_CLOSED":"STATUS_CHANGED",actor.id,t,{from_value:from,to_value:to});return row;});}
async function agentMessage(actor,id,body,internal=false){return db.sequelize.transaction(async t=>{const ticket=await db.SupportTicket.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!ticket)fail("Ticket not found","TICKET_NOT_FOUND",404);if(["CLOSED","CANCELLED"].includes(ticket.status))fail("Ticket cannot receive replies","TICKET_NOT_REPLYABLE",409);const uploads=await validateUploads(body.attachmentIds,actor.id,t),m=await db.SupportMessage.create({id:crypto.randomUUID(),ticket_id:id,sender_user_id:actor.id,sender_type:"AGENT",body:body.message,visibility:internal?"INTERNAL":"CUSTOMER_VISIBLE"},{transaction:t});await attach(uploads,id,m.id,actor.id,internal?"INTERNAL":"CUSTOMER_VISIBLE",t);if(!internal)await ticket.update({first_response_at:ticket.first_response_at||now(),last_agent_message_at:now(),status:"WAITING_FOR_CUSTOMER"},{transaction:t});await event(id,internal?"INTERNAL_NOTE_CREATED":"AGENT_REPLIED",actor.id,t);return m;});}
async function assign(id,agentId,actor,claim=false){return db.sequelize.transaction(async t=>{const ticket=await db.SupportTicket.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!ticket)fail("Ticket not found","TICKET_NOT_FOUND",404);if(claim&&ticket.assigned_agent_id)fail("Ticket already assigned","TICKET_ALREADY_ASSIGNED",409);const agent=await db.User.findOne({where:{id:agentId,accountStatus:"ACTIVE",accountType:{[Op.in]:["support_agent","admin","superadmin"]}},transaction:t});if(!agent)fail("Eligible agent not found","AGENT_NOT_ELIGIBLE",400);const old=ticket.assigned_agent_id;await ticket.update({assigned_agent_id:agentId,status:ticket.status==="OPEN"?"ASSIGNED":ticket.status},{transaction:t});await event(id,"TICKET_ASSIGNED",actor.id,t,{from_value:old,to_value:agentId});return ticket;});}
async function attachmentUrl(ticketId,attachmentId,actor,isStaff){const ticket=isStaff?await db.SupportTicket.findByPk(ticketId):await ownTicket(ticketId,actor.id);if(!ticket)fail("Ticket not found","TICKET_NOT_FOUND",404);const a=await db.SupportAttachment.findOne({where:{id:attachmentId,ticket_id:ticketId,...(!isStaff&&{visibility:"CUSTOMER_VISIBLE"})}});if(!a)fail("Attachment not found","ATTACHMENT_NOT_FOUND",404);const u=await db.Upload.findByPk(a.upload_id);return storage.createSignedDownloadUrl(u.file_path);}
async function reconcile(limit=100){const rows=await db.SupportTicket.findAll({where:{status:{[Op.notIn]:["RESOLVED","CLOSED","CANCELLED"]},[Op.or]:[{first_response_at:null,first_response_due_at:{[Op.lte]:now()}},{resolution_due_at:{[Op.lte]:now()}}]},limit,order:[["createdAt","ASC"]]});for(const x of rows){const type=!x.first_response_at&&x.first_response_due_at<=now()?"FIRST_RESPONSE_OVERDUE":"RESOLUTION_OVERDUE",eventId=`sla:${x.id}:${type}`;await db.SupportEscalation.findOrCreate({where:{event_id:eventId},defaults:{id:crypto.randomUUID(),event_id:eventId,ticket_id:x.id,type,reason:type}});}return rows.length;}
module.exports={createTicket,customerReply,transition,agentMessage,assign,ownTicket,attachmentUrl,reconcile,assertResourceOwner,transitions};