Development #2

Merged
Sathira merged 4 commits from development into main 2026-09-12 06:48:46 +00:00
46 changed files with 251 additions and 2 deletions
Showing only changes of commit f920ca8920 - Show all commits
+3
View File
@@ -75,3 +75,6 @@ EMAIL_VERIFICATION_TTL_SECONDS=86400
PUPPETEER_EXECUTABLE_PATH=
GOOGLE_CLIENT_ID=
APPLE_CLIENT_ID=
# Phase 10 bounded reconciliation and privacy retention
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE=100
RECOMMENDATION_EVENT_RETENTION_DAYS=90
+9
View File
@@ -0,0 +1,9 @@
# Newsletter Consent API
`POST /api/v1/newsletter/subscribe` accepts `email`, `locale`, and an allowlisted source (`HOME_FOOTER`, `CHECKOUT`, or `ACCOUNT`). Email is trimmed, lowercased, and uniquely stored. Repeated subscription is idempotent and enumeration-safe.
The current product policy uses immediate single opt-in. Each subscription receives a cryptographically random unsubscribe token, while only its SHA-256 hash is stored. `POST /api/v1/newsletter/unsubscribe/:token` always returns a neutral success response. Resubscription records fresh consent and rotates the token.
Authenticated users may inspect their linked consent at `GET /api/v1/newsletter/me`. Admin listing is `GET /api/v1/admin/newsletter/subscribers` and requires `newsletter.subscribers.read`; token hashes are never returned. Export/manage permissions are reserved, and Phase 10 does not implement campaign sending.
Newsletter consent is legally distinct from Phase 3 profile marketing preferences. An email preference does not create newsletter consent, and an explicit newsletter unsubscribe takes precedence until a new explicit subscribe action occurs.
+14
View File
@@ -0,0 +1,14 @@
# Deterministic Recommendation API
Phase 10 recommendations are local, explainable rules—not AI or machine learning.
- `POST /api/v1/recommendations/events` accepts authenticated, idempotent `PRODUCT_VIEW` events only. Clients cannot claim purchases or other authoritative commerce events.
- `GET /api/v1/recommendations/recently-viewed` returns a customer's unique recent public products.
- `GET /api/v1/products/:productId/recommendations/related` prefers Phase 4 `ProductRelation`, then bounded same-category/brand fallback.
- `GET /api/v1/recommendations/trending` ranks a 30-day bounded aggregate with purchase/cart/wishlist/click/view weights.
- `GET /api/v1/recommendations/popular` uses eligible paid order item quantities net of refunded quantity, with a featured fallback.
- `GET /api/v1/recommendations/for-you` combines the authenticated user's recent product interests with aggregate candidates and falls back to trending/featured products.
Responses reuse the Phase 4 localized product summary and signed media projection. Only active/public products with an active variant are eligible. Exact stock is not exposed. Limits are capped at 24. Business-specific price quotation remains a known integration item; no customer-specific recommendation response is shared in cache.
Events store no email, IP, access token, cookie, raw session key, or full user agent. Session keys, if enabled later for anonymous ingestion, are hash-only. Retention defaults to 90 days and cleanup is bounded. The clean service boundary can later be replaced by a separately authenticated recommendation service; Phase 10 adds no URL, credential, bypass, LLM, embedding, vector store, or ML model.
+29
View File
@@ -0,0 +1,29 @@
# Support and Help API
Phase 10 adds a permission-gated support case system and a localized help center. All routes are under `/api/v1`.
## Customer support
- `POST /support/tickets` creates a self-owned ticket and initial public message in one transaction. Identity, business context, priority and status are server-derived.
- `GET /support/tickets` and `GET /support/tickets/:id` are self-only; internal notes and internal attachments are excluded.
- `POST /support/tickets/:id/messages` appends a public reply. A resolved ticket is explicitly reopened; closed/cancelled tickets reject replies.
- `POST /support/tickets/:id/close` performs a validated state transition.
- `GET /support/tickets/:id/attachments/:attachmentId` authorizes the ticket and visibility before issuing a short-lived signed S3 URL.
Ticket states are `OPEN`, `ASSIGNED`, `WAITING_FOR_CUSTOMER`, `WAITING_FOR_SUPPORT`, `RESOLVED`, `CLOSED`, and `CANCELLED`. Customers cannot select priority; new tickets default to `NORMAL`. Subjects are limited to 200 characters, messages to 10,000 characters, and five attachments per message. Attachments reuse Phase 2 uploads and allow JPEG, PNG, WebP, or PDF only.
Related resources support orders, payments, refunds, returns, shipments, loyalty redemptions, and business settlements. Creation verifies the resource against the authenticated customer or business; a resource identifier alone never grants access.
## Staff support
Under `/admin/support/tickets`, staff can list/detail, assign or atomically claim, reply, add internal notes, change priority, resolve, reopen, close, and download attachments. Permissions are granular: `support.tickets.read`, `.assign`, `.reply`, `.status`, `.priority`, `.internal_notes`, and `.escalate`. Category and SLA controls use `support.categories.manage` and `support.sla.manage`.
State/assignment operations lock the ticket row. Events are append-only. SLA deadlines are snapshotted from the active priority policy at creation using clock time; a bounded five-minute reconciliation creates idempotent first-response or resolution escalations. Business-hour calendars and multi-instance cron validation remain Phase 11 work.
## Help center
Public endpoints are `GET /help/categories`, `/help/categories/:slug`, `/help/articles`, `/help/articles/:slug`, and `/help/search?q=`. Only active categories and published articles are returned. `en`, `si`, and `ta` use the Phase 4 locale resolver with English fallback. Search is bounded to 2–100 query characters and at most 50 results.
Admin endpoints under `/admin/help` list/create categories and articles and explicitly publish/archive articles. They require `help.read`, `help.manage`, or `help.publish`. Article bodies are stored as plain Markdown-like text with HTML tags removed; consumers must render text/Markdown safely and must not treat it as trusted HTML.
All collection APIs use bounded pagination or bounded results and the standard `{ success, data, pagination? }` envelope.
+18
View File
@@ -426,3 +426,21 @@ Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/di
## Phase 9 Completion Update
Date: 2026-09-09. Module 12 is 86% and Module 13 is revised to 88%. Loyalty accounts are 92%, points ledger 90%, earning rules 86%, membership 88%, rewards/redemption 86%, referrals 82%, birthday rewards 80%, and expiry 80%. Business tiers are 84%, business credit 84%, settlements 78%, wholesale dashboard 82%, and wholesale analytics 76%. Module 07 is revised to 82% through coupon-entitlement foundations; Modules 09/10 are unchanged except for paid-event loyalty and internal-credit integration. Fourteen models, a forward-only migration, bounded cron reconciliation, new owner/admin APIs, and immutable concurrency-safe ledgers were added. Migration and real MySQL/cron/document/notification validation remain staging requirements. Module 16 AI Customer Support Chatbot remains intentionally excluded and will be developed separately.
## Phase 10 Completion Update
Date: 2026-09-09
- Module 15 Support Ticket: **84%**
- Module 17 Product Recommendations: **78%**
- Support tickets 90%; messages 88%; assignment 86%; SLA/escalation 74%; attachments 82%; related-resource integration 78%.
- Help center 84%; help localization 86%; help search 76%; newsletter consent 84%.
- Recommendation events 80%; related 86%; recently viewed 84%; trending 78%; popular 74%; personalized deterministic 70%.
- Module 14 notifications is unchanged: event/template delivery fanout remains outstanding.
- Module 03 catalogue relationships are reused without a revised completion claim.
- Module 04 localization infrastructure is reused without a revised completion claim.
- Verification: **28 suites / 145 tests passing**; syntax passed for **373 JavaScript files**; `npm ls --depth=0` reports no dependency problems.
- Migration: `20260909100000-phase-10-support-recommendations.js` created but **not executed**. Phase 0–9 migrations were not changed.
- Staging requirements: legacy-data precheck; real MySQL migration/FK/query-plan and row-lock validation; Redis/BullMQ/cron multi-instance validation; S3 signed-download and file-content validation; SMTP notification wiring; business-price projection; authoritative shopping/commerce recommendation events; IDOR/E2E/concurrency tests.
- Phase 11 readiness: safe to begin hardening after the Phase 10 migration and infrastructure checks are scheduled; Phase 10 is not a production-readiness claim.
Module 16 AI Customer Support Chatbot is intentionally excluded from this backend. It will be developed as a separate service.
@@ -0,0 +1,53 @@
# ZUMRI Phase 10 Support, Help Center, Newsletter and Recommendation Foundations
## Objective
Provide practical customer support, localized self-service content, explicit newsletter consent, and deterministic recommendations while preserving Phase 0–9 domain ownership.
## Existing Components Reused
Phase 1 identity/RBAC; Phase 2 upload, signed S3 access, notification/audit infrastructure; Phase 4 locale, product relations and product DTO; Phase 5 inventory boundary; Phase 7 order/payment truth; Phase 8 shipment truth; Phase 9 loyalty/business ownership; atomic reference numbers and existing cron lifecycle.
## Support Architecture
`SupportTicket` owns case lifecycle, `SupportMessage` conversation, `SupportTicketEvent` append-only history, `SupportTicketLink` polymorphic links, and `SupportAttachment` upload references. Ticket creation is transactional. The status graph is explicit; generic status mutation is absent. Agent claim/assignment locks the row. Internal notes and attachments are filtered from customer reads. Resource links validate domain ownership, and signed downloads require ticket authorization.
## SLA and Escalation
An active per-priority `SupportSlaPolicy` snapshots first-response and resolution deadlines using `CLOCK_TIME`. The bounded cron detects overdue open work and uses deterministic event keys to create each `SupportEscalation` once. Full calendars, warning tiers, acknowledgement APIs, and verified multi-instance scheduling remain outstanding.
## Support Notifications, Permissions and Audit
Phase 2 notification infrastructure is retained as the delivery boundary; full template/fanout wiring is outstanding. New support/help/newsletter/recommendation permissions are explicit. Support lifecycle history is durable in ticket events, and privileged general audit calls are intentionally limited pending real queue integration testing.
## Help Center Architecture
Help categories and articles have `en`/`si`/`ta` translation tables and English fallback. Articles implement draft, publish, and archive lifecycle; public APIs query published content only. FAQ is an article type, avoiding a parallel engine. Search uses bounded MySQL `LIKE` queries. HTML tags are removed and bodies are treated as untrusted Markdown-like text.
## Newsletter Consent
Newsletter subscriptions are unique by normalized email, source/locale aware, idempotent, independently revocable, and linked to a user when known. Immediate opt-in is the documented policy. Unsubscribe authorization uses a random token with hash-only persistence. Profile marketing preferences never override explicit unsubscribe.
## Recommendation Architecture
Privacy-conscious interaction events retain meaningful signals only. Client ingestion is restricted to product views and protected by authentication, validation, rate limiting, and event-id uniqueness. Explicit product relations lead related results. Recently viewed is de-duplicated; trending uses weighted 30-day SQL aggregation; popular uses paid order items net of refunds; for-you deterministically prioritizes recent interests with a featured fallback. Queries and response sizes are bounded, inactive/hidden products are excluded, and the existing localized serializer is reused.
## Future External Recommendation Service Boundary
The current implementation is `LOCAL_DETERMINISTIC`. A future external service may implement the same recommendation input/output contract using scoped machine credentials. There is no insecure internal bypass or placeholder service URL.
## Explicit AI Exclusion
Module 16 AI Customer Support Chatbot is intentionally excluded. No OpenAI/LLM integration, prompts, embeddings, RAG, vector database, generated replies, agent, or ML training pipeline was implemented.
## Security and Database Changes
Customer ownership is server-derived; strict request schemas reject unknown fields; internal visibility is enforced; uploads and linked resources receive independent authorization; newsletter tokens are hash-only; recommendation telemetry cannot spoof purchases. Migration `20260909100000-phase-10-support-recommendations.js` is forward-only and creates 14 tables with uniqueness and query indexes. Precheck legacy support/FAQ/newsletter/event tables and duplicate normalized email addresses before staging. No backfill is fabricated.
## Tests and Known Limitations
Unit coverage exercises strict fields, priority/event spoofing, transition policy, token hashing, source allowlists, body safety, and permissions. Real MySQL FK/migration/concurrency, Redis/BullMQ, S3 signed downloads, SMTP notifications, business-price projection, authoritative event hooks, rich CMS update APIs, and multi-instance cron behavior require Phase 11 staging work.
## Phase 11 Prerequisites
Apply migrations only after schema/data prechecks and a verified backup. Seed categories/SLA policies and permissions, validate real infrastructure, add concurrency/IDOR/E2E coverage, wire support notifications and authoritative recommendation signals, and measure aggregate query plans before production readiness assessment.
+2
View File
@@ -51,6 +51,8 @@ const envSchema = z.object({
PAYHERE_MERCHANT_ID: z.string().optional(), PAYHERE_MERCHANT_SECRET: z.string().optional(),
PAYHERE_NOTIFY_URL: z.string().url().optional(), PAYHERE_RETURN_URL: z.string().url().optional(), PAYHERE_CANCEL_URL: z.string().url().optional(),
LOYALTY_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
RECOMMENDATION_EVENT_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
+1
View File
@@ -28,4 +28,5 @@ module.exports = {
ORDERS_READ:"orders.read",ORDERS_MANAGE:"orders.manage",ORDERS_CANCEL:"orders.cancel",PAYMENTS_READ:"payments.read",PAYMENTS_MANAGE:"payments.manage",PAYMENTS_REFUND:"payments.refund",INVOICES_READ:"invoices.read",RETURNS_READ:"returns.read",RETURNS_MANAGE:"returns.manage",
SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage",
LOYALTY_ACCOUNTS_READ:"loyalty.accounts.read",LOYALTY_POINTS_ADJUST:"loyalty.points.adjust",LOYALTY_MANAGE:"loyalty.manage",LOYALTY_REFERRALS_READ:"loyalty.referrals.read",WHOLESALE_CREDIT_READ:"wholesale.credit.read",WHOLESALE_CREDIT_MANAGE:"wholesale.credit.manage",WHOLESALE_SETTLEMENTS_READ:"wholesale.settlements.read",WHOLESALE_SETTLEMENTS_MANAGE:"wholesale.settlements.manage",WHOLESALE_ANALYTICS_READ:"wholesale.analytics.read",
SUPPORT_TICKETS_READ:"support.tickets.read",SUPPORT_TICKETS_ASSIGN:"support.tickets.assign",SUPPORT_TICKETS_REPLY:"support.tickets.reply",SUPPORT_TICKETS_STATUS:"support.tickets.status",SUPPORT_TICKETS_PRIORITY:"support.tickets.priority",SUPPORT_TICKETS_INTERNAL_NOTES:"support.tickets.internal_notes",SUPPORT_TICKETS_ESCALATE:"support.tickets.escalate",SUPPORT_CATEGORIES_MANAGE:"support.categories.manage",SUPPORT_SLA_MANAGE:"support.sla.manage",HELP_READ:"help.read",HELP_MANAGE:"help.manage",HELP_PUBLISH:"help.publish",NEWSLETTER_SUBSCRIBERS_READ:"newsletter.subscribers.read",NEWSLETTER_SUBSCRIBERS_EXPORT:"newsletter.subscribers.export",NEWSLETTER_SUBSCRIBERS_MANAGE:"newsletter.subscribers.manage",RECOMMENDATIONS_READ:"recommendations.read",RECOMMENDATIONS_MANAGE:"recommendations.manage",
};
+1
View File
@@ -0,0 +1 @@
const db=require("../models"),help=require("../services/help/help.service"),{resolveLocale}=require("../services/catalogue/locale.service");const locale=req=>resolveLocale(req);exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await help.categories(locale(req))});}catch(e){next(e);}};exports.category=async(req,res,next)=>{try{const row=await db.HelpCategory.findOne({where:{slug:req.params.slug,status:"ACTIVE"}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help category not found"}});res.json({success:true,data:{category:(await help.categories(locale(req))).find(x=>x.id===row.id),articles:await help.articles({locale:locale(req),categoryId:row.id})}});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await help.articles({locale:locale(req),categoryId:req.query.category,featured:req.query.featured==null?undefined:req.query.featured==="true",limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};exports.article=async(req,res,next)=>{try{const a=await db.HelpArticle.findOne({where:{slug:req.params.slug,status:"PUBLISHED"}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help article not found"}});const tr=help.pick(await db.HelpArticleTranslation.findAll({where:{article_id:a.id}}),locale(req));res.json({success:true,data:{id:a.id,slug:a.slug,categoryId:a.category_id,type:a.article_type,title:tr?.title||null,summary:tr?.summary||null,body:tr?.body||null,locale:tr?.locale||locale(req),isFeatured:a.is_featured,publishedAt:a.published_at}});}catch(e){next(e);}};exports.search=async(req,res,next)=>{try{const q=String(req.query.q||"").trim();if(q.length<2||q.length>100)return res.status(400).json({success:false,error:{code:"INVALID_QUERY",message:"q must contain 2-100 characters"}});res.json({success:true,data:await help.articles({locale:locale(req),q,limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};
+1
View File
@@ -0,0 +1 @@
const db=require("../models"),help=require("../services/help/help.service");exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpCategory.findAll({order:[["sort_order","ASC"]]})});}catch(e){next(e);}};exports.createCategory=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createCategory(req.body)});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpArticle.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.createArticle=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createArticle(req.user,req.body)});}catch(e){next(e);}};exports.publish=status=>async(req,res,next)=>{try{res.json({success:true,data:await help.publish(req.params.id,req.user,status)});}catch(e){next(e);}};
+1
View File
@@ -0,0 +1 @@
const db=require("../models"),service=require("../services/marketing/newsletter.service");exports.subscribe=async(req,res,next)=>{try{const x=await service.subscribe({...req.body,userId:req.user?.id});res.status(x.idempotent?200:201).json({success:true,message:"Subscription request processed",data:{status:x.subscription.status}});}catch(e){next(e);}};exports.unsubscribe=async(req,res,next)=>{try{await service.unsubscribe(req.params.token);res.json({success:true,message:"Unsubscribe request processed"});}catch(e){next(e);}};exports.mine=async(req,res,next)=>{try{res.json({success:true,data:await db.NewsletterSubscription.findOne({where:{user_id:req.user.id},attributes:{exclude:["unsubscribe_token_hash"]}})});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||50,100),x=await db.NewsletterSubscription.findAndCountAll({attributes:{exclude:["unsubscribe_token_hash"]},limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};
@@ -0,0 +1 @@
const service=require("../services/recommendation/recommendation.service"),{resolveLocale}=require("../services/catalogue/locale.service");const loc=req=>resolveLocale(req),limit=req=>Math.min(Math.max(+req.query.limit||12,1),24);exports.event=async(req,res,next)=>{try{const x=await service.recordView({...req.body,userId:req.user?.id});res.status(x.created?201:200).json({success:true,data:{accepted:true,idempotent:!x.created}});}catch(e){next(e);}};exports.related=async(req,res,next)=>{try{res.json({success:true,data:await service.related(req.params.productId,loc(req),limit(req))});}catch(e){next(e);}};exports.trending=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"trending",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.popular=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"popular",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.recent=async(req,res,next)=>{try{res.json({success:true,data:await service.recent(req.user.id,loc(req),limit(req))});}catch(e){next(e);}};exports.forYou=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"for-you",userId:req.user.id,locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};
@@ -0,0 +1 @@
const{Op}=require("sequelize"),db=require("../../models"),support=require("../../services/support/support.service");exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||20,100),where={...(req.query.status&&{status:req.query.status}),...(req.query.priority&&{priority:req.query.priority}),...(req.query.category&&{category_id:req.query.category}),...(req.query.assignedAgentId&&{assigned_agent_id:req.query.assignedAgentId}),...(req.query.business&&{business_customer_id:req.query.business}),...(req.query.unassigned==="true"&&{assigned_agent_id:null}),...(req.query.overdue==="true"&&{resolution_due_at:{[Op.lt]:new Date()},status:{[Op.notIn]:["RESOLVED","CLOSED","CANCELLED"]}})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit,offset:(page-1)*limit});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await db.SupportTicket.findByPk(req.params.id);if(!ticket)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const[messages,attachments,links,events,escalations]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id},order:[["occurred_at","ASC"]]}),db.SupportEscalation.findAll({where:{ticket_id:ticket.id}})]);res.json({success:true,data:{ticket,messages,attachments,links,events,escalations}});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.body.agentId,req.user)});}catch(e){next(e);}};exports.claim=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.user.id,req.user,true)});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.note=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body,true)});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await support.transition(req.params.id,to,req.user)});}catch(e){next(e);}};exports.priority=async(req,res,next)=>{try{const row=await db.SupportTicket.findByPk(req.params.id);if(!row)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const from=row.priority;await row.update({priority:req.body.priority});await db.SupportTicketEvent.create({id:require("crypto").randomUUID(),event_id:require("crypto").randomUUID(),ticket_id:row.id,actor_user_id:req.user.id,type:"PRIORITY_CHANGED",from_value:from,to_value:row.priority,occurred_at:new Date()});res.json({success:true,data:row});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,true)}});}catch(e){next(e);}};
@@ -0,0 +1 @@
const db=require("../../models"),support=require("../../services/support/support.service");const page=req=>({page:Math.max(Number(req.query.page)||1,1),limit:Math.min(Math.max(Number(req.query.limit)||20,1),100)});exports.create=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.createTicket(req.user,req.body)});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const p=page(req),where={customer_user_id:req.user.id,...(req.query.status&&{status:req.query.status}),...(req.query.category&&{category_id:req.query.category})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit:p.limit,offset:(p.page-1)*p.limit});res.json({success:true,data:x.rows,pagination:{...p,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await support.ownTicket(req.params.id,req.user.id),[messages,attachments,links,events]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id,type:["TICKET_CREATED","TICKET_ASSIGNED","TICKET_RESOLVED","TICKET_CLOSED","TICKET_REOPENED"]},attributes:{exclude:["metadata"]},order:[["occurred_at","ASC"]]})]);res.json({success:true,data:{ticket,messages,attachments,links,events}});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.customerReply(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.close=async(req,res,next)=>{try{await support.ownTicket(req.params.id,req.user.id);res.json({success:true,data:await support.transition(req.params.id,"CLOSED",req.user)});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,false)}});}catch(e){next(e);}};
+1 -1
View File
@@ -1 +1 @@
module.exports=(s,D)=>s.define("OrderItem",{id:{type:D.STRING,primaryKey:true},order_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},reservation_key:{type:D.STRING(160),allowNull:false},sku:{type:D.STRING(100),allowNull:false},product_name:{type:D.STRING(255),allowNull:false},variant_description:D.STRING(255),quantity:{type:D.INTEGER,allowNull:false},unit_price:{type:D.DECIMAL(15,2),allowNull:false},discount_amount:{type:D.DECIMAL(15,2),allowNull:false},line_total:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},returned_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},refunded_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},metadata:D.JSON},{tableName:"order_items",timestamps:true,updatedAt:false});
module.exports=(s,D)=>{const M=s.define("OrderItem",{id:{type:D.STRING,primaryKey:true},order_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},reservation_key:{type:D.STRING(160),allowNull:false},sku:{type:D.STRING(100),allowNull:false},product_name:{type:D.STRING(255),allowNull:false},variant_description:D.STRING(255),quantity:{type:D.INTEGER,allowNull:false},unit_price:{type:D.DECIMAL(15,2),allowNull:false},discount_amount:{type:D.DECIMAL(15,2),allowNull:false},line_total:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},returned_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},refunded_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},metadata:D.JSON},{tableName:"order_items",timestamps:true,updatedAt:false});M.associate=db=>M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});return M;};
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("HelpArticle",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(180),allowNull:false,unique:true},category_id:{type:D.STRING,allowNull:false},article_type:{type:D.ENUM("ARTICLE","FAQ"),allowNull:false,defaultValue:"ARTICLE"},status:{type:D.ENUM("DRAFT","PUBLISHED","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},is_featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false},published_at:D.DATE,created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"help_articles",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("HelpArticleTranslation",{id:{type:D.STRING,primaryKey:true},article_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},title:{type:D.STRING(220),allowNull:false},summary:D.STRING(500),body:{type:D.TEXT("long"),allowNull:false},seo_title:D.STRING(220),seo_description:D.STRING(500)},{tableName:"help_article_translations",timestamps:true,indexes:[{unique:true,fields:["article_id","locale"]}]});
+1
View File
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("HelpCategory",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(160),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},icon_key:D.STRING(80)},{tableName:"help_categories",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("HelpCategoryTranslation",{id:{type:D.STRING,primaryKey:true},category_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(160),allowNull:false},description:D.STRING(500)},{tableName:"help_category_translations",timestamps:true,indexes:[{unique:true,fields:["category_id","locale"]}]});
+14
View File
@@ -320,6 +320,20 @@ db.PaymentAllocation = require("./wholesale/paymentAllocation.model")(
sequelize,
DataTypes,
);
db.SupportCategory = require("./support/supportCategory.model")(sequelize, DataTypes);
db.SupportTicket = require("./support/supportTicket.model")(sequelize, DataTypes);
db.SupportMessage = require("./support/supportMessage.model")(sequelize, DataTypes);
db.SupportTicketEvent = require("./support/supportTicketEvent.model")(sequelize, DataTypes);
db.SupportTicketLink = require("./support/supportTicketLink.model")(sequelize, DataTypes);
db.SupportAttachment = require("./support/supportAttachment.model")(sequelize, DataTypes);
db.SupportSlaPolicy = require("./support/supportSlaPolicy.model")(sequelize, DataTypes);
db.SupportEscalation = require("./support/supportEscalation.model")(sequelize, DataTypes);
db.HelpCategory = require("./help/helpCategory.model")(sequelize, DataTypes);
db.HelpCategoryTranslation = require("./help/helpCategoryTranslation.model")(sequelize, DataTypes);
db.HelpArticle = require("./help/helpArticle.model")(sequelize, DataTypes);
db.HelpArticleTranslation = require("./help/helpArticleTranslation.model")(sequelize, DataTypes);
db.NewsletterSubscription = require("./marketing/newsletterSubscription.model")(sequelize, DataTypes);
db.ProductInteractionEvent = require("./recommendation/productInteractionEvent.model")(sequelize, DataTypes);
/* Associations */
Object.keys(db).forEach((model) => {
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("NewsletterSubscription",{id:{type:D.STRING,primaryKey:true},email_normalized:{type:D.STRING(254),allowNull:false,unique:true},user_id:D.STRING,status:{type:D.ENUM("PENDING","SUBSCRIBED","UNSUBSCRIBED"),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},source:{type:D.ENUM("HOME_FOOTER","CHECKOUT","ACCOUNT","ADMIN_IMPORT"),allowNull:false},consented_at:D.DATE,confirmed_at:D.DATE,unsubscribed_at:D.DATE,unsubscribe_token_hash:{type:D.STRING(64),allowNull:false}},{tableName:"newsletter_subscriptions",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("ProductInteractionEvent",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},user_id:D.STRING,session_key_hash:D.STRING(64),product_id:{type:D.STRING,allowNull:false},variant_id:D.STRING,event_type:{type:D.ENUM("PRODUCT_VIEW","PRODUCT_CLICK","WISHLIST_ADD","CART_ADD","CHECKOUT_START","PURCHASE"),allowNull:false},source:{type:D.STRING(40),allowNull:false},occurred_at:{type:D.DATE,allowNull:false}},{tableName:"recommendation_events",timestamps:true,updatedAt:false});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportAttachment",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},message_id:D.STRING,upload_id:{type:D.INTEGER,allowNull:false},uploaded_by:{type:D.STRING,allowNull:false},visibility:{type:D.ENUM("CUSTOMER_VISIBLE","INTERNAL"),allowNull:false,defaultValue:"CUSTOMER_VISIBLE"}},{tableName:"support_attachments",timestamps:true,updatedAt:false});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportCategory",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},translations:{type:D.JSON,allowNull:false,defaultValue:{}},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"support_categories",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportEscalation",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},ticket_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("FIRST_RESPONSE_OVERDUE","RESOLUTION_OVERDUE","MANUAL"),allowNull:false},level:{type:D.INTEGER,allowNull:false,defaultValue:1},reason:{type:D.STRING(300),allowNull:false},status:{type:D.ENUM("OPEN","ACKNOWLEDGED"),allowNull:false,defaultValue:"OPEN"},acknowledged_at:D.DATE},{tableName:"support_escalations",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportMessage",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},sender_user_id:D.STRING,sender_type:{type:D.ENUM("CUSTOMER","AGENT","SYSTEM"),allowNull:false},message_type:{type:D.ENUM("MESSAGE","SYSTEM"),allowNull:false,defaultValue:"MESSAGE"},body:{type:D.TEXT,allowNull:false},visibility:{type:D.ENUM("CUSTOMER_VISIBLE","INTERNAL"),allowNull:false,defaultValue:"CUSTOMER_VISIBLE"}},{tableName:"support_messages",timestamps:true,updatedAt:false});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportSlaPolicy",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(120),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false},priority:{type:D.ENUM("LOW","NORMAL","HIGH","URGENT"),allowNull:false,unique:true},first_response_minutes:{type:D.INTEGER,allowNull:false},resolution_minutes:{type:D.INTEGER,allowNull:false},business_hours_mode:{type:D.ENUM("CLOCK_TIME"),allowNull:false,defaultValue:"CLOCK_TIME"}},{tableName:"support_sla_policies",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportTicket",{id:{type:D.STRING,primaryKey:true},ticket_number:{type:D.STRING(80),allowNull:false,unique:true},customer_user_id:{type:D.STRING,allowNull:false},business_customer_id:D.STRING,subject:{type:D.STRING(200),allowNull:false},category_id:D.STRING,priority:{type:D.ENUM("LOW","NORMAL","HIGH","URGENT"),allowNull:false,defaultValue:"NORMAL"},status:{type:D.ENUM("OPEN","ASSIGNED","WAITING_FOR_CUSTOMER","WAITING_FOR_SUPPORT","RESOLVED","CLOSED","CANCELLED"),allowNull:false,defaultValue:"OPEN"},assigned_agent_id:D.STRING,source:{type:D.ENUM("WEB","MOBILE","ADMIN"),allowNull:false,defaultValue:"WEB"},sla_policy_id:D.STRING,first_response_due_at:D.DATE,resolution_due_at:D.DATE,first_response_at:D.DATE,resolved_at:D.DATE,closed_at:D.DATE,last_customer_message_at:D.DATE,last_agent_message_at:D.DATE},{tableName:"support_tickets",timestamps:true});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportTicketEvent",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},ticket_id:{type:D.STRING,allowNull:false},actor_user_id:D.STRING,type:{type:D.STRING(60),allowNull:false},from_value:D.STRING,to_value:D.STRING,metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false}},{tableName:"support_ticket_events",timestamps:true,updatedAt:false});
@@ -0,0 +1 @@
module.exports=(s,D)=>s.define("SupportTicketLink",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},resource_type:{type:D.ENUM("ORDER","PAYMENT","REFUND","RETURN","SHIPMENT","LOYALTY_REDEMPTION","BUSINESS_SETTLEMENT"),allowNull:false},resource_id:{type:D.STRING,allowNull:false}},{tableName:"support_ticket_links",timestamps:true,updatedAt:false,indexes:[{unique:true,fields:["ticket_id","resource_type","resource_id"]}]});
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../controllers/help.controller"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.get("/help/categories",c.categories);r.get("/help/categories/:slug",c.category);r.get("/help/articles",c.articles);r.get("/help/articles/:slug",c.article);r.get("/help/search",sensitiveLimiter,c.search);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../controllers/helpAdmin.controller"),{authenticate}=require("../middleware/auth.middleware"),{checkPermission}=require("../middleware/permission.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas");r.use(authenticate);r.get("/help/categories",checkPermission("help.read",{custom:true}),c.categories);r.post("/help/categories",checkPermission("help.manage",{custom:true}),validate(s.helpCategory),c.createCategory);r.get("/help/articles",checkPermission("help.read",{custom:true}),c.articles);r.post("/help/articles",checkPermission("help.manage",{custom:true}),validate(s.helpArticle),c.createArticle);r.post("/help/articles/:id/publish",checkPermission("help.publish",{custom:true}),c.publish("PUBLISHED"));r.post("/help/articles/:id/archive",checkPermission("help.publish",{custom:true}),c.publish("ARCHIVED"));module.exports=r;
+12
View File
@@ -43,6 +43,12 @@ const loyaltyCustomerRoutes = require("./loyalty/customer.routes");
const loyaltyAdminRoutes = require("./loyalty/admin.routes");
const wholesaleCustomerRoutes = require("./wholesale/customer.routes");
const wholesaleAdminRoutes = require("./wholesale/admin.routes");
const supportCustomerRoutes = require("./support/customer.routes");
const supportAdminRoutes = require("./support/admin.routes");
const helpRoutes = require("./help.routes");
const helpAdminRoutes = require("./helpAdmin.routes");
const newsletterRoutes = require("./newsletter.routes");
const recommendationRoutes = require("./recommendation.routes");
const router = express.Router();
@@ -78,5 +84,11 @@ router.use("/", loyaltyCustomerRoutes);
router.use("/admin", loyaltyAdminRoutes);
router.use("/", wholesaleCustomerRoutes);
router.use("/admin", wholesaleAdminRoutes);
router.use("/", supportCustomerRoutes);
router.use("/admin", supportAdminRoutes);
router.use("/", helpRoutes);
router.use("/admin", helpAdminRoutes);
router.use("/", newsletterRoutes);
router.use("/", recommendationRoutes);
module.exports = router;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../controllers/newsletter.controller"),{authenticate}=require("../middleware/auth.middleware"),{checkPermission}=require("../middleware/permission.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.post("/newsletter/subscribe",sensitiveLimiter,validate(s.newsletter),c.subscribe);r.post("/newsletter/unsubscribe/:token",sensitiveLimiter,c.unsubscribe);r.get("/newsletter/me",authenticate,c.mine);r.get("/admin/newsletter/subscribers",authenticate,checkPermission("newsletter.subscribers.read",{custom:true}),c.list);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../controllers/recommendation.controller"),{authenticate}=require("../middleware/auth.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.post("/recommendations/events",authenticate,sensitiveLimiter,validate(s.viewEvent),c.event);r.get("/products/:productId/recommendations/related",c.related);r.get("/recommendations/trending",c.trending);r.get("/recommendations/popular",c.popular);r.get("/recommendations/recently-viewed",authenticate,c.recent);r.get("/recommendations/for-you",authenticate,c.forYou);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../../controllers/support/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/supportRecommendation.schemas");r.use(authenticate);r.get("/support/tickets",checkPermission("support.tickets.read",{custom:true}),c.list);r.get("/support/tickets/:id",checkPermission("support.tickets.read",{custom:true}),c.detail);r.post("/support/tickets/:id/assign",checkPermission("support.tickets.assign",{custom:true}),validate(s.assign),c.assign);r.post("/support/tickets/:id/claim",checkPermission("support.tickets.assign",{custom:true}),c.claim);r.post("/support/tickets/:id/messages",checkPermission("support.tickets.reply",{custom:true}),validate(s.message),c.reply);r.post("/support/tickets/:id/internal-notes",checkPermission("support.tickets.internal_notes",{custom:true}),validate(s.internalNote),c.note);r.post("/support/tickets/:id/resolve",checkPermission("support.tickets.status",{custom:true}),c.action("RESOLVED"));r.post("/support/tickets/:id/reopen",checkPermission("support.tickets.status",{custom:true}),c.action("WAITING_FOR_SUPPORT"));r.post("/support/tickets/:id/close",checkPermission("support.tickets.status",{custom:true}),c.action("CLOSED"));r.patch("/support/tickets/:id/priority",checkPermission("support.tickets.priority",{custom:true}),validate(s.priority),c.priority);r.get("/support/tickets/:id/attachments/:attachmentId",checkPermission("support.tickets.read",{custom:true}),c.attachment);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router(),c=require("../../controllers/support/customer.controller"),{authenticate}=require("../../middleware/auth.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../../middleware/rateLimit.middleware");r.use(authenticate);r.get("/support/tickets",c.list);r.post("/support/tickets",sensitiveLimiter,validate(s.ticketCreate),c.create);r.get("/support/tickets/:id",c.detail);r.post("/support/tickets/:id/messages",sensitiveLimiter,validate(s.message),c.reply);r.post("/support/tickets/:id/close",c.close);r.get("/support/tickets/:id/attachments/:attachmentId",c.attachment);module.exports=r;
+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};
@@ -0,0 +1 @@
const{z}=require("zod"),wrap=body=>z.object({body:body.strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()}),id=z.string().min(1).max(180),text=z.string().trim().min(1).max(10000),locale=z.enum(["en","si","ta"]);exports.ticketCreate=wrap(z.object({subject:z.string().trim().min(3).max(200),categoryId:id.optional(),message:text,relatedResource:z.object({type:z.enum(["ORDER","PAYMENT","REFUND","RETURN","SHIPMENT","LOYALTY_REDEMPTION","BUSINESS_SETTLEMENT"]),id}).optional(),attachmentIds:z.array(z.number().int().positive()).max(5).default([]),source:z.enum(["WEB","MOBILE"]).default("WEB")}));exports.message=wrap(z.object({message:text,attachmentIds:z.array(z.number().int().positive()).max(5).default([])}));exports.internalNote=wrap(z.object({message:text,attachmentIds:z.array(z.number().int().positive()).max(5).default([])}));exports.assign=wrap(z.object({agentId:id}));exports.priority=wrap(z.object({priority:z.enum(["LOW","NORMAL","HIGH","URGENT"])}));exports.link=wrap(z.object({type:z.enum(["ORDER","PAYMENT","REFUND","RETURN","SHIPMENT","LOYALTY_REDEMPTION","BUSINESS_SETTLEMENT"]),id}));exports.category=wrap(z.object({code:z.string().regex(/^[A-Z][A-Z0-9_]{1,59}$/),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),translations:z.record(locale,z.object({name:z.string().min(1).max(160),description:z.string().max(500).optional()})),sortOrder:z.number().int().default(0)}));exports.sla=wrap(z.object({name:z.string().trim().min(1).max(120),status:z.enum(["ACTIVE","INACTIVE"]),priority:z.enum(["LOW","NORMAL","HIGH","URGENT"]),firstResponseMinutes:z.number().int().positive().max(43200),resolutionMinutes:z.number().int().positive().max(129600)}).refine(x=>x.resolutionMinutes>=x.firstResponseMinutes));const translation=z.object({locale,title:z.string().min(1).max(220),summary:z.string().max(500).optional(),body:text,seoTitle:z.string().max(220).optional(),seoDescription:z.string().max(500).optional()});exports.helpCategory=wrap(z.object({slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),status:z.enum(["ACTIVE","INACTIVE"]).default("ACTIVE"),sortOrder:z.number().int().default(0),iconKey:z.string().max(80).optional(),translations:z.array(z.object({locale,name:z.string().min(1).max(160),description:z.string().max(500).optional()})).min(1)}));exports.helpArticle=wrap(z.object({slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),categoryId:id,articleType:z.enum(["ARTICLE","FAQ"]).default("ARTICLE"),sortOrder:z.number().int().default(0),isFeatured:z.boolean().default(false),translations:z.array(translation).min(1)}));exports.newsletter=wrap(z.object({email:z.string().trim().email().max(254),locale:locale.default("en"),source:z.enum(["HOME_FOOTER","CHECKOUT","ACCOUNT"]).default("HOME_FOOTER")}));exports.viewEvent=wrap(z.object({eventId:id,productId:id,variantId:id.optional(),source:z.enum(["WEB","MOBILE"]).default("WEB"),sessionKey:z.string().min(16).max(200).optional()}));
+2 -1
View File
@@ -13,11 +13,12 @@ const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron
const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron");
const startCheckoutExpiryCron = require("./checkoutExpiry.cron");
const startLoyaltyReconciliationCron = require("./loyaltyReconciliation.cron");
const startSupportReconciliationCron = require("./supportReconciliation.cron");
function startAllCrons() {
console.log("Starting Cron Jobs...");
const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron(), startLoyaltyReconciliationCron()];
const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron(), startLoyaltyReconciliationCron(), startSupportReconciliationCron()];
return async () => {
for (const task of tasks) {
+1
View File
@@ -0,0 +1 @@
const cron=require("node-cron"),support=require("../app/services/support/support.service"),recommendations=require("../app/services/recommendation/recommendation.service");module.exports=()=>cron.schedule("*/5 * * * *",async()=>{try{await support.reconcile(Number(process.env.SUPPORT_SLA_RECONCILIATION_BATCH_SIZE||100));await recommendations.cleanup(1000);}catch(e){console.error("Phase 10 reconciliation failed",e.message);}},{noOverlap:true});
@@ -0,0 +1,16 @@
"use strict";module.exports={async up(q,S){const ID={type:S.STRING,primaryKey:true,allowNull:false},REQ={type:S.STRING,allowNull:false},STR={type:S.STRING},DATE={type:S.DATE},INT={type:S.INTEGER,allowNull:false,defaultValue:0},TS={createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}};
await q.createTable("support_categories",{id:ID,code:{type:S.STRING(60),allowNull:false,unique:true},status:REQ,translations:{type:S.JSON,allowNull:false},sort_order:INT,...TS});
await q.createTable("support_sla_policies",{id:ID,name:REQ,status:REQ,priority:{type:S.STRING,allowNull:false,unique:true},first_response_minutes:{type:S.INTEGER,allowNull:false},resolution_minutes:{type:S.INTEGER,allowNull:false},business_hours_mode:REQ,...TS});
await q.createTable("support_tickets",{id:ID,ticket_number:{type:S.STRING(80),allowNull:false,unique:true},customer_user_id:REQ,business_customer_id:STR,subject:{type:S.STRING(200),allowNull:false},category_id:STR,priority:REQ,status:REQ,assigned_agent_id:STR,source:REQ,sla_policy_id:STR,first_response_due_at:DATE,resolution_due_at:DATE,first_response_at:DATE,resolved_at:DATE,closed_at:DATE,last_customer_message_at:DATE,last_agent_message_at:DATE,...TS});await q.addIndex("support_tickets",["customer_user_id","createdAt"]);await q.addIndex("support_tickets",["business_customer_id","createdAt"]);await q.addIndex("support_tickets",["status","priority"]);await q.addIndex("support_tickets",["assigned_agent_id","status"]);await q.addIndex("support_tickets",["first_response_due_at","resolution_due_at"]);
await q.createTable("support_messages",{id:ID,ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},sender_user_id:STR,sender_type:REQ,message_type:REQ,body:{type:S.TEXT,allowNull:false},visibility:REQ,createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("support_messages",["ticket_id","createdAt"]);
await q.createTable("support_ticket_events",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},actor_user_id:STR,type:REQ,from_value:STR,to_value:STR,metadata:{type:S.JSON},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("support_ticket_events",["ticket_id","occurred_at"]);
await q.createTable("support_ticket_links",{id:ID,ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},resource_type:REQ,resource_id:REQ,createdAt:{type:S.DATE,allowNull:false}});await q.addConstraint("support_ticket_links",{fields:["ticket_id","resource_type","resource_id"],type:"unique",name:"uq_support_ticket_resource"});await q.addIndex("support_ticket_links",["resource_type","resource_id"]);
await q.createTable("support_attachments",{id:ID,ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},message_id:STR,upload_id:{type:S.INTEGER,allowNull:false,references:{model:"uploads",key:"id"}},uploaded_by:REQ,visibility:REQ,createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("support_attachments",["ticket_id","visibility"]);
await q.createTable("support_escalations",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},ticket_id:{...REQ,references:{model:"support_tickets",key:"id"}},type:REQ,level:{type:S.INTEGER,allowNull:false},reason:{type:S.STRING(300),allowNull:false},status:REQ,acknowledged_at:DATE,...TS});await q.addIndex("support_escalations",["ticket_id","status"]);
await q.createTable("help_categories",{id:ID,slug:{type:S.STRING(160),allowNull:false,unique:true},status:REQ,sort_order:INT,icon_key:{type:S.STRING(80)},...TS});await q.addIndex("help_categories",["status","sort_order"]);
await q.createTable("help_category_translations",{id:ID,category_id:{...REQ,references:{model:"help_categories",key:"id"}},locale:{type:S.STRING(5),allowNull:false},name:{type:S.STRING(160),allowNull:false},description:{type:S.STRING(500)},...TS});await q.addConstraint("help_category_translations",{fields:["category_id","locale"],type:"unique",name:"uq_help_category_locale"});await q.addIndex("help_category_translations",["locale"]);
await q.createTable("help_articles",{id:ID,slug:{type:S.STRING(180),allowNull:false,unique:true},category_id:{...REQ,references:{model:"help_categories",key:"id"}},article_type:REQ,status:REQ,sort_order:INT,is_featured:{type:S.BOOLEAN,allowNull:false,defaultValue:false},published_at:DATE,created_by:REQ,updated_by:STR,...TS});await q.addIndex("help_articles",["category_id","status","published_at"]);
await q.createTable("help_article_translations",{id:ID,article_id:{...REQ,references:{model:"help_articles",key:"id"}},locale:{type:S.STRING(5),allowNull:false},title:{type:S.STRING(220),allowNull:false},summary:{type:S.STRING(500)},body:{type:S.TEXT("long"),allowNull:false},seo_title:{type:S.STRING(220)},seo_description:{type:S.STRING(500)},...TS});await q.addConstraint("help_article_translations",{fields:["article_id","locale"],type:"unique",name:"uq_help_article_locale"});await q.addIndex("help_article_translations",["locale"]);
await q.createTable("newsletter_subscriptions",{id:ID,email_normalized:{type:S.STRING(254),allowNull:false,unique:true},user_id:STR,status:REQ,locale:{type:S.STRING(5),allowNull:false},source:REQ,consented_at:DATE,confirmed_at:DATE,unsubscribed_at:DATE,unsubscribe_token_hash:{type:S.STRING(64),allowNull:false},...TS});await q.addIndex("newsletter_subscriptions",["status","email_normalized"]);
await q.createTable("recommendation_events",{id:ID,event_id:{type:S.STRING(180),allowNull:false,unique:true},user_id:STR,session_key_hash:{type:S.STRING(64)},product_id:{...REQ,references:{model:"products",key:"id"}},variant_id:STR,event_type:REQ,source:{type:S.STRING(40),allowNull:false},occurred_at:{type:S.DATE,allowNull:false},createdAt:{type:S.DATE,allowNull:false}});await q.addIndex("recommendation_events",["user_id","occurred_at"]);await q.addIndex("recommendation_events",["product_id","occurred_at"]);await q.addIndex("recommendation_events",["event_type","occurred_at"]);await q.addIndex("recommendation_events",["product_id","event_type","occurred_at"]);
},async down(){throw new Error("Phase 10 migration is forward-only; restore from a verified backup instead");}};
@@ -0,0 +1,20 @@
jest.mock("../../app/services/activity.service",()=>({logActivity:jest.fn()}));
const schemas=require("../../app/validation/supportRecommendation.schemas"),newsletter=require("../../app/services/marketing/newsletter.service"),help=require("../../app/services/help/help.service"),support=require("../../app/services/support/support.service"),permissions=require("../../app/constants/permissions");
const parse=(schema,body)=>schema.safeParse({body,params:{},query:{}});
describe("Phase 10 validation and policy",()=>{
test("ticket accepts safe customer fields",()=>expect(parse(schemas.ticketCreate,{subject:"Missing parcel",message:"Please check this order",attachmentIds:[],source:"WEB"}).success).toBe(true));
test("ticket rejects ownership injection",()=>expect(parse(schemas.ticketCreate,{subject:"Missing parcel",message:"Please check",customerUserId:"other"}).success).toBe(false));
test("customer cannot submit priority",()=>expect(parse(schemas.ticketCreate,{subject:"Urgent parcel",message:"Please check",priority:"URGENT"}).success).toBe(false));
test("empty support messages are rejected",()=>expect(parse(schemas.message,{message:" "}).success).toBe(false));
test("support attachments are bounded",()=>expect(parse(schemas.message,{message:"ok",attachmentIds:[1,2,3,4,5,6]}).success).toBe(false));
test("ticket links use a strict resource allowlist",()=>expect(parse(schemas.link,{type:"USER",id:"x"}).success).toBe(false));
test("closed tickets have no outbound transitions",()=>expect(support.transitions.CLOSED).toEqual([]));
test("resolved tickets may explicitly reopen",()=>expect(support.transitions.RESOLVED).toContain("WAITING_FOR_SUPPORT"));
test("newsletter email normalization is deterministic",()=>expect(newsletter.normalize(" PERSON@Example.COM ")).toBe("person@example.com"));
test("newsletter authorization tokens are hashable without storage of raw token",()=>{const token="a-secure-random-token";expect(newsletter.hash(token)).toMatch(/^[a-f0-9]{64}$/);expect(newsletter.hash(token)).not.toBe(token);});
test("newsletter source is allowlisted",()=>expect(parse(schemas.newsletter,{email:"a@example.com",source:"SCRAPED_LIST"}).success).toBe(false));
test("client event schema only accepts product views",()=>expect(parse(schemas.viewEvent,{eventId:"evt-1",productId:"p1",eventType:"PURCHASE"}).success).toBe(false));
test("client event rejects unknown mutation fields",()=>expect(parse(schemas.viewEvent,{eventId:"evt-1",productId:"p1",source:"WEB",email:"private@example.com"}).success).toBe(false));
test("help content strips HTML tags",()=>expect(help.clean("<script>alert(1)</script>Safe")).toBe("alert(1)Safe"));
test("all privileged Phase 10 permissions are explicit",()=>expect([permissions.SUPPORT_TICKETS_READ,permissions.HELP_MANAGE,permissions.NEWSLETTER_SUBSCRIBERS_READ,permissions.RECOMMENDATIONS_MANAGE]).toEqual(["support.tickets.read","help.manage","newsletter.subscribers.read","recommendations.manage"]));
});