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 viaILogger<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; onlyApproved/Rejected/Received/RefundRecordedare 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
Orderand linking to/orders/{orderNumber}. - SellerOrder fulfilment -
Confirmed,Shipped,Delivered,Cancelledonly (see "avoids noise" above). - Shipment logistics -
OutForDeliveryonly, 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
SellerOrderLineon the review (never broadcast to every seller who happens to list the same catalogProduct). 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/notificationsand/admin/notificationsso 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
actionUrlvia React Router'snavigate()- 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/SmsNotificationChannelare 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,MarkReadidempotency, invalid recipient, unsafeActionUrlrejection.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 (UserAcannot read/mark-readUserB's notification, and an "admin" caller does not automatically bypass ownership, sinceNotificationAccesshas noIsAdminconcept at all).Application/NotificationReliabilityTests.cs- the failure-handling guarantees in "Atomicity" above: a duplicate-key failure detaches only the failed entry (not the wholeChangeTracker);NotifyBestEffortAsyncswallows-and-logs a genuine repository failure whileNotifyAsync(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.
Related pages¶
- Application Architecture
- Seller Order Fulfilment - the
SellerOrderstate transitions this slice notifies on - Marketplace Logistics - the
Shipmentmilestones this slice notifies on, and the SellerOrder-sync dedup relationship - Marketplace Returns & Refunds - the return/refund workflow this slice notifies on, and the refund-wording disclaimer this slice's message mirrors
- Payments & Seller Settlements - the settlement lifecycle this slice notifies on
- Product Reviews & Ratings - the verified-purchase review workflow this slice notifies on
- Marketplace Reporting & Dashboards - reports the same underlying business events in aggregate rather than as individual notifications