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.

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}

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.

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

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 AkshayaBazaar. 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).

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.