Skip to content

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. StartProgress only succeeds from Acknowledged, never directly from Submitted. AssignGrievanceCommandHandler therefore auto-acknowledges when assigning a Submitted grievance (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. Once InProgress or later, the correct terminal move is Resolve (even "we could not substantiate this" is a resolution, not a rejection) - Rejected has no reopen edge.
  • Reopen (spec requirement: "explicitly designed if supported") is legal from Resolved or Closed, back to InProgress, and only within Marketplace:Grievances:ReopenWindowDays days of whichever of ResolvedAt/ClosedAt is later (Grievance.Reopen). It clears Resolution/ResolvedAt/ClosedAt for a fresh resolution cycle but keeps the same ReferenceNumber and full GrievanceAudit history - one continuous case file, not a new ticket. Callable by the filing customer or staff (see Customer workflow).
  • Internal notes (GrievanceMessage.IsInternal) never change Status themselves and are restricted to Support/System authors 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 (mirrors ReturnRequest.ReturnNumber).
  • CustomerUserId - the filing customer, from the authenticated caller only.
  • SellerUserId (nullable) - never accepted from the client; derived server-side from SellerOrder.SellerUserId when a SellerOrderId is supplied. See Order/seller ownership.
  • OrderId / SellerOrderId / SellerOrderLineId (all nullable) - see below. SellerOrderLineId is this codebase's closest analogue to a generic "OrderItemId": the legacy, pre-marketplace Order.Items (OrderItem) collection carries no seller attribution at all, so it cannot answer "does this concern Seller X" the way SellerOrderLine (the same granularity ReturnRequest/ RefundTransaction/SettlementAdjustment already 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 slides DueAt (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:

  1. Order is loaded by OrderId; Order.CustomerId must equal the filing customer, or GrievanceOrderOwnershipException (400) is thrown - a customer can never attach another customer's order.
  2. If SellerOrderId is also supplied, the SellerOrder is loaded and its OrderId must match the Order already resolved in step 1 - a mismatched pair (a real order paired with an unrelated seller order id) is rejected the same way.
  3. Grievance.SellerUserId is set from SellerOrder.SellerUserId - the client never supplies a SellerId directly, closing the "client-supplied SellerId" attack the spec calls out explicitly.
  4. If SellerOrderLineId is also supplied, it must be one of the resolved SellerOrder.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 (isInternal is 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-only GrievanceAudit trail.
  • POST /api/admin/grievances/{id}/assign - also auto-acknowledges from Submitted.
  • POST /api/admin/grievances/{id}/acknowledge
  • POST /api/admin/grievances/{id}/start (Acknowledged → InProgress)
  • POST /api/admin/grievances/{id}/request-customer-info / request-seller-info
  • POST /api/admin/grievances/{id}/resume
  • POST /api/admin/grievances/{id}/escalate
  • POST /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 same POST /api/grievances/{id}/close route the customer uses - GrievanceAccess.LoadOwnedByCustomerOrStaffAsync decides who may call it)
  • POST /api/admin/grievances/{id}/priority
  • POST /api/grievances/{id}/messages with isInternal: 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) - past DueAt and not yet Resolved/Closed/Rejected.
  • IsAcknowledgementOverdue(createdAt, acknowledgedAt, ackSlaHours, now).
  • AcknowledgementAgeing / ResolutionAgeing - TimeSpan helpers 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:

  1. Application pre-flight (GrievanceAccess.CheckVersion) - if the caller supplies ExpectedVersion, a stale value throws GrievanceConcurrencyConflictException (409, GRIEVANCE_CONCURRENCY_CONFLICT) before any mutation is attempted.
  2. Database backstop - GrievancesController.Guarded<T> catches DbUpdateConcurrencyException from every mutation action (whether or not ExpectedVersion was 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:

  1. Authoritative Cloudflare managed-domain API check:
  2. GET /accounts/{account_id}/r2/buckets/{private_bucket}/domains/managed
  3. Enforces result.enabled == false. If enabled, deployment fails immediately.
  4. Authoritative Cloudflare custom-domain API check:
  5. GET /accounts/{account_id}/r2/buckets/{private_bucket}/domains/custom
  6. Inspects every custom domain and requires 0 enabled public custom domains.
  7. Fail-closed guarantees:
  8. 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".
  9. Defense-in-depth anonymous object probe:
  10. 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.
  11. Secret and log hygiene:
  12. 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:

  1. Re-derives the caller's identity from ICurrentUserService (never trusts a client claim).
  2. Sends GetGrievanceAttachmentDownloadQuery to GetGrievanceAttachmentDownloadQueryHandler, which re-runs the SAME party-membership check every other read path uses (GrievanceAccess.LoadVisibleAsync - filing customer, associated seller, or staff) and THEN checks IsInternal: a customer/seller who is a legitimate party to the grievance is still denied an internal-only attachment; only staff may fetch one.
  3. 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) with Content-Disposition: attachment, X-Content-Type-Options: nosniff, and Cache-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 SellerOrderId that doesn't belong to the given OrderId is rejected.
  • A forged SellerOrderLineId that doesn't belong to the given SellerOrder is rejected.
  • SellerId is 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 unbounded ToList before 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, or SellerUserId directly).

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:

  1. GrievancesController.IsSupportStaff (currently _currentUser.IsAdmin) - the single property every admin action reads to build its command's IsSupportStaff argument.
  2. GrievanceAccess (ECommerce.Application/Features/Grievances/GrievanceFeatures.cs) - every ownership/visibility rule (LoadVisibleAsync, LoadForStaffAsync, LoadOwnedByCustomerOrStaffAsync) takes a plain bool 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, new MarketplaceNotificationType values) go through the existing best-effort INotificationService.NotifyBestEffortAsync path - 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, and dotnet ef migrations has-pending-model-changes clean 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/ and frontend/src/pages/{Grievances,Seller/Grievances,Admin/Grievances}/.