Customer Grievance / Support Workflow¶
Purpose¶
A customer complaint/support-ticket workflow for the Vayzo marketplace: a customer files a
grievance (optionally against a specific order/seller order/order line), support acknowledges and
works it, a seller may be looped in when the issue concerns their order, and the case is resolved,
closed, or rejected - all through an explicit, closed state machine with a full audit trail. This
is an isolated tranche (feat/vayzomart-grievance, branched from
c74f21aa4796eaff23b9d0c37171d92169b4ca3b) built independently of, and without depending on or
modifying, the Dynamic RBAC work on feat/vayzomart-dynamic-rbac. See
Dynamic RBAC compatibility below.
Builds on the same conventions as Marketplace Returns & Refunds
(explicit state machine, server-derived ownership, append-only audit) and
LEG-01 Policy Registry & Acceptance Ledger, which already reserved a
GrievancePolicy PolicyType for this subsystem's eventual arrival.
State machine¶
Submitted -----------------------------------------------> Rejected (terminal)
|
v
Acknowledged --------------------------------------------> Rejected (terminal)
|
v
InProgress <--------------------------------+
| \ \ |
| \ \ |
v v v |
WaitingForCustomer WaitingForSeller Escalated
| | | |
+-------+---------------+-----------------+ (all resume to InProgress)
|
v
Resolved --------> Closed
^ | |
| +--- Reopen ------+ (within the configured reopen window)
+-------------------------------------------------------(Reopen also runs directly Closed->InProgress)
The transition table is a single closed Dictionary<GrievanceStatus, GrievanceStatus[]>
(Grievance.LegalTransitions, ECommerce.Domain/Entities/Grievance.cs) - every public mutation
method (Acknowledge, StartProgress, RequestCustomerInfo, RequestSellerInfo,
ResumeProgress, Escalate, Resolve, Close, Reject, Reopen) calls the same private
TransitionTo guard, so no status jump is possible outside this map, regardless of which
command/handler/role calls it. An illegal transition throws GrievanceInvalidTransitionException
(409).
Notes on specific edges:
- Acknowledgement is skippable-in-effect but not bypassable-in-record.
StartProgressonly succeeds fromAcknowledged, never directly fromSubmitted.AssignGrievanceCommandHandlertherefore auto-acknowledges when assigning aSubmittedgrievance (Grievance.AssignTo), so "acknowledged before worked" always holds in the audit trail even when a support agent picks up and starts a case in one motion. - Reject is only legal from
Submitted/Acknowledged- a grievance judged not to have been a valid complaint (duplicate, out of scope, spam) before real work began. OnceInProgressor later, the correct terminal move isResolve(even "we could not substantiate this" is a resolution, not a rejection) -Rejectedhas no reopen edge. - Reopen (spec requirement: "explicitly designed if supported") is legal from
ResolvedorClosed, back toInProgress, and only withinMarketplace:Grievances:ReopenWindowDaysdays of whichever ofResolvedAt/ClosedAtis later (Grievance.Reopen). It clearsResolution/ResolvedAt/ClosedAtfor a fresh resolution cycle but keeps the sameReferenceNumberand fullGrievanceAudithistory - one continuous case file, not a new ticket. Callable by the filing customer or staff (see Customer workflow). - Internal notes (
GrievanceMessage.IsInternal) never changeStatusthemselves and are restricted toSupport/Systemauthors at both the domain layer (Grievance.AddMessage) and the command handler (AddGrievanceMessageCommandHandler) - defense in depth, not a single point of failure.
Domain model¶
| Entity | Purpose |
|---|---|
Grievance |
The case itself - see fields below. |
GrievanceMessage |
One reply/note in the timeline (AuthorType: Customer/Seller/Support/System; IsInternal). Append-only. |
GrievanceAttachment |
Evidence metadata (see Private evidence storage). Append-only. GrievanceMessageId is a real FK to GrievanceMessage (GrievanceConfigurations.cs, Restrict delete behavior - both are soft-deleted only, so the FK's delete behavior is never actually exercised). |
GrievanceAudit |
Append-only history row: actor, action, timestamp, before/after JSON snapshot. |
Grievance fields of note:
ReferenceNumber-GRV-yyyyMMdd-XXXXXXXX, unique, generated server-side (mirrorsReturnRequest.ReturnNumber).CustomerUserId- the filing customer, from the authenticated caller only.SellerUserId(nullable) - never accepted from the client; derived server-side fromSellerOrder.SellerUserIdwhen aSellerOrderIdis supplied. See Order/seller ownership.OrderId/SellerOrderId/SellerOrderLineId(all nullable) - see below.SellerOrderLineIdis this codebase's closest analogue to a generic "OrderItemId": the legacy, pre-marketplaceOrder.Items(OrderItem) collection carries no seller attribution at all, so it cannot answer "does this concern Seller X" the waySellerOrderLine(the same granularityReturnRequest/RefundTransaction/SettlementAdjustmentalready use) can.Category- closed enum (GrievanceCategory: Order, Delivery, Return, Refund, Payment, Product, Seller, Account, Privacy, Other) - never arbitrary customer strings, per spec section 2.Priority-GrievancePriority(Low/Normal/High/Urgent). Changing it later never slidesDueAt(see SLA design).Status-GrievanceStatus, governed by the state machine above.DueAt- computed once at creation, immutable resolution-SLA commitment.Version- optimistic-concurrency token (see Concurrency).
Order/seller ownership¶
When OrderId/SellerOrderId/SellerOrderLineId are supplied at creation
(CreateGrievanceCommandHandler), every reference is verified server-side against the
authoritative order graph, exactly mirroring CreateReturnRequestCommandHandler:
Orderis loaded byOrderId;Order.CustomerIdmust equal the filing customer, orGrievanceOrderOwnershipException(400) is thrown - a customer can never attach another customer's order.- If
SellerOrderIdis also supplied, theSellerOrderis loaded and itsOrderIdmust match theOrderalready resolved in step 1 - a mismatched pair (a real order paired with an unrelated seller order id) is rejected the same way. Grievance.SellerUserIdis set fromSellerOrder.SellerUserId- the client never supplies a SellerId directly, closing the "client-supplied SellerId" attack the spec calls out explicitly.- If
SellerOrderLineIdis also supplied, it must be one of the resolvedSellerOrder.Lines- otherwise rejected.
A SellerOrderId/SellerOrderLineId supplied without an OrderId is rejected outright (both at
the domain constructor Grievance.Create and the command handler) - it could never be
ownership-verified in the first place.
Customer workflow¶
- Create:
POST /api/grievances- category/subject/description/priority, optional order/seller-order/line reference (verified as above). - List own:
GET /api/grievances/me(paginated, filterable by status/category). - Read:
GET /api/grievances/{id}- only their own grievance (GrievanceForbiddenException/403 otherwise). - Reply:
POST /api/grievances/{id}/messages(isInternalis rejected for a non-staff caller - see Internal notes). - Attach evidence:
POST /api/grievances/{id}/attachments. - Close a Resolved grievance (spec: "acknowledge resolution where appropriate"):
POST /api/grievances/{id}/close. - Reopen a Resolved/Closed grievance within the configured window:
POST /api/grievances/{id}/reopen.
A customer can never: view another customer's grievance, attach another customer's order, change
Status directly (beyond the two customer-facing close/reopen edges above, which are still gated
by the same state machine), assign staff, create or view internal notes, or touch audit history -
every one of these is enforced in GrievanceAccess/Grievance (Application/Domain layers), not
just hidden in the UI.
Seller workflow¶
A seller sees only grievances where Grievance.SellerUserId equals their own user id
(GET /api/grievances/seller, GrievanceAccess.LoadVisibleAsync). They may reply
(POST /api/grievances/{id}/messages) and attach evidence, exactly like a customer - a seller can
never see unrelated grievances, change the administrative Status/Resolution, assign support
staff, edit another party's messages, or alter the audit trail.
Admin/support workflow¶
Every mutation below is gated [Authorize(Roles = "Admin")] at the controller and re-checked via
GrievanceAccess.LoadForStaffAsync in the handler (defense in depth):
GET /api/admin/grievances- search/filter by status, category, priority, seller, assignee, created-date range, overdue-only, and free-text search (reference/subject) - all database-side, paginated (see Security / isolation).GET /api/admin/grievances/{id}/audit- the append-onlyGrievanceAudittrail.POST /api/admin/grievances/{id}/assign- also auto-acknowledges fromSubmitted.POST /api/admin/grievances/{id}/acknowledgePOST /api/admin/grievances/{id}/start(Acknowledged → InProgress)POST /api/admin/grievances/{id}/request-customer-info/request-seller-infoPOST /api/admin/grievances/{id}/resumePOST /api/admin/grievances/{id}/escalatePOST /api/admin/grievances/{id}/resolve(resolution text required)POST /api/admin/grievances/{id}/reject(reason required, only from Submitted/Acknowledged)POST /api/admin/grievances/{id}/close(shares the samePOST /api/grievances/{id}/closeroute the customer uses -GrievanceAccess.LoadOwnedByCustomerOrStaffAsyncdecides who may call it)POST /api/admin/grievances/{id}/priorityPOST /api/grievances/{id}/messageswithisInternal: true- internal notes.
List-query scalability: GET /api/admin/grievances (and the customer/seller list endpoints)
return GrievanceSummaryDto, not the full GrievanceDto - GrievanceRepository.QueryList()
deliberately does not .Include(g => g.Messages).Include(g => g.Attachments) the way the
detail query (QueryDetail(), used only by GetGrievanceByIdQuery) does. Eager-loading two
one-to-many collections on a paginated query multiplies rows (N grievances × M messages × K
attachments), and no list view renders the timeline anyway. Only GET /api/grievances/{id} loads
the full conversation/attachment history.
Internal notes / visibility¶
GrievanceMessage.IsInternal and GrievanceAttachment.IsInternal are only ever true when the
author/uploader is Support or System - enforced in three independent places: the domain
(Grievance.AddMessage/AddAttachment), the command handler
(AddGrievanceMessageCommandHandler), and implicitly by role gating on the controller. On read,
GrievanceAccess.VisibleMessages/VisibleAttachments filters internal items out of every
non-staff response - the backend filters, the frontend never has to (and never should) hide
them client-side. GetGrievanceAuditHistoryQuery is staff-only outright, not just filtered. See
also Security / isolation below.
SLA design¶
Marketplace:Grievances:ResolutionSlaHours (default 72), AcknowledgementSlaHours (default 24),
and ReopenWindowDays (default 7) are all configurable, read via GrievanceSla
(ECommerce.Application/Features/Grievances/GrievanceFeatures.cs). Unlike
ReturnsConfig.ReadWindowDays (which fails closed - a missing return window is a real business/
legal decision that must block returns), grievance SLA config is safe-default, not fail-closed:
a missing/invalid value never blocks a customer from filing or staff from working a case, since
customer support must always be reachable.
DueAt is computed once at creation (GrievanceSla.ComputeDueAt) from the configured resolution
SLA scaled by priority (Urgent 0.25×, High 0.5×, Normal 1×, Low 1.5× the base hours) and never
recomputed afterward - a later priority change does not silently move the commitment.
GrievanceSlaEvaluator (public, pure, deterministic - takes now as an explicit parameter, never
reads the wall clock itself) provides:
IsOverdue(status, dueAt, now)- pastDueAtand not yet Resolved/Closed/Rejected.IsAcknowledgementOverdue(createdAt, acknowledgedAt, ackSlaHours, now).AcknowledgementAgeing/ResolutionAgeing-TimeSpanhelpers for reporting.
None of this is ever computed or trusted from the frontend - GrievanceDto.IsOverdue is set by the
backend mapper, and the admin queue's overdueOnly filter is applied database-side
(IGrievanceRepository.GetAllAsync) using the identical rule.
Concurrency¶
Grievance.Version (int, default 1, incremented on every mutation) is an EF Core
IsConcurrencyToken() column - the exact same pattern as ProductCompliance.Version
(GrievanceConfiguration, mirroring ProductComplianceConfiguration). Two concurrent mutations
(e.g. support agent A resolves while agent B escalates) race at the database level: the first
SaveChangesAsync to commit wins, and the second's UPDATE ... WHERE Version = <stale> matches
zero rows, so EF Core throws DbUpdateConcurrencyException - one mutation wins, the other gets an
explicit conflict, never a silent overwrite.
Two layers:
- Application pre-flight (
GrievanceAccess.CheckVersion) - if the caller suppliesExpectedVersion, a stale value throwsGrievanceConcurrencyConflictException(409,GRIEVANCE_CONCURRENCY_CONFLICT) before any mutation is attempted. - Database backstop -
GrievancesController.Guarded<T>catchesDbUpdateConcurrencyExceptionfrom every mutation action (whether or notExpectedVersionwas supplied) and maps it to the same 409 shape.
See GrievanceFlowTests.ConcurrentTransitions_OneWins_OtherGetsDbConcurrencyException and
ExpectedVersion_Stale_ThrowsFriendlyConflict_BeforeAnyMutation.
Append operations participate too: Grievance.AddMessage/AddAttachment increment Version
exactly like a status transition (decided during the P1/P2 remediation tranche - see
GrievanceP1RemediationTests.AddMessage_Increments_Version), so two concurrent replies race on the
same optimistic-concurrency token as everything else. AddMessageCommand/AddAttachmentCommand
both accept an optional ExpectedVersion and go through the same GrievancesController.Guarded<T>
helper as every other mutation, so a DbUpdateConcurrencyException on an append is mapped to the
same 409 GRIEVANCE_CONCURRENCY_CONFLICT shape.
Private evidence storage¶
Grievance evidence is written through a dedicated IPrivateFileStorageService
(ECommerce.Application/Common/Interfaces/IServices.cs) - a separate contract from the public
IFileStorageService product images use, backed by R2PrivateFileStorageService (a mandatory,
distinct private bucket; LocalPrivateFileStorageService standing in outside R2 in Development only, same
Provider/Development gate as the public path). UploadPrivateAsync returns only an opaque
object key - GrievanceAttachment.StorageKey never holds a PublicBaseUrl-rooted or otherwise
publicly resolvable URL, and no GrievanceAttachmentDto ever serializes a Url/StorageKey field
to any caller (customer, seller, or staff) - see requirement 11 "do not expose raw object key
unnecessarily".
Outside Development, FileStorage:R2:PrivateBucketName is mandatory and startup fails if it is
empty or equals the public FileStorage:R2:BucketName. There is no fallback bucket and this path
never reads FileStorage:R2:PublicBaseUrl. In Development, LocalPrivateFileStorageService uses
IWebHostEnvironment.WebRootPath authoritatively (with fallback to ASPNETCORE_WEBROOT and <ContentRoot>/wwwroot),
enforcing canonical path containment so private files can never be stored within or traversed into the public webroot.
The private bucket is an operational security boundary enforced by scripts/r2-private-anonymous-denial.sh:
- Authoritative Cloudflare managed-domain API check:
GET /accounts/{account_id}/r2/buckets/{private_bucket}/domains/managed- Enforces
result.enabled == false. If enabled, deployment fails immediately. - Authoritative Cloudflare custom-domain API check:
GET /accounts/{account_id}/r2/buckets/{private_bucket}/domains/custom- Inspects every custom domain and requires 0 enabled public custom domains.
- Fail-closed guarantees:
- Deployment immediately fails on missing credentials, authentication errors (401/403), HTTP 500, network/DNS failure, timeout, malformed JSON, or
success != true. "Could not check" is never interpreted as "private". - Defense-in-depth anonymous object probe:
- Following successful API verification, uploads a unique probe object via authenticated S3 API, proves unauthenticated HTTP access is definitively denied (HTTP 401/403) across known S3 endpoints and optional candidate base URLs (
R2_PRIVATE_PUBLIC_BASE_URLS), deletes the probe, and only then allows Kubernetes deployment to proceed. - Secret and log hygiene:
- Tokens, secret access keys, and object keys are strictly redacted from deployment logs.
Run bash scripts/test-r2-private-deployment-gate.sh or python scripts/test-r2-private-deployment-gate.py to run the 15-case mock test suite.
The only way to read an attachment back is
GET /api/grievances/{grievanceId}/attachments/{attachmentId}/download
(GrievancesController.DownloadAttachment), which:
- Re-derives the caller's identity from
ICurrentUserService(never trusts a client claim). - Sends
GetGrievanceAttachmentDownloadQuerytoGetGrievanceAttachmentDownloadQueryHandler, which re-runs the SAME party-membership check every other read path uses (GrievanceAccess.LoadVisibleAsync- filing customer, associated seller, or staff) and THEN checksIsInternal: a customer/seller who is a legitimate party to the grievance is still denied an internal-only attachment; only staff may fetch one. - Streams the object back through the backend (
IPrivateFileStorageService.OpenReadAsync- option A from the remediation spec: "stream object through backend", chosen over a signed URL so the object's bucket/endpoint details never reach the client at all) withContent-Disposition: attachment,X-Content-Type-Options: nosniff, andCache-Control: private, no-store- never a redirect to a public/permanent link, never rendered inline.
Validation (FileValidationHelper.TryValidateEvidenceFile,
ECommerce.API/Helpers/FileValidationHelper.cs) is unchanged in spirit: real magic-byte sniffing
(JPEG/PNG/WEBP/PDF signatures) - never the client's claimed Content-Type or file extension -
with a single 10MB ceiling (MaxEvidenceFileSizeBytes) applied uniformly to every accepted format
(the remediation also fixed a pre-existing inconsistency where a >5MB JPEG/PNG/WEBP was silently
rejected below the advertised 10MB limit - see "File size policy" below). SVG/HTML/JS/executable
uploads are rejected by construction: they simply never match one of the four allow-listed magic-
byte signatures, so there is no explicit denylist to maintain or bypass. Uploaded filenames are
always server-generated GUIDs ({Guid.NewGuid():N}{ext}), so there is no path-traversal surface,
and ownership is verified before any upload is attempted (GrievanceAccess.LoadVisibleAsync, then
internal-note authorship checked separately).
Orphan cleanup: the object is written to private storage before the owning
GrievanceAttachment row commits (upload has to succeed to get a StorageKey to persist). If the
subsequent SaveChangesAsync fails for any reason (domain validation, a concurrency conflict, a
transient DB error), AddGrievanceAttachmentCommandHandler deletes the just-uploaded object via
IPrivateFileStorageService.DeleteAsync (best-effort - a cleanup failure never masks the original
exception) before rethrowing - see
GrievanceP1RemediationTests.AttachmentUpload_DbSaveFails_UploadedObjectIsDeleted.
File size policy: a single FileValidationHelper.MaxEvidenceFileSizeBytes (10MB) now governs
every accepted format - JPEG, PNG, WEBP, and PDF - both advertised (frontend copy, API error
message) and enforced (TryValidateEvidenceFile). Product image uploads elsewhere in the codebase
keep their own, separate 5MB MaxFileSizeBytes policy (TryValidateImage) - the two are
intentionally different limits for different upload surfaces, but each is now internally
consistent.
Malware scanning - documented gap, not implemented: GrievanceAttachmentScanStatus
(ECommerce.Domain/Enums/Enums.cs) and GrievanceAttachment.ScanStatus define the state a real
scanner would drive (NotScanned -> Clean/Flagged -> Quarantined), but no scanner is wired up in
this tranche - every attachment is created and stays NotScanned, and this column is not read by
any authorization/visibility decision anywhere. This is a deliberate, typed seam for a future
integration (a queued scan job that flips ScanStatus and, for Flagged/Quarantined, would need
its own visibility rule), not a claim that scanning exists today. Do not build a fake/heuristic
scanner against this column - see the remediation spec's "malware/quarantine" section.
Audit¶
Every security-sensitive action writes exactly one GrievanceAudit row via
GrievanceAuditRecorder.RecordAsync (submit, acknowledge, assign, every status transition,
priority change, customer/seller/support reply, internal note, escalation, resolution,
closure/reopen, attachment). Rows carry ActorUserId, ActorType, Action, and JSON
BeforeJson/AfterJson snapshots of only the fields relevant to that action. There is no
update/delete method anywhere on GrievanceAudit, no PUT/DELETE route in
GrievancesController, and reading the trail (GET /api/admin/grievances/{id}/audit) is
staff-only, unconditionally - customers/sellers can neither view nor modify it.
Actor correctness (P1-2 remediation): every staff-only command (AcknowledgeGrievanceCommand,
AssignGrievanceCommand, StartGrievanceProgressCommand, RequestCustomerInfoCommand,
RequestSellerInfoCommand, ResumeGrievanceProgressCommand, EscalateGrievanceCommand,
ResolveGrievanceCommand, RejectGrievanceCommand, ChangeGrievancePriorityCommand) now carries
an explicit ActorUserId, sourced ONLY from GrievancesController's ICurrentUserService.UserId
(the authenticated caller) - never accepted from the request body, and never defaulted to
AssignedToUserId or CustomerUserId (the pre-remediation bug: every staff audit row recorded
g.AssignedToUserId ?? g.CustomerUserId as the actor, which meant an admin acting on an unassigned
or customer-filed grievance produced a false audit trail attributing the action to the assignee or
the filing customer). AssignGrievanceCommand in particular keeps ActorUserId (who performed the
assignment, e.g. Admin A) and AssignToUserId (who it was assigned TO, e.g. Support B) as two
distinct fields - the audit row's ActorUserId is always the former, with the latter only ever
appearing inside the AfterJson snapshot. AddGrievanceMessageCommand/CloseGrievanceCommand/
ReopenGrievanceCommand already carried the correct UserId before this remediation (both
customer- and staff-callable, so they needed the real caller regardless) and are unchanged. See
GrievanceP1RemediationTests (AdminA_Assigns_ToSupportB_AuditActorIsAdminA_...,
AdminA_Resolves_UnassignedGrievance_ActorIsAdminA_..., AdminA_ChangesPriority_ActorIsAdminA,
Customer_AddingMessage_ActorIsCustomer_NeverSupportFallback).
Security / isolation¶
Verified explicitly by GrievanceFlowTests/GrievancesControllerAuthorizationTests:
- Customer A cannot read/act on Customer B's grievance (
GrievanceForbiddenException). - An unrelated seller cannot read/act on a grievance involving a different seller.
- A forged
OrderId(someone else's order) is rejected at creation (GrievanceOrderOwnershipException). - A forged
SellerOrderIdthat doesn't belong to the givenOrderIdis rejected. - A forged
SellerOrderLineIdthat doesn't belong to the givenSellerOrderis rejected. SellerIdis always derived server-side, never accepted from the client.- Internal notes/attachments never leak to a non-staff caller (filtered at the query-handler layer, not just hidden client-side) - see Internal notes / visibility above.
- Audit rows cannot be created, updated, or deleted through any public API.
- The status workflow cannot be bypassed (closed transition table).
- Invalid/unknown ids return 404 (
GrievanceNotFoundException) - fail closed. - Pagination is hard-clamped server-side (
GrievanceRepository.MaxPageSize = 100) regardless of what a client requests, and every filter is applied database-side (no unboundedToListbefore paging). - No mass assignment - every command is an explicit, narrow record with only the fields a given
role is allowed to set (e.g. a customer can never send
Status,AssignedToUserId, orSellerUserIddirectly).
Dynamic RBAC compatibility¶
Dynamic RBAC (feat/vayzomart-dynamic-rbac) is being developed independently and is not
modified, merged, or cherry-picked into this branch. Every staff-only check in this feature is
funneled through exactly two seams so a future permission system can replace them without touching
domain/application logic:
GrievancesController.IsSupportStaff(currently_currentUser.IsAdmin) - the single property every admin action reads to build its command'sIsSupportStaffargument.GrievanceAccess(ECommerce.Application/Features/Grievances/GrievanceFeatures.cs) - every ownership/visibility rule (LoadVisibleAsync,LoadForStaffAsync,LoadOwnedByCustomerOrStaffAsync) takes a plainbool isSupportStaff, never a role string or attribute.
Recommended future permission mapping (not implemented here):
| Permission | Where it would apply |
|---|---|
grievance.read.own |
GET /api/grievances/me, GET /api/grievances/seller, GET /api/grievances/{id} (own) |
grievance.create |
POST /api/grievances |
grievance.reply |
POST /api/grievances/{id}/messages (non-internal) |
grievance.support.read |
GET /api/admin/grievances, GET /api/admin/grievances/{id}/audit |
grievance.support.assign |
POST /api/admin/grievances/{id}/assign |
grievance.support.update |
acknowledge/start/request-info/resume/priority |
grievance.resolve |
POST /api/admin/grievances/{id}/resolve, .../close (staff path), .../reject |
grievance.escalate |
POST /api/admin/grievances/{id}/escalate |
grievance.internal-note |
POST /api/grievances/{id}/messages with isInternal: true, and attachments with isInternal: true |
Swapping IsSupportStaff to a real permission check, and adding a per-category or
per-assignment-scope refinement later, requires touching only GrievancesController - no change to
Grievance, GrievanceAccess, or any command handler.
Operational considerations¶
- No financial/refund behavior is touched. A grievance about a refund is purely a support conversation; any actual refund still goes through the existing Returns & Refunds flow, unchanged.
- No real notification provider is called - grievance events (
GrievanceSubmitted,GrievanceAcknowledged,GrievanceInfoRequested,GrievanceMessageReceived,GrievanceEscalated,GrievanceResolved,GrievanceClosed,GrievanceRejected, newMarketplaceNotificationTypevalues) go through the existing best-effortINotificationService.NotifyBestEffortAsyncpath - see Marketplace Notifications; no channel beyond in-app is live in this environment. - Migration is purely additive - four new tables (
Grievances,GrievanceMessages,GrievanceAttachments,GrievanceAudits), no existing table altered. Verified: fresh apply, rollback, re-apply, anddotnet ef migrations has-pending-model-changesclean against the existing dev database (see the PR/branch report for exact commands run). - Frontend: My Account → Help / Grievances (customer), Seller portal → Grievances, Admin
portal → Grievances. See
frontend/src/components/grievances/andfrontend/src/pages/{Grievances,Seller/Grievances,Admin/Grievances}/.