Skip to content

Marketplace Notifications

Purpose

A centralized, durable in-app notification system that fires as a trusted side effect of successful marketplace operations - order placement, seller fulfilment, logistics milestones, returns/refunds, seller settlements, and product review moderation. Every notification is created server-side by the trusted command handler that already performed the underlying state change; there is no POST /api/notifications endpoint, and no client ever supplies a recipient, notification type, or reference id. Builds on Seller Order Fulfilment, Marketplace Logistics, Marketplace Returns & Refunds, Payments & Seller Settlements, and Product Reviews & Ratings.

In-app is the only enabled delivery channel in this slice. Email/SMS abstractions exist (INotificationChannel, EmailNotificationChannel, SmsNotificationChannel) but are disabled by default and are genuine no-ops even if enabled - no real vendor (SendGrid/SES/Twilio/SNS/WhatsApp/ Firebase) is ever called anywhere in this codebase.

Vayzo v1 Production Notification Policy

Channel Status Runtime Config Production Setting
In-app Implemented + Enabled Marketplace:Notifications:InAppEnabled true
Email Not implemented + Disabled Marketplace:Notifications:EmailEnabled false
SMS Not implemented + Disabled Marketplace:Notifications:SmsEnabled false
WhatsApp Not implemented None N/A
Push Not implemented None N/A

External notification providers (Email, SMS, WhatsApp, Push) are strictly post-launch scope. Crucially, real external providers must not later be called synchronously inside transaction-bound NotifyAsync flows without designing proper transactional outbox and durable retry/dead-letter semantics. The current in-app database persistence and atomicity behavior must remain unchanged.

For the initial production launch, Kubernetes renders this exact policy through Marketplace__Notifications__InAppEnabled=true, Marketplace__Notifications__EmailEnabled=false, and Marketplace__Notifications__SmsEnabled=false. The production Helm chart fails closed if email or SMS is set to true before real provider dispatch exists.

MarketplaceNotification entity

MarketplaceNotification (ECommerce.Domain/Entities/MarketplaceNotification.cs) has no public mutable setters - every field changes only through a named, domain-guarded method, mirroring every other entity added in this codebase (ReturnRequest, ProductReview, etc.).

Id, RecipientUserId
Type, Title, Message
Status (Unread | Read)
ReferenceType, ReferenceId, ActionUrl
IdempotencyKey
CreatedAt, ReadAt

Deliberately just two statuses - no Sent/Delivered/Failed for the in-app record itself, since the row's existence already is the "sent" fact for the only enabled channel. MarkRead() is idempotent (a second call is a harmless no-op, never throws, and never overwrites the original ReadAt with a later timestamp).

Recipient ownership / security

RecipientUserId is always resolved server-side by the triggering command handler (e.g. Order. CustomerId, SellerOrder.SellerUserId) - never accepted from the client for notification creation. The client never supplies RecipientUserId, CustomerId, SellerUserId, NotificationType, ReferenceId, or ReadAt anywhere in the Notifications API surface.

A user may only list/read/mark-read their own notifications. Unlike ReturnAccess/ SellerOrderAccess/ShipmentAccess elsewhere in this codebase, NotificationAccess.LoadOwnedAsync (NotificationFeatures.cs) takes no IsAdmin parameter at all - there is no bypass for Admin here, by design (see Authorization tests).

ActionUrl safety

ActionUrl is always generated server-side from a hardcoded string template inside the triggering handler (e.g. $"/orders/{orderNumber}") - never built from client input. MarketplaceNotification.Create/NormalizeActionUrl additionally rejects, at construction time, anything that is not a genuine application-relative path: it must start with exactly one /, never //, and never contain a : (which rules out absolute URLs, protocol-relative URLs, and scheme-based payloads like javascript:). The frontend never treats ActionUrl as an arbitrary external URL either - NotificationItem only ever passes it to React Router's navigate().

Idempotency

