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.RatingPrisma 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:
RatingsServiceis 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
RetentionPurgeJobsweep. A 2027 review lives forever unless a moderator manually deletes it. - AML §14 — audit chain integrity.
rating.create+rating.moderate.deleteevents land, but thecommentfield is excluded from theafterpayload (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 /ratingsis 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:
assertSubjectExistswalks theInsurer/Agent/Producttables before creating a row so orphan ratings can't happen. ✓ - Unique reference codes:
generateRatingRef()gives every row an opaqueRTG-…handle for external correspondence without exposing the UUID. - Transactional summary rollup:
summary()runs the average- count + by-category groupBy in a single
$transactionso the numbers are consistent.
- count + by-category groupBy in a single
- Audit-chain hook on create + delete:
rating.create+rating.moderate.deleteevents land on the hash-chained trail with correct actor context (albeit without the comment body). - Permission split:
RatingRead/RatingWrite/RatingModerateare already three separate scopes — good RBAC scaffolding to build on.
Gaps
All 12 gaps closed. See shipped-notes below.
Release ratings-p1-20260719-0846. Closes gaps 1, 2, 3, 5:
- gap 1 — Migration
20260719050000_ratings_phase1addsRatingModerationStatusenum +moderationStatus(defaultpending_reviewfor new rows, backfilled toapprovedfor historical) +moderatedAt+moderatedBymoderationReason. NewPOST /ratings/:id/moderateendpoint accepts{ decision: approve|reject|redact, reason? }gated byRatingModerate.summary()+list()filter tomoderationStatus='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+commentHashcommentPiiDetected.RatingsService.createrunsdetectCommentPii()(E.164 +07xxphone, RFC-5322 email, Uganda NIN shape) — hits forcepending_reviewregardless of the default + emitrating.comment.pii_detectedaudit event. Comment is encrypted at rest viaPiiCipher(AES-256-GCM); a deterministic HMAC-SHA256 sibling lives oncommentHashfor lookup + duplicate detection. Aredactmoderation decision scrubs the encrypted comment- hash + stamps
piiScrubbedAt.
- gap 3 — Migration adds partial unique indexes
ratings_subject_user_key+ratings_subject_client_keyso a single rater identity can only carry one rating per (subject, rater) pair. Duplicate → 409IDEMPOTENCY_CONFLICTat the service boundary.POST /ratingsalso accepts an optionalIdempotency-Keyheader wired through the sharedIdempotencyService. - gap 5 — New
data_sharing.ratings_retention_daysoperational policy (default 2555 = 7 y AML §14 floor). Migration addsdeletedAt+piiScrubbedAt.RetentionPurgeJob.tickgains a sweep: past the window, soft-delete the row + scrub the encrypted comment sibling- hash + plaintext comment. Emits
rating.retention.purgedaudit event; the anonymised rating scalar (1-5) + subject - createdAt stay for the summary rollup's historical shape.
- hash + plaintext comment. Emits
Locked in by src/modules/ratings/ratings-phase1.spec.ts
(12 cases). Test suite: 632/632 (93 suites).
Release ratings-p2-20260719-1226. Closes gaps 4, 6, 7, 8:
- gap 4 — New
PublicRatingsControllerat/api/v1/public/ratings/subject/:subjectType/:subjectId/api/v1/public/ratings/summary/:subjectType/:subjectId.@Public()+@Throttle(60/min)+?jurisdiction=<iso>x-consent-tokenheader (mirrors Content Assets Phase 1 gap 4). Returns onlymoderationStatus='approved'+ non-retracted rows. Comments are decrypted viaPiiCipherthen passed throughredactCommentForPublicso residual phone / email / NIN slips render as[REDACTED:phone]etc. Every fetch emitsrating.public_fetch; non-UG without consent → 451 +rating.public_fetch.cross_border_denied.
- gap 6 —
@Throttleon every mutation:POST /ratings5/min,POST /:id/moderate20/min,POST /:id/retract5/min,DELETE /:id10/min. - gap 7 — Migration
20260719060000_ratings_phase2addsretractedAt+retractedBy. NewPOST /ratings/:id/retractendpoint (RatingWriteouter gate + service-sideisRater OR isModeratorcheck). Retracted rows drop out ofsummary,list, and the public read paths. Emitsrating.retracted_by_rater. - gap 8 —
rating.createauditafterpayload gainscommentHash(SHA-256 of the raw comment) +commentPrefix(first 200 chars, PII-redacted viaredactCommentForPublic) 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).
Release ratings-p3-20260719-1242. Closes gaps 9-12:
- gap 9 — Migration
20260719070000_ratings_phase3adds theRatingReportmodel +RatingReportReasonenum. NewPOST /ratings/:id/reportendpoint (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 anapprovedrating the service auto-flipsmoderationStatusback topending_review+ emitsrating.reported.abuseaudit event with the running total +quarantinedflag. - gap 10 — Migration adds
RatingReply+RatingReplyAuthorenum. NewPOST /ratings/:id/replyendpoint (RatingWrite- throttle 10/min). Service-side identity match:
agentauthorType requiresuser.agentId === rating.subjectId;insurerrequiresuser.insurerId === rating.subjectId; aRatingModerateactor can post aplatformreply. Replies enter the same moderation queue viaPOST /ratings/replies/:replyId/moderate(RatingModerate - throttle 20/min). Public read decorates each rating with
its
approved+ non-redacted replies (PII-redacted).
- throttle 10/min). Service-side identity match:
- gap 11 — Best-effort notification dispatch on create + on
approve/reject moderation via
NotificationDispatchService.dispatchByTemplate. Rater getsrating.submitted.rater_ack; moderated ratings dispatchrating.moderated.approveorrating.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 —
assertRaterQualifiedruns at everycreate()call. Refuses when there is noReferralrow touching the subject (agent path OR insurer path) with a 403RATER_NOT_QUALIFIED. Special-case: an agent user rating their own agent profile is always refused regardless of the escape hatch. Env flagRATINGS_ALLOW_UNRELATED_RATER=truebypasses 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 +
PiiCipherencryption + redaction on read + audit-payload hash), AML §14 (moderation lifecycle audit events +commentHash+commentPrefixpreserved 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.