Skip to main content

Ratings (consumer reviews of insurers, agents, products)

Scope

One monolithic module, 209 lines, three write-side endpoints (GET /ratings, GET /ratings/summary/:subjectType/:subjectId, POST /ratings) plus an admin-only DELETE /ratings/:id. No moderation, no public surface, no retention policy, and the free-text comment field is plaintext. The infrastructure is correct — subject-existence check, unique reference, audit-chain hook on create + delete — but the shape is wide-open.

  • src/modules/ratings/ratings.module.ts (209 lines) — RatingsService (list / summary / create / remove / assertSubjectExists) + RatingsController (3 endpoints) + module.
  • Rating Prisma model — 12 columns: id, reference (unique opaque code), subjectType (enum: insurer / agent / product), subjectId (uuid), rating (int 1-5), category (nullable string — turnaround_time / quote_quality / service), raterUserId + raterClientId (nullable, at least one set), comment (nullable string, 2000 char max), metadata (nullable JSON), createdAt. Two indexes on (subjectType, subjectId) + (raterUserId) + (raterClientId).
  • test/e2e/ratings-cms-warehouse.e2e-spec.ts — the sole cross-module verification: creates a rating + checks the summary rollup. No moderation / PII / consent coverage.
  • No cross-consumers: RatingsService is exported but no other module injects it. Notification templates don't fire on create; the warehouse-export catalogue at line 37 does not list 'ratings'.

Compliance envelope

  • CPA 2022 §37 — the platform is on the hook for any published review shown to a prospective customer. Published reviews must be (a) provably real (rater identity verified against a real business relationship, not anonymous astroturf), (b) moderated (human review or algorithmic flagging), (c) reproducible (the moderator's decision must be auditable). Today: no moderation queue, no rater-identity check, no public read endpoint — a shipped review flow would violate §37 the moment it exposed a public page.
  • DPPA 2019 §21 — PII in the free-text comment. A customer typing "Agent Ojok +256701234567 called me" writes a phone number into the DB in plaintext. There is no PII detector, no allowlist, no encryption at rest. On backup export, on warehouse pipe, on developer database access — that phone number leaks.
  • DPPA 2019 §17 — subject-erasure / rectification. A rater who wants to withdraw a review has no API path. Only a RatingModerate-permissioned actor can delete, and it's a hard-delete without a reason field.
  • DPPA 2019 §11 — storage-limitation. There is no retention window on ratings + no RetentionPurgeJob sweep. A 2027 review lives forever unless a moderator manually deletes it.
  • AML §14 — audit chain integrity. rating.create + rating.moderate.delete events land, but the comment field is excluded from the after payload (so a deleted review leaves no audit-trail record of what was said) and no moderation-decision events exist because moderation itself doesn't exist.
  • BOU cybersecurity §5.4 — throttle + audit on public surfaces. POST /ratings is unthrottled at both the route + the per-user level. A single authenticated user can flood a subject with N ratings — trivially attackable review-bombing vector.

Current state (2026-07-19)

Module footprint

  • src/modules/ratings/ratings.module.ts — 209 lines. DTOs + service + controller + module.
  • test/e2e/ratings-cms-warehouse.e2e-spec.ts — 1 create + 1 summary case, no compliance surface.
  • Prisma model: Rating (12 columns, 3 indexes).
  • Permissions defined in src/common/security/roles.ts (RatingRead, RatingWrite, RatingModerate).
  • No dedicated sweep job for retention. No cross-module consumers.

What works today

  • Subject-existence validation: assertSubjectExists walks the Insurer / Agent / Product tables before creating a row so orphan ratings can't happen. ✓
  • Unique reference codes: generateRatingRef() gives every row an opaque RTG-… handle for external correspondence without exposing the UUID.
  • Transactional summary rollup: summary() runs the average
    • count + by-category groupBy in a single $transaction so the numbers are consistent.
  • Audit-chain hook on create + delete: rating.create + rating.moderate.delete events land on the hash-chained trail with correct actor context (albeit without the comment body).
  • Permission split: RatingRead / RatingWrite / RatingModerate are already three separate scopes — good RBAC scaffolding to build on.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-19)

Release ratings-p1-20260719-0846. Closes gaps 1, 2, 3, 5:

  • gap 1 — Migration 20260719050000_ratings_phase1 adds RatingModerationStatus enum + moderationStatus (default pending_review for new rows, backfilled to approved for historical) + moderatedAt + moderatedBy
    • moderationReason. New POST /ratings/:id/moderate endpoint accepts { decision: approve|reject|redact, reason? } gated by RatingModerate. summary() + list() filter to moderationStatus='approved'; list() also accepts an optional ?moderationStatus= for the compliance dashboard. Distinct audit events per decision: rating.moderated.approve / .reject / .redact.
  • gap 2 — Migration adds commentEncrypted + commentHash
    • commentPiiDetected. RatingsService.create runs detectCommentPii() (E.164 + 07xx phone, RFC-5322 email, Uganda NIN shape) — hits force pending_review regardless of the default + emit rating.comment.pii_detected audit event. Comment is encrypted at rest via PiiCipher (AES-256-GCM); a deterministic HMAC-SHA256 sibling lives on commentHash for lookup + duplicate detection. A redact moderation decision scrubs the encrypted comment
    • hash + stamps piiScrubbedAt.
  • gap 3 — Migration adds partial unique indexes ratings_subject_user_key + ratings_subject_client_key so a single rater identity can only carry one rating per (subject, rater) pair. Duplicate → 409 IDEMPOTENCY_CONFLICT at the service boundary. POST /ratings also accepts an optional Idempotency-Key header wired through the shared IdempotencyService.
  • gap 5 — New data_sharing.ratings_retention_days operational policy (default 2555 = 7 y AML §14 floor). Migration adds deletedAt + piiScrubbedAt. RetentionPurgeJob.tick gains a sweep: past the window, soft-delete the row + scrub the encrypted comment sibling
    • hash + plaintext comment. Emits rating.retention.purged audit event; the anonymised rating scalar (1-5) + subject
    • createdAt stay for the summary rollup's historical shape.