The same business event for the same recipient produces at most one notification. IdempotencyKey carries a unique database index (MarketplaceNotificationConfiguration.HasIndex(x => x.IdempotencyKey).IsUnique()) - the real concurrency guard, not just an in-memory pre-check. MarketplaceNotificationRepository.TryAddAsync inserts and, on a duplicate-key violation (checked for both MySQL's "Duplicate entry" wording in production and SQLite's "UNIQUE constraint failed" in tests), swallows it and returns false rather than letting a raw DbUpdateException escape. INotificationService.NotifyAsync treats that false as a silent no-op - callers can call it unconditionally on every retry of the triggering business operation.

Key format (conceptual, not rigidly enforced beyond a 200-char bound):

order:{orderId}:placed:customer:{userId}
sellerorder:{sellerOrderId}:created:seller:{sellerUserId}
sellerorder:{sellerOrderId}:confirmed:customer:{customerId}
sellerorder:{sellerOrderId}:shipped:customer:{customerId}
sellerorder:{sellerOrderId}:delivered:customer:{customerId}
sellerorder:{sellerOrderId}:cancelled:customer:{customerId}
shipment:{shipmentId}:outfordelivery:customer:{customerId}
return:{returnRequestId}:requested:seller:{sellerUserId}
return:{returnRequestId}:approved:customer:{customerId}
return:{returnRequestId}:rejected:customer:{customerId}
return:{returnRequestId}:received:customer:{customerId}
refund:{refundTransactionId}:recorded:customer:{customerId}
settlement:{settlementId}:{eventTag}:seller:{sellerUserId}
review:{reviewId}:received:seller:{sellerUserId}
review:{reviewId}:hidden:customer:{customerId}
review:{reviewId}:restored:customer:{customerId}
support:{conversationId}:agent-requested:{staffUserId}
support:{conversationId}:assigned:{agentId}
support-message:{messageId}:customer-to-agent
support-message:{messageId}:agent-to-customer
support:{conversationId}:resolved:{customerId}
support:{conversationId}:closed:{customerId}

Customer support conversation notifications

Recipients for RequestHumanAgent are resolved server-side, never client-supplied: the handler looks up the CustomerSupport role via IRbacRepository.GetRoleByNameAsync and fans out to every currently active holder via IRbacRepository.GetActiveUserIdsForRoleAsync (a set-based SQL join, never "load every user and filter in memory"). The idempotency key is scoped to {conversationId}:agent-requested:{staffUserId} - repeated escalations of the same still-waiting conversation (the explicit "talk to agent" button, or the bot detecting the same intent mid-chat) never re-notify staff for the same business event; a genuinely new escalation only becomes possible once the conversation's business state moves it out of that queued condition. A customer message sent while merely WaitingForAgent (nobody assigned yet) intentionally does not notify anyone again - the original SupportAgentRequested fan-out remains the one queue-attention event.

SupportAgentRequested/SupportAgentAssigned/SupportCustomerMessage are staff-facing and deliberately not email-worthy in this tranche (staff work the live admin queue, GetAdminSupportQueueQuery); SupportAgentReply/SupportConversationResolved/ SupportConversationClosed are customer-facing and are email-worthy, since a customer may not be actively watching the chat widget when an agent responds or closes out a conversation.

Atomicity

INotificationService exposes two methods with deliberately different failure behavior - see Application/Common/Interfaces/INotificationService.cs:

  • NotifyAsync - failures propagate. Use only where the caller intentionally wants notification persistence to participate in its own atomic unit.
  • NotifyBestEffortAsync - identical, except any failure (other than the expected idempotent- duplicate no-op, which never throws in the first place) is caught, logged via ILogger<MarketplaceNotificationService>, and never rethrown.

Where the triggering handler already has an explicit database transaction open (VerifyAndCreateOrderCommandHandler, CreateReturnRequestCommandHandler, RejectReturnCommandHandler, InspectReturnCommandHandler), notification dispatch calls NotifyAsync, inside that same transaction, before tx.CommitAsync() - because IMarketplaceNotificationRepository is injected with the same scoped AppDbContext (and therefore the same underlying connection/transaction) as every other repository in that request, a notification-persistence failure there rolls back the whole business operation, and a successfully committed business change is never left with a silently-missing notification. This is deliberate and covered by dedicated tests (NotificationReliabilityTests.cs) proving the whole operation - not just the notification - rolls back.

