Files
Sathira Sri Sathara f920ca8920
CI / test (push) Successful in 10m22s
CI / test (pull_request) Successful in 10m53s
feat: Implement Phase 10 support and recommendations features
- 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.
2026-09-09 16:07:31 +05:30

17 lines
9.6 KiB
JavaScript

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};