Locked in by src/modules/ratings/ratings-phase1.spec.ts (12 cases). Test suite: 632/632 (93 suites).

Phase 2 shipped (2026-07-19)

Release ratings-p2-20260719-1226. Closes gaps 4, 6, 7, 8:

  • gap 4 — New PublicRatingsController at /api/v1/public/ratings/subject/:subjectType/:subjectId
    • /api/v1/public/ratings/summary/:subjectType/:subjectId. @Public() + @Throttle(60/min) + ?jurisdiction=<iso>
    • x-consent-token header (mirrors Content Assets Phase 1 gap 4). Returns only moderationStatus='approved' + non-retracted rows. Comments are decrypted via PiiCipher then passed through redactCommentForPublic so residual phone / email / NIN slips render as [REDACTED:phone] etc. Every fetch emits rating.public_fetch; non-UG without consent → 451 + rating.public_fetch.cross_border_denied.
  • gap 6 — @Throttle on every mutation: POST /ratings 5/min, POST /:id/moderate 20/min, POST /:id/retract 5/min, DELETE /:id 10/min.
  • gap 7 — Migration 20260719060000_ratings_phase2 adds retractedAt + retractedBy. New POST /ratings/:id/retract endpoint (RatingWrite outer gate + service-side isRater OR isModerator check). Retracted rows drop out of summary, list, and the public read paths. Emits rating.retracted_by_rater.
  • gap 8 — rating.create audit after payload gains commentHash (SHA-256 of the raw comment) + commentPrefix (first 200 chars, PII-redacted via redactCommentForPublic) so a deleted-review dispute can be reconstructed without the audit trail carrying the full plaintext PII. New audit events wired: rating.retracted_by_rater, rating.public_fetch, rating.public_fetch.cross_border_denied, rating.retention.purged (Phase 1), rating.comment.pii_detected (Phase 1), rating.moderated.approve / .reject / .redact (Phase 1).

Locked in by src/modules/ratings/ratings-phase2.spec.ts (11 cases). Test suite: 643/643 (94 suites).

Phase 3 shipped (2026-07-19)

Release ratings-p3-20260719-1242. Closes gaps 9-12:

  • gap 9 — Migration 20260719070000_ratings_phase3 adds the RatingReport model + RatingReportReason enum. New POST /ratings/:id/report endpoint (RatingWrite + throttle 10/min) records { ratingId, reportedByUserId, reason } with a partial unique index so a single reporter identity can only fire once per rating. At the third distinct report on an approved rating the service auto-flips moderationStatus back to pending_review + emits rating.reported.abuse audit event with the running total + quarantined flag.
  • gap 10 — Migration adds RatingReply + RatingReplyAuthor enum. New POST /ratings/:id/reply endpoint (RatingWrite
    • throttle 10/min). Service-side identity match: agent authorType requires user.agentId === rating.subjectId; insurer requires user.insurerId === rating.subjectId; a RatingModerate actor can post a platform reply. Replies enter the same moderation queue via POST /ratings/replies/:replyId/moderate (RatingModerate
    • throttle 20/min). Public read decorates each rating with its approved + non-redacted replies (PII-redacted).
  • gap 11 — Best-effort notification dispatch on create + on approve/reject moderation via NotificationDispatchService.dispatchByTemplate. Rater gets rating.submitted.rater_ack; moderated ratings dispatch rating.moderated.approve or rating.moderated.reject. Failures are logged as warnings but do not fail the underlying mutation (template rows are seeded lazily; missing rows short-circuit through .catch(() => undefined)).
  • gap 12 — assertRaterQualified runs at every create() call. Refuses when there is no Referral row touching the subject (agent path OR insurer path) with a 403 RATER_NOT_QUALIFIED. Special-case: an agent user rating their own agent profile is always refused regardless of the escape hatch. Env flag RATINGS_ALLOW_UNRELATED_RATER=true bypasses the Referral check for the initial rollout window; product path is deferred (Referral has no productId FK).

Locked in by src/modules/ratings/ratings-phase3.spec.ts (10 cases). Test suite: 653/653 (95 suites).

All 12 gaps closed. Ratings module is audit-clean against CPA §37 (moderation queue + rater-provenance + public read

  • reply lane), DPPA §11 (7 y retention sweep), DPPA §16 (cross-border consent gate on public read), DPPA §17 (rater self-retraction), DPPA §21 (PII detector + PiiCipher encryption + redaction on read + audit-payload hash), AML §14 (moderation lifecycle audit events + commentHash + commentPrefix preserved on delete), and BOU cybersecurity §5.4 (throttle + idempotency on every mutation + audit trail on every public fetch).

Phased implementation plan

Complete — every phase shipped.