This transaction-bound path is intentionally safe only because delivery channels do not perform external I/O. Do not add a direct SendGrid/SES/Twilio/SNS/WhatsApp/Firebase call behind NotifyAsync or INotificationChannel.DeliverAsync: a provider outage or timeout there could roll back checkout, returns, refunds, or settlement state. Real external delivery needs a separate outbox/worker/retry/dead-letter design before the channel flags can be enabled in production.

Where the triggering handler has no explicit transaction (most fulfilment/logistics/settlement/ review handlers - a deliberate choice not to redesign every existing handler solely for notifications), notification dispatch calls NotifyBestEffortAsync as a separate step issued right after the primary SaveChangesAsync() call. A failure there is caught and logged inside NotifyBestEffortAsync itself - it can never turn an already-committed business operation (e.g. a successful SellerOrder.Confirm()) into a failed response for the caller. This centralizes the "catch, log, never fail an already-successful operation" behavior in exactly one place (MarketplaceNotificationService.NotifyBestEffortAsync) rather than repeating a try/catch at every one of the dozen-plus call sites.

MarketplaceNotificationRepository.TryAddAsync's duplicate-key catch detaches only the failed notification entry (_db.Entry(notification).State = EntityState.Detached) - never ChangeTracker.Clear(), which would silently forget every OTHER entity already tracked on the same shared, scoped AppDbContext (e.g. a ReturnRequest a transaction-bound caller already saved earlier in the same ambient transaction). The DB unique constraint on IdempotencyKey remains the real idempotency guard either way - only the failure-handling scope changed.

Every INotificationService constructor parameter is optional (INotificationService? notifications = null) - real API wiring (DependencyInjection.cs) always supplies a real instance, but this lets every pre-existing test/call site that doesn't care about notifications keep compiling and passing unchanged, matching "do not redesign every marketplace handler solely for notifications."

Event matrix

Trigger Recipient Type
Verified checkout completes Customer OrderPlaced
Verified checkout completes Each seller with a line in the order SellerOrderCreated
Seller confirms Customer SellerOrderConfirmed
Seller ships (direct, or via Shipment reaching PickedUp) Customer SellerOrderShipped
Shipment reaches OutForDelivery Customer ShipmentOutForDelivery
Seller delivers (direct, or via Shipment reaching Delivered) Customer SellerOrderDelivered
Seller cancels Customer SellerOrderCancelled
Customer requests a return Owning seller ReturnRequested
Seller approves a return Customer ReturnApproved
Seller rejects a return Customer ReturnRejected
Return received by seller Customer ReturnReceived
Inspection completes / refund recorded Customer ReturnRefundRecorded
Settlement generated Owning seller SettlementGenerated
Settlement marked eligible / hold released Owning seller SettlementEligible
Settlement processing starts Owning seller SettlementProcessing
Settlement put on hold Owning seller SettlementOnHold
Settlement settled (paid out) Owning seller SettlementSettled
Verified review created Seller from the purchased SellerOrderLine ReviewReceived
Admin hides a review Review owner (customer) ReviewHidden
Admin restores a review Review owner (customer) ReviewRestored
Customer requests a human support agent (button or detected chat intent) Every active CustomerSupport role holder SupportAgentRequested
A support conversation is assigned to an agent The assigned agent SupportAgentAssigned
Customer sends a message while an agent is assigned The assigned agent SupportCustomerMessage
Agent replies in a support conversation The conversation's customer SupportAgentReply
Support conversation marked resolved The conversation's customer SupportConversationResolved
Support conversation closed The conversation's customer SupportConversationClosed

