Development #2
@@ -75,3 +75,6 @@ EMAIL_VERIFICATION_TTL_SECONDS=86400
|
|||||||
PUPPETEER_EXECUTABLE_PATH=
|
PUPPETEER_EXECUTABLE_PATH=
|
||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
APPLE_CLIENT_ID=
|
APPLE_CLIENT_ID=
|
||||||
|
# Phase 10 bounded reconciliation and privacy retention
|
||||||
|
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE=100
|
||||||
|
RECOMMENDATION_EVENT_RETENTION_DAYS=90
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -426,3 +426,21 @@ Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/di
|
|||||||
## Phase 9 Completion Update
|
## 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.
|
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.
|
||||||
@@ -51,6 +51,8 @@ const envSchema = z.object({
|
|||||||
PAYHERE_MERCHANT_ID: z.string().optional(), PAYHERE_MERCHANT_SECRET: z.string().optional(),
|
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(),
|
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),
|
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),
|
LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
|
||||||
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
|
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
|
||||||
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
|
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
|
||||||
|
|||||||
@@ -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",
|
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",
|
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",
|
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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);}};
|
||||||
@@ -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);}};
|
||||||
@@ -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 @@
|
|||||||
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;};
|
||||||
|
|||||||
@@ -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"]}]});
|
||||||
@@ -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"]}]});
|
||||||
@@ -320,6 +320,20 @@ db.PaymentAllocation = require("./wholesale/paymentAllocation.model")(
|
|||||||
sequelize,
|
sequelize,
|
||||||
DataTypes,
|
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 */
|
/* Associations */
|
||||||
Object.keys(db).forEach((model) => {
|
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"]}]});
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -43,6 +43,12 @@ const loyaltyCustomerRoutes = require("./loyalty/customer.routes");
|
|||||||
const loyaltyAdminRoutes = require("./loyalty/admin.routes");
|
const loyaltyAdminRoutes = require("./loyalty/admin.routes");
|
||||||
const wholesaleCustomerRoutes = require("./wholesale/customer.routes");
|
const wholesaleCustomerRoutes = require("./wholesale/customer.routes");
|
||||||
const wholesaleAdminRoutes = require("./wholesale/admin.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();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -78,5 +84,11 @@ router.use("/", loyaltyCustomerRoutes);
|
|||||||
router.use("/admin", loyaltyAdminRoutes);
|
router.use("/admin", loyaltyAdminRoutes);
|
||||||
router.use("/", wholesaleCustomerRoutes);
|
router.use("/", wholesaleCustomerRoutes);
|
||||||
router.use("/admin", wholesaleAdminRoutes);
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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};
|
||||||
@@ -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
@@ -13,11 +13,12 @@ const startCleanInactiveNotificationsCron = require("./notificationCleaning.cron
|
|||||||
const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron");
|
const startInventoryReservationExpiryCron = require("./inventoryReservationExpiry.cron");
|
||||||
const startCheckoutExpiryCron = require("./checkoutExpiry.cron");
|
const startCheckoutExpiryCron = require("./checkoutExpiry.cron");
|
||||||
const startLoyaltyReconciliationCron = require("./loyaltyReconciliation.cron");
|
const startLoyaltyReconciliationCron = require("./loyaltyReconciliation.cron");
|
||||||
|
const startSupportReconciliationCron = require("./supportReconciliation.cron");
|
||||||
|
|
||||||
function startAllCrons() {
|
function startAllCrons() {
|
||||||
console.log("Starting Cron Jobs...");
|
console.log("Starting Cron Jobs...");
|
||||||
|
|
||||||
const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron(), startLoyaltyReconciliationCron()];
|
const tasks = [startCleanInactiveNotificationsCron(), startInventoryReservationExpiryCron(), startCheckoutExpiryCron(), startLoyaltyReconciliationCron(), startSupportReconciliationCron()];
|
||||||
|
|
||||||
return async () => {
|
return async () => {
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
|
|||||||
@@ -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"]));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user