Deliberately not fired in this slice (avoids noise)

  • SellerOrderPacked / SellerOrderReadyToShip - intermediate fulfilment sub-states, not customer-facing milestones worth a notification.
  • Shipment InTransit - low-value noise between the "shipped" (PickedUp) and "out for delivery" milestones.
  • Return PickedUp / InTransit - mirrors the outbound choice; only Approved/Rejected/ Received/RefundRecorded are customer-facing return touchpoints in this slice.
  • Return ScheduleReturnPickupCommand - internal logistics scheduling step, not a customer touchpoint.

Shipment/fulfilment dedup - one authoritative trigger

SellerOrder can reach Shipped/Delivered via two independent code paths: the direct manual fulfilment commands (ShipSellerOrderCommandHandler/DeliverSellerOrderCommandHandler in OrderFeatures.cs) or the Logistics module's shipment-driven sync (MarkPickedUpCommandHandler/MarkDeliveredCommandHandler in LogisticsFeatures.cs, guarded by an if (sellerOrder.Status == ...) check so the sync - and its notification - only fires once). Both paths use the identical idempotency key for the same seller order and event (sellerorder:{id}:shipped:customer:{customerId} / sellerorder:{id}:delivered:customer:{ customerId}), so even if both were somehow reachable for the same seller order, the DB unique constraint guarantees at most one notification - but in practice the domain's own state guards make the two paths mutually exclusive per seller order (SellerOrder.Ship()/Deliver() only succeed once).

Customer notifications

  • Order placed - immediately after a verified checkout, referencing the Order and linking to /orders/{orderNumber}.
  • SellerOrder fulfilment - Confirmed, Shipped, Delivered, Cancelled only (see "avoids noise" above).
  • Shipment logistics - OutForDelivery only, beyond the Shipped/Delivered pair already covered by the fulfilment milestones above.
  • Returns/refunds - Approved, Rejected, Received, RefundRecorded.

Seller notifications

  • New SellerOrder on every checkout that includes one of their listings.
  • New return requested against one of their seller orders.
  • Settlement generated / status changed (Generated, Eligible, Processing, OnHold, Settled).
  • New verified product review, resolved from the actual purchased SellerOrderLine on the review (never broadcast to every seller who happens to list the same catalog Product). No PII (no customer name/email/id) appears in the message.

Refund wording

Every ReturnRefundRecorded notification states plainly:

A refund of {amount} for return {returnNumber} has been recorded in Vayzo. No automated Razorpay/bank refund is executed in this environment.

Never implies money has actually moved - mirrors the identical disclaimer already used in Marketplace Returns & Refunds and its frontend.

Notification APIs

GET  /api/notifications              status=unread|(omitted), page, pageSize (default 20, max 100)
GET  /api/notifications/{id}
GET  /api/notifications/unread-count
POST /api/notifications/{id}/read
POST /api/notifications/read-all

[Authorize] at the controller level, no Roles restriction - any authenticated user (customer, seller, or admin) reaches every action, and ownership (not role) decides what they can see. Results are always paginated, newest first - never the entire notification history unbounded.

DTO

{
  "id": "...",
  "type": "SellerOrderShipped",
  "title": "Your order has shipped",
  "message": "...",
  "status": "Unread",
  "referenceType": "SellerOrder",
  "referenceId": "...",
  "actionUrl": "/orders/ORD-...",
  "createdAt": "...",
  "readAt": null
}

IdempotencyKey is never exposed - internal operational plumbing only.

Stable errors

Code HTTP Meaning
NOTIFICATION_NOT_FOUND 404 No such notification
NOTIFICATION_FORBIDDEN 403 Not this user's notification

Marking an already-Read notification read again is idempotent success (200, same DTO), not an error - chosen consistently with INotificationService.NotifyAsync's own idempotent-retry model. Never a raw EF/MySQL error - see ExceptionHandlingMiddleware.cs.

Frontend

  • Navbar bell (components/notifications/NotificationBell.jsx) - unread-count badge, dropdown panel with the most recent notifications, "Mark all as read", "View all". Uses an accessible label (Notifications, N unread), never color alone.
  • Full page (/notifications, also mounted at /seller/notifications and /admin/notifications so sellers/admins reach the identical shared page from their own portal chrome - not a separate seller/admin notification system) - All/Unread tabs, pagination.
  • Clicking a notification marks it read, then navigates to its actionUrl via React Router's navigate() - never a raw external URL.
  • Polling: unread count refetches every 45s and on window focus (useUnreadNotificationCount); the recent-list panel refetches only when opened. No per-few-seconds polling anywhere.

Configuration

Marketplace:Notifications:InAppEnabled = true   (default true)
Marketplace:Notifications:EmailEnabled = false  (default false)
Marketplace:Notifications:SmsEnabled   = false  (default false)

Checkout/order/fulfilment/etc. functionality never fails solely because a channel is disabled - NotifyAsync always persists the notification row regardless of which channels are enabled; channels only affect whether DeliverAsync also runs (and every DeliverAsync implementation in this codebase is a no-op regardless).

Helm renders these as:

Marketplace__Notifications__InAppEnabled
Marketplace__Notifications__EmailEnabled
Marketplace__Notifications__SmsEnabled

There are deliberately no Notifications__Providers__*, WhatsApp, push, provider credential, or simulated-provider settings for the launch chart.

Channel abstraction

INotificationChannel (Application/Common/Interfaces/INotificationService.cs) - InApp, Email, Sms are all registered in DependencyInjection.cs. InAppNotificationChannel.DeliverAsync is a genuine no-op (the DB row MarketplaceNotificationService already persisted is the in-app delivery). EmailNotificationChannel/SmsNotificationChannel are disabled-by-default, genuine no-ops even if enabled - no real vendor is ever called, and neither ever marks a notification as "sent" by that channel (there is no such status to set - see "MarketplaceNotification entity" above).

Privacy

Seller-facing notifications (ReviewReceived, ReturnRequested, settlement notifications) never include customer PII (name/email/phone/address) - only order/return/settlement numbers and amounts. Customer-facing notifications never include other customers' data. IdempotencyKey is never exposed publicly.

Deferred work (explicitly out of scope for this slice)

  • Real email/SMS delivery - no SendGrid/SES/Twilio/SNS/WhatsApp/Firebase integration exists anywhere; EmailNotificationChannel/SmsNotificationChannel are documented, forward-compatible no-ops only.
  • Push notifications - no device-token/push-provider integration exists in this slice.
  • Real-time delivery (SignalR/WebSockets) - this slice uses lightweight polling only; no real-time transport exists yet.
  • Per-user notification preferences (mute/digest/channel opt-out) - every enabled channel currently applies uniformly to every recipient.
  • Bulk/broadcast notifications - every notification in this slice is a 1:1 side effect of one business event for one recipient; there is no admin-broadcast/marketing-notification feature.

Tests

  • Domain/MarketplaceNotificationTests.cs - construction, MarkRead idempotency, invalid recipient, unsafe ActionUrl rejection.
  • Api/NotificationsControllerAuthorizationTests.cs - reflection-based [Authorize] wiring.
  • Application/NotificationFlowTests.cs - full flow through the real command handlers: multi-seller checkout, fulfilment, returns, settlements, reviews; idempotency (including DB-unique-constraint level, not just in-memory); ownership/IDOR (UserA cannot read/mark-read UserB's notification, and an "admin" caller does not automatically bypass ownership, since NotificationAccess has no IsAdmin concept at all).
  • Application/NotificationReliabilityTests.cs - the failure-handling guarantees in "Atomicity" above: a duplicate-key failure detaches only the failed entry (not the whole ChangeTracker); NotifyBestEffortAsync swallows-and-logs a genuine repository failure while NotifyAsync (unwrapped) still propagates it; Confirm/Ship/Deliver/Cancel all succeed and persist even when their best-effort notification write fails; checkout/return-request/inspect-refund still roll back their WHOLE operation when their intentionally-atomic notification write fails; repeated calls (including through the best-effort path) never create a duplicate notification.