EP-010 — CRM, Loyalty & Campaigns RTM
EPIC: EP-010 CRM, Loyalty & Campaigns Reconciled: 2026-05-25 Pattern: see
./PLAYBOOK.md. Wave 1 of the 21-EPIC fan-out; follows the./EP-002-rtm.mdpilot template. Source of truth for "% passing": flow-testshmp-infrastructure/testing/flow-tests/src/flows/loyalty-program.flow.spec.ts,staff-inbox.flow.spec.ts,whatsapp-feedback.flow.spec.ts.
Status legend
| Status | Meaning |
|---|---|
Built | Code exists + tests exist + tests pass |
Built-Untested | Code exists, no automated test coverage (or test exists but is a stub) |
Partial | Code exists for some acceptance criteria, gaps for others |
Stub | Code is a placeholder / TODO / returns mock data |
Not Started | No code |
Summary
| Metric | Count |
|---|---|
| Total stories | 19 |
| Catalog claims "Built" | 9 |
| Catalog claims "Partial" | 4 |
| Catalog claims "Not Started" | 6 |
| Verified-Built | 2 (US-010-01, US-010-13) |
| Built-Untested | 0 |
| Partial | 13 (US-010-02, -03, -04, -06, -07, -08, -09, -10, -11, -15, -17, -18, +US-010-19 scaffold) |
| Stub | 0 |
| Not Started | 4 (US-010-05, -12, -14, -16) |
| Drift findings | 15 (D-001..D-015; 7 P0 / 7 P1 / 1 P2) |
| [SME-PROPOSED] refinements | 34 |
ID convention drift (D-001 below): catalog uses
US-010-NN(2-digit). The review files underreviews/EP-010/useUS-010-NNN(3-digit). This RTM follows the catalog; review-file references use the existing 3-digit names (-001..-009 reviews map to catalog -01..-09).
US-010-01 — Define Loyalty Tiers
Catalog status: Built (P0, 5 points) Verified status: Built (with significant validation gaps per review)
Code locations:
hmp-crm-service/src/loyalty/loyalty.service.ts— Programs + tiers domain service (createProgram, createTier, updateProgram, getProgram withtiers: true, rewards: true)hmp-crm-service/src/loyalty/loyalty.controller.ts:32-110—@Roles('SUPER_ADMIN','ADMIN','MANAGER'),@RequiresFeature('crm.loyalty'), GET/POST/loyalty/programs, GET/PATCH/loyalty/programs/:id, GET/loyalty/tiers, GET/loyalty/tiers/:programId, POST/loyalty/tiershmp-crm-service/src/loyalty/dto/loyalty.dto.ts— CreateProgramDto, CreateTierDtohmp-crm-service/src/loyalty/tier-review.service.ts— Annual tier-review cron (IST timezone)hmp-web-dashboard/src/app/(dashboard)/crm/loyalty/page.tsx— Tiers tab withTierCardgrid
Unit + integration tests:
hmp-crm-service/src/loyalty/loyalty.service.spec.ts— 4 tests (program create, earn+balance, auto-upgrade, redeem rejection)hmp-crm-service/src/loyalty/tier-review.service.spec.ts— Annual tier-review cron tests
Flow tests:
hmp-infrastructure/testing/flow-tests/src/flows/loyalty-program.flow.spec.ts:: "Scenario 1 — enroll new loyalty member in seeded program + Silver tier" — exercises program/tier seed indirectly (TBD — feature-flag gated, soft-skip ifcrm.loyaltyoff)
Use cases referencing this story: none
SME notes:
- Catalog title says 4 tiers (Silver/Gold/Platinum/Diamond); seed ships only Silver/Gold/Platinum per
loyalty-program.flow.spec.tsheader. EPIC body §3.1 says "Multi-tier loyalty program (Silver, Gold, Platinum, Diamond)" — Diamond is missing from the seed. Story Gherkin example uses generic "Gold" only. - Review file
US-010-001-review.mdflags 12 gaps including missing@@unique([programId, name]), no overlap validation, no PATCH/DELETE tier endpoints, notenantIdscoping on queries (P0 security), no Kafka events. LoyaltyTier.minPoints/maxPointsis a Number range — there is no DB CHECK constraint preventing overlapping ranges (Silver 0-999+Bronze 500-1499). TheearnPointstier-resolver picksorderBy minPoints descwhich is non-deterministic under overlap.
Drift findings:
D-002 — [P0] [Missing-validation + tenant-isolation] LoyaltyService.createTier does not enforce per-program tier name uniqueness (no @@unique([programId, name]) in Prisma; no service-side check), nor overlapping point-range rejection, nor tenant scoping on loyalty/programs/:id GET (the controller passes tenantId but the service does not consistently filter by it — see review GAP-5). Silent overlap → non-deterministic tier upgrade.
- [SME-PROPOSED] Add AC: "Creating two tiers with the same
namewithin oneprogramIdSHALL return 409 withcode=LOYALTY_TIER_DUPLICATE_NAME. Two tiers with overlapping[minPoints, maxPoints]ranges SHALL return 409 withcode=LOYALTY_TIER_RANGE_OVERLAP. Both rules enforced by service-layer check + Prisma@@unique([programId, name])constraint." - [SME-PROPOSED] Add AC: "Every loyalty read/write endpoint SHALL scope by
tenantIdfrom JWT. A request with a forgedprogramIdbelonging to tenant-B from tenant-A's JWT SHALL return 404 (not 403 — no row-existence signal)."
US-010-02 — Member Enrollment
Catalog status: Built (P0, 5 points) Verified status: Partial — review verdict: 3/10 ACs pass
Code locations:
hmp-crm-service/src/loyalty/loyalty.service.ts—enrollMember(tenantId, dto),earnPoints(memberId, dto)(tier auto-upgrade with Kafkaloyalty.tier.changed)hmp-crm-service/src/loyalty/loyalty.controller.ts— POST/loyalty/members, GET/loyalty/members, GET/loyalty/members/:id, POST/loyalty/members/:id/earnhmp-crm-service/src/loyalty/portal/loyalty-portal.controller.ts— booking-engine self-enrollment surface (stub per review)hmp-crm-service/src/loyalty/dto/loyalty.dto.ts— EnrollMemberDto, EarnPointsDtohmp-web-dashboard/src/app/(dashboard)/crm/loyalty/page.tsx— Members tab (search, tier/status filter, paginated table)
Unit + integration tests:
hmp-crm-service/src/loyalty/loyalty.service.spec.ts— 2 relevant tests (earn+balance, auto-upgrade)hmp-crm-service/src/loyalty/portal/loyalty-portal.service.spec.ts— 5 portal tests
Flow tests:
hmp-infrastructure/testing/flow-tests/src/flows/loyalty-program.flow.spec.ts:: "Scenario 1 — enroll a new loyalty member" — TBD (crm.loyaltyfeature-flag gated)
Use cases referencing this story: none
SME notes (review file US-010-002-review.md):
- Membership number format is
LM-{timestamp}-{random}; story specifiesHMP-LY-XXXXX/HMP-XXXXXXXX. Review GAP-1 (MEDIUM). EnrollMemberDtolacksguestId→Guest.loyaltyMemberIdnever linked; profile→member resolution broken in dashboard.- No duplicate-enrollment check, no inactive-program check, no Kafka
loyalty.member.enrolledevent. - Indian-compliance gap: AC-6 ("GST excluded from points calculation") is NOT_IMPL —
earnPointsaccepts a pre-calculatedpointsvalue. Story Gherkin Scenario 3 explicitly requires "10000 INR room + 1800 INR GST (18%) → points on 10000 only". - No Kafka consumer for
folio.settled/billing.invoice.finalized(AC-10 NOT_IMPL) → stay-based points never auto-credit. This is the core "earn-from-stay" loop being absent.
Drift findings:
D-003 — [P0] [Cross-service-handoff-missing + India-compliance] No FolioSettledConsumer exists in crm-service; the entire stay-based earn loop relies on staff manually POSTing to /loyalty/members/:id/earn. AC-10 + AC-4 + AC-6 (GST exclusion) all hinge on this consumer. Story is marked "Built"; reality is a controller endpoint with no event wiring + no GST math.
- [SME-PROPOSED] Add AC: "crm-service SHALL register a
FolioSettledConsumersubscribing tofolio.settled(or canonicalFOLIO_POSTED/FOLIO_PAYMENT_POSTED) from frontdesk-service. The consumer SHALL: (a) computequalifyingAmount = totalAmount - (cgst + sgst + igst + serviceCharge); (b) apply tier multiplier; (c) credit points withexpiryDate = now + earningRules.pointsExpiryMonths(default 24); (d) publishloyalty.points.earnedwith dedup keyfolio:{folioId}:points-earn." - [SME-PROPOSED] Add AC: "
Guest.loyaltyMemberIdSHALL be updated atomically withLoyaltyMembercreate in a single$transaction.EnrollMemberDtoSHALL acceptguestId(UUID, required for staff enrolment) orphone+firstName+lastName(for self-enrolment without prior guest profile)."
US-010-03 — Points Earn Rules
Catalog status: Built (P0, 8 points) Verified status: Partial — caller-supplied points, no GST exclusion, no bonus rules
Code locations:
hmp-crm-service/src/loyalty/loyalty.service.ts—earnPoints, tier multiplier path (basic),loyalty.tier.changedKafka emithmp-crm-service/src/loyalty/dto/loyalty.dto.ts—EarnPointsDto
Unit + integration tests:
hmp-crm-service/src/loyalty/loyalty.service.spec.ts— earn+balance, auto-upgrade
Flow tests:
loyalty-program.flow.spec.ts:: "Scenario 2 — earn points (500 from a Room Stay)" — TBD
Use cases referencing this story: none
SME notes:
earningRulesis a JSON blob onLoyaltyProgram— no typed schema. Tenants can submit any shape; service consumers must parse defensively.- No direct-booking bonus, no birthday bonus, no first-stay bonus (all promised by EPIC §6 KPIs and story Scenarios 2-3).
- No idempotency on
POST /loyalty/members/:id/earn— replay risk via OTA retry.
Drift findings:
D-004 — [P1] [Story-incomplete + India-tax-critical] earningRules is an untyped JSON object on the program. The story specifies stay-based, spend-based, activity-based earn types and a Gold 1.5× multiplier, but the DB shape allows ANY structure. India-tax concern: there is no enforced field for excludeGstFromQualifyingSpend: boolean, even though the canonical Indian-hospitality model is points on pre-GST tariff only.
- [SME-PROPOSED] Add AC: "
earningRulesSHALL be a Zod-validated typed object with the discriminated-union shape{ kind: 'STAY' | 'SPEND' | 'ACTIVITY', ...details }and a top-levelqualifyingSpend: { includeGst: boolean (default false), includeServiceCharge: boolean (default false), excludedChargeCodes: string[] }. Validation runs atPOST /loyalty/programstime." - [SME-PROPOSED] Add AC: "
POST /loyalty/members/:id/earnSHALL acceptreferenceId(folio ID, reservation ID, or external receipt). The(memberId, referenceId)pair SHALL be unique; replays return the originalPointsTransactionrow (200, idempotent)."
US-010-04 — Points Redemption
Catalog status: Built (P0, 8 points) Verified status: Partial — review verdict: 2/10 ACs pass
Code locations:
hmp-crm-service/src/loyalty/loyalty.service.ts—redeemPoints(balance check + deduction;BadRequestExceptionon insufficient points)hmp-crm-service/src/loyalty/portal/loyalty-portal.service.ts— guest-facingredeemReward,getAvailableRewardswithaffordableflaghmp-crm-service/src/loyalty/portal/loyalty-portal.controller.ts— POST/loyalty/portal/redeem, GET/loyalty/portal/rewardshmp-crm-service/src/loyalty/dto/loyalty.dto.ts—RedeemPointsDto
Unit + integration tests:
loyalty.service.spec.ts— 1 redemption-rejection test (insufficient points)loyalty-portal.service.spec.ts— portal redeem coverage
Flow tests:
loyalty-program.flow.spec.ts:: "Scenario 3 — redeem 200 points" — TBD
Use cases referencing this story: none
SME notes (review file US-010-003-review.md):
- No
maxRedemptionPctcap (story AC-2 NOT_IMPL) — member can redeem 100% of folio. - No
minBalancecap (AC-3 NOT_IMPL) — member can drain to zero. - No optimistic locking on
pointsBalance(AC-10 NOT_IMPL) — two concurrentPOST /redeemcalls can double-deduct (P0 financial race). - No reverse-redemption on
reservation.cancelled(AC-7 NOT_IMPL) — booking cancellation leaves points spent. - No voucher generation (story Scenario 3 last-line says "voucher code generated + sent via SMS"); reward redemption returns a Prisma row with no voucher entity.
Drift findings:
D-005 — [P0] [Financial-correctness + concurrency] LoyaltyService.redeemPoints performs findUnique → decrement without optimistic locking, version column, or transactional balance assertion. Two concurrent redeem calls deduct twice. For an end-of-stay flow with parallel booking-engine + front-desk surfaces, this is a realistic race.
- [SME-PROPOSED] Add AC: "
redeemPointsSHALL execute in a Prisma$transactionusingupdate where: { id, pointsBalance: { gte: dto.points } }and assertcount === 1, returning 409 withcode=LOYALTY_INSUFFICIENT_OR_RACEotherwise. Spec covers two concurrent redemptions of 600 points each against a 1,000-balance member — exactly one MUST succeed." - [SME-PROPOSED] Add AC: "Cancellation reversal — a
ReservationCancelledConsumerSHALL credit back any REDEEM rows tagged with the cancelled reservation'sreferenceIdand publishloyalty.points.refunded(idempotent viareferenceId)."
US-010-05 — Points Expiry Processing
Catalog status: Not Started (P0, 5 points)
Verified status: Partial — service skeleton exists but EARN rows lack expiryDate
Code locations:
hmp-crm-service/src/loyalty/points-expiry.service.ts—PointsExpiryService(cron skeleton + DPDPA reference)hmp-crm-service/src/loyalty/points-expiry.service.spec.ts— spec exists
Unit + integration tests: spec file present; coverage of nightly expiry path is the operative gap.
Flow tests: none.
Use cases referencing this story: none
SME notes:
- Service file exists per
Bash lsbutLoyaltyService.earnPointsdoes not setexpiryDateon the PointsTransaction EARN row (per US-010-002 review GAP-7). Result: the expiry cron has nothing to expire. - Pre-expiry reminder (story AC-3, 30 days before) requires a separate scheduled scan + notification dispatch — not implemented.
Drift findings:
D-006 — [P1] [Catalog-vs-code-drift + half-built] Catalog says "Not Started" but points-expiry.service.ts + spec EXIST. However, the upstream earnPoints write path doesn't populate expiryDate, so the cron is decorative. Either declare the story Partial or remove the skeleton.
- [SME-PROPOSED] Promote catalog status from
Not Started→Partial. Add AC: "Every EARN PointsTransaction row SHALL carryexpiryDate = transactionDate + program.earningRules.pointsExpiryMonths(default 24). Nightly cron at 02:30 IST scans EARN rows whereexpiryDate < nowAND no offsetting EXPIRE row, writes an EXPIRE transaction, deducts the member balance, and publishesloyalty.points.expired." - [SME-PROPOSED] Add AC: "30-day pre-expiry reminder cron at 09:00 IST scans EARN rows with
expiryDatebetweennow+29dandnow+31d, dispatches one SMS/email per member via notification-service with{{points}} {{expiryDate}}template. Throttle: 1 reminder per member per 30 days regardless of multiple expiring batches."
US-010-06 — Points Transfer Between Members
Catalog status: Partial (P2, 5 points) Verified status: Partial — endpoint exists; fees, limits, audit, UI all missing per EPIC §10 story body
Code locations:
hmp-crm-service/src/loyalty/loyalty.controller.ts— controller surface (transfer endpoint per story body)hmp-crm-service/src/loyalty/loyalty.service.ts— transfer service path (validation: same tenant only)
Unit + integration tests: no dedicated transfer spec found in loyalty.service.spec.ts.
Flow tests: none.
Use cases referencing this story: none
SME notes:
- Story Gherkin Scenario 1 specifies a 10% transfer fee; no code enforces it. Scenario 2 specifies a monthly 5,000-point limit; no rate-limit table exists. Scenario 3 specifies OTP verification of recipient; auth-service has no OTP endpoint wired into the CRM transfer path.
- Tax exposure: in India, loyalty point transfers across non-related parties can trigger GST under Schedule II (transfer of right in goods). The story doesn't address this.
Drift findings: (no new D; documented in US-010-04 D-005 family — concurrency applies here too)
- [SME-PROPOSED] Add AC: "Transfers SHALL be limited to verified family members (recipient on the same
Guest.familyGroupId) OR require recipient OTP verification. Tax disclaimer in transfer UI: 'Point transfers may have GST implications; consult your tax advisor.'"
US-010-07 — Reward Catalog Management
Catalog status: Built (P1, 5 points) Verified status: Partial — list/create work; PATCH missing; inventory/availability gaps
Code locations:
hmp-crm-service/src/loyalty/loyalty.controller.ts— GET/loyalty/rewards, POST/loyalty/rewardshmp-crm-service/src/loyalty/loyalty.service.ts— reward CRUD (no PATCH per review GAP-9)hmp-crm-service/src/loyalty/portal/loyalty-portal.service.ts—getAvailableRewardswithaffordableboolean
Unit + integration tests: covered indirectly via portal tests.
Flow tests: none.
Use cases referencing this story: none
SME notes:
- No
availabilityWindowenforcement (story Scenario 2 — "Paused for dates 2026-04-01 to 2026-04-15") —isActiveboolean only. - No daily-cap inventory (story Scenario 3 — "max 2 redemptions per day") — no
redemptionsTodaycounter.
Drift findings: (none new — feeds D-005 redemption-control family)
- [SME-PROPOSED] Add AC: "
LoyaltyRewardSHALL gainpausedFrom/pausedUntildate fields andmaxRedemptionsPerDay. Redemption attempt outside availability or over cap returns 422 withcode=REWARD_UNAVAILABLE/REWARD_DAILY_CAP_REACHEDplus next-available-date."
US-010-08 — Campaign Creation (6 Types)
Catalog status: Built (P0, 8 points) Verified status: Partial — review verdict: 2/8 ACs pass
Code locations:
hmp-crm-service/src/campaigns/campaigns.controller.ts:34-161—@Roles('SUPER_ADMIN','ADMIN','MANAGER'),@RequiresFeature('crm.campaigns'), POST/GET/GET-by-id/DELETE/POST-pause/POST-launch/POST-execute/GET-recipients/GET-metrics. Tenant-id is read fromx-tenant-idheader (forbidden if missing).hmp-crm-service/src/campaigns/campaigns.service.ts—create,findOne,findAll,launch,execute,segmentRecipients,pause,remove,getMetricshmp-crm-service/src/campaigns/dto/campaign.dto.ts—CreateCampaignDto(type+channel as plain strings, no enum validation per review GAP-7)hmp-crm-service/src/campaigns/trai-dnd.service.ts— TRAI DND registry check servicehmp-web-dashboard/src/app/(dashboard)/crm/campaigns/page.tsx— list page with launch/pause/delete dropdown
Unit + integration tests:
hmp-crm-service/src/campaigns/campaigns.service.spec.ts— 15+ tests (segmentation, batch, status transitions)hmp-crm-service/src/campaigns/__tests__/campaign-cross-tenant-denial.spec.ts— 14 tests (Pick D Gold-Standard, includingsegmentRecipientsPII-leak guard)
Flow tests:
hmp-infrastructure/testing/flow-tests/src/flows/staff-inbox.flow.spec.ts— campaign-adjacent (staff communication inbox flow)
Use cases referencing this story: none
SME notes (review file US-010-004-review.md):
- No type-specific config validation (BIRTHDAY.daysBefore, FEEDBACK.daysAfterCheckout, TRANSACTIONAL.triggerEvent, NEWSLETTER.recurrencePattern, ANNIVERSARY.anniversaryType). Story body shows all five — code accepts any string in
segmentRulesJSON. - No PATCH endpoint, no clone endpoint.
- Security gap (Task #11): per the cross-tenant-denial spec header,
CampaignsService.createwrites the row using caller-supplied tenantId but does NOT verify that the DTO'spropertyIdortemplateIdbelongs to that tenant. A SUPER_ADMIN with a forgedpropertyIdwrites a Campaign attached to another tenant's property. Same gap applies totemplateId(template-channel mismatch also unchecked per review GAP-5). - Security gap (Task #11, findOne info-leaky):
findOnerejects cross-tenant reads withForbiddenExceptionrather thanNotFoundException, leaking row existence. The pattern inhmp-pos-serviceorders cross-tenant denial returns 404 — CRM should match (per the spec's own header comment, "this is information-leaky vs. the canonical pattern").
Drift findings:
D-007 — [P0] [Security + cross-service-validation] CampaignsService.create does not call PropertyServiceClient.assertPropertyTenant(propertyId, tenantId) nor verify templateId ownership. The cross-tenant-denial spec documents this as a known gap. Together with findOne returning 403 instead of 404, an attacker with a tenant-A JWT can probe foreign campaign IDs (existence-leak) and write rows pointing at foreign properties (data-pollution).
- [SME-PROPOSED] Add AC: "
POST /api/campaignsSHALL callPropertyServiceClient.assertPropertyTenant(dto.propertyId, tenantId)before insert. SHALL look updto.templateIdand asserttemplate.tenantId === tenantId AND template.channel === dto.channel. Either check failing returns 404 (not 403, not 422 — no existence/ownership signal)." - [SME-PROPOSED] Add AC: "
CampaignsService.findOnecross-tenant denial SHALL returnNotFoundException(404), matching thehmp-pos-service/src/orderscanonical pattern. The existing 403 path is a known regression tracked since the campaign-cross-tenant-denial.spec.ts was authored."
D-008 — [P1] [Indian-compliance + story-incomplete] trai-dnd.service.ts exists but the EPIC §10 story body for US-010-08 (and -10) does not specify the DND-check contract. TRAI's TCCCPR 2018 requires consent-based dispatch for promotional SMS to non-business numbers + DLT sender-ID validation. Missing from ACs.
- [SME-PROPOSED] Add AC: "All
PROMOTIONALandNEWSLETTERSMS campaigns SHALL be filtered throughTraiDndService.filterRecipients(numbers, headerCategory)before dispatch. Excluded numbers are recorded asskipped: 'DND_REGISTRY'in campaign metrics. DLT sender ID + template ID validation is enforced at the notification-service boundary (cross-EPIC: EP-014)."
US-010-09 — Communication Template Engine
Catalog status: Built (P0, 8 points) Verified status: Partial — review verdict: 2/8 ACs pass
Code locations:
- Templates engine lives in
hmp-notification-service(perUS-010-005-review.mdGAP-9 — path is/api/v1/notification/templatesnot/api/v1/crm/templates) - Shared package:
@hmp/template-engine(block-tree → MJML → HTML; 11 typed block types; Handlebars helpers for currency/IST date/pluralize per platform CLAUDE) - Shared package:
@hmp/notification-contracts(Zod schemas per event, 5-channel__fallbackstrings)
Unit + integration tests: per review file, no templates.service.spec.ts exists in notification-service (review GAP — 0 tests for template logic).
Flow tests: none.
Use cases referencing this story: none
SME notes (review file US-010-005-review.md):
- No auto-extraction of
{{variable}}patterns from body/subject (story AC-1, AC-2 NOT_IMPL). - No
languagefield inCommunicationTemplateschema (story AC-3 + AC-8 NOT_IMPL — Hindi/multi-language fallback impossible). - No SMS character-limit validation (story AC-4 NOT_IMPL) — 480-char threshold absent.
- No
versioncolumn → updates overwrite; running campaigns reference mutable templates (AC-7 NOT_IMPL). - No dashboard template management UI (review GAP-13).
- Path mismatch story-vs-code: story says
/api/v1/crm/templates; code lives at/api/v1/notification/templates. Gateway-routing decision.
Drift findings:
D-009 — [P1] [Multi-language + Indian-language-critical] No language column on CommunicationTemplate. Story Scenario 3 explicitly requires Hindi + English templates with fallback. India's 8th-schedule language coverage (HI/TA/TE/BN/MR/GU + EN) is a baseline requirement for Tier-2/3 hotel guest comms — currently impossible.
- [SME-PROPOSED] Add AC: "
CommunicationTemplateSHALL gainlanguage String @default('en')+version Int @default(1)columns.@@unique([propertyId, name, channel, language])constraint. Update path SHALL create a new version row (immutable history);campaign.templateVersionpins the version snapshot. Dispatch path resolves guest's preferred language with fallback chain(preferred → en)." - [SME-PROPOSED] Add AC: "Body SHALL be parsed with regex
/\\{\\{(\\w+)\\}\\}/g; extracted variables checked againstKNOWN_VARIABLESregistry; unknown variables return non-blockingwarnings[]in the create/update response."
US-010-10 — Multi-Channel Campaign Dispatch
Catalog status: Partial (P0, 13 points) Verified status: Partial — review verdict: 2/9 ACs pass
Code locations:
hmp-crm-service/src/campaigns/campaigns.service.ts—execute()(sync),executeBatch()(batch size 100), publishesnotification.send+campaign.batch.sendKafka eventshmp-crm-service/src/campaigns/trai-dnd.service.ts— DND checkhmp-notification-service— KafkaNOTIFICATION_SENDconsumer; 4 channel adapters (Email/SMS/WhatsApp/Push) per platform CLAUDE
Unit + integration tests:
campaigns.service.spec.ts— covers batch execute path
Flow tests:
hmp-infrastructure/testing/flow-tests/src/flows/whatsapp-feedback.flow.spec.ts— adjacent WhatsApp dispatch flow
Use cases referencing this story: none
SME notes (review file US-010-006-review.md):
executeBatch()sendscampaign.nameas message content, NOT a rendered template body (review GAP-1 HIGH).- No pause/resume mid-execution (execute is synchronous in a single request).
- No SendGrid/Twilio/Meta webhook consumers →
delivered/opened/clicked/convertedfields never updated post-dispatch. - No auto-pause on >50% batch failure rate.
- No TRAI DND enforcement on PROMOTIONAL SMS at dispatch time despite
trai-dnd.service.tsexisting.
Drift findings:
D-010 — [P0] [Critical-implementation-gap] executeBatch sends campaign.name as the literal message body. The campaign-level templateId is recorded but never resolved/rendered. Every campaign that "succeeds" today is delivering the campaign name string to guests — a real ops escape.
- [SME-PROPOSED] Add AC: "
executeBatchSHALL resolvecampaign.templateId(with version pin if D-009 lands), calltemplateEngine.render(template, { guest, booking, hotel, ...vars }), and publish the rendered body in the KafkaNOTIFICATION_SENDpayload. Spec asserts the payloadcontentfield is the rendered output, not the campaign name." - [SME-PROPOSED] Add AC: "Dispatch SHALL invoke
TraiDndService.filterRecipientsBEFORE batching for any campaign of typePROMOTIONAL/NEWSLETTERon channelSMS. Filtered count surfaces asmetrics.skipped.dnd. Spec asserts a DND-registered number is excluded from the Kafka publish."
US-010-11 — Drip Campaign Visual Builder
Catalog status: Built (P1, 13 points) Verified status: Partial — linear step engine exists, NOT the React Flow visual builder
Code locations:
hmp-crm-service/src/campaigns/drip-engine.service.ts— linear step-based drip enginehmp-crm-service/src/campaigns/drip-engine.service.spec.ts— drip engine tests- BullMQ queue
crm-drip-campaigns(per crm CLAUDE Gotchas — F-023 — replaced in-memoryscheduledSteps[]) hmp-web-dashboard/src/app/(dashboard)/crm/campaigns/...— Drip Builder link (timeline UI, not React Flow per review)
Unit + integration tests: 8 tests in campaigns.service.spec.ts + drip-engine.service.spec.ts.
Flow tests: none.
Use cases referencing this story: none
SME notes (review file US-010-007-review.md):
- Verdict FAIL: linear
campaign_stepstable + cron scheduler is FUNDAMENTALLY DIFFERENT from the React Flow node graph specified in the story. - No
DripCampaign/DripCampaignVersionPrisma model withworkflow_json. - No node-types (Trigger/Delay/Condition/Action/Split) — just a vertical timeline.
- BullMQ wiring IS correct (no longer in-memory) — that's the one improvement on the story body.
Drift findings:
D-011 — [P1] [Catalog-overestimates] Catalog says Built; review verdict is FAIL. The shipped linear builder satisfies a narrow read of "drip" but not the visual workflow described in story Scenario 1 ("Trigger → Delay → Action → Condition → Yes/No branches"). Demoting to Partial is generous.
- [SME-PROPOSED] Demote catalog →
Partial. Author follow-up storyUS-010-NEW-DRIP-WORKFLOW-JSONfor the actual React Flow + workflow-JSON + per-node config-panel scope. Existing linear engine becomes the v0 internal implementation behind it.
US-010-12 — Drip Campaign Execution Engine
Catalog status: Not Started (P0, 13 points) Verified status: Not Started — agrees with US-010-11 review
Code locations: none for the workflow-graph executor; linear drip-engine.service.ts covers a narrow subset.
Unit + integration tests: none.
Flow tests: none.
Use cases referencing this story: none
SME notes:
- Honest "Not Started" — agrees with the story body (no Kafka consumer that traverses a workflow graph; no scheduler that respects delay nodes; no condition-node evaluator).
- Hard dependency on US-010-09 (rendered template) and US-010-10 (dispatch wiring). Sequencing matters in plan.
Drift findings: (none — story-vs-reality matches)
- [SME-PROPOSED] Add AC: "Execution-engine entry contract:
DripWorkflowExecutor.start({ tenantId, workflowId, triggerEvent, contextVars }). Engine state persisted asDripInstance(workflowId, contextSnapshot, currentNodeId, nextFireAt, status). Cancellation hook:ReservationCancelledConsumerterminates anyDripInstancereferencing the cancelled booking. Required before any production drip rollout."
US-010-13 — Guest Segmentation (Manual)
Catalog status: Built (P1, 5 points) Verified status: Built — manual filter segmentation works (within campaigns); review verdict for the broader segmentation engine (US-010-008 review) is FAIL
Code locations:
hmp-crm-service/src/campaigns/campaigns.service.ts—segmentRecipients()filters guests byvipLevel,minStays,lastStayBefore/After,hasEmail,hasPhone,tags- Hard cap: 500 guests per segmentation pass (review GAP-7)
Unit + integration tests:
campaigns.service.spec.ts— 7 tests insegmentRecipientsdescribe block
Flow tests: none.
Use cases referencing this story: none
SME notes:
tagsfilter istext containson theGuest.notesfield — fragile. Should be aGuest.tags String[]array.- 500-cap means a large property with >500 segmented guests gets silently truncated.
- No dedicated
GuestSegmententity → segments are NOT reusable across campaigns; every campaign re-encodes its rules.
Drift findings: (none net new — see D-012 below for RFM scope)
- [SME-PROPOSED] Replace
tagstext-search withGuest.tags String[]array + GIN index. AddGuest.tagsHistory Jsonfor audit. Cursor-based pagination instead oftake: 500.
US-010-14 — RFM-Based Guest Segmentation
Catalog status: Not Started (P1, 13 points)
Verified status: Not Started — no guest_rfm_scores table, no nightly cron
Code locations: none.
Unit + integration tests: none.
Flow tests: none.
Use cases referencing this story: none
SME notes (review file US-010-008-review.md):
- Story specifies a nightly 02:00 IST batch + Champions/Loyal/At-Risk/Lost named segments + per-tenant configurable thresholds. None exists.
- Honest "Not Started".
Drift findings:
D-012 — [P2] [Story-incomplete + India-INR-thresholds] Story Scenario 3 specifies per-tenant monetary thresholds in INR. The story does not specify the source-of-truth for "monetary" (gross? net of GST? folio total vs realized payment?). Resolution affects RFM math.
- [SME-PROPOSED] Add AC: "RFM monetary score SHALL use
sum(folio.realizedPayment - folio.refund)over the rolling 12-month window (NOT gross folio amount, NOT including GST). Currency = INR; multi-currency properties (out of scope for v1) re-quantise to INR at booking date FX rate."
US-010-15 — Feedback Survey Builder
Catalog status: Built (P1, 8 points) Verified status: Partial — survey CRUD + response submission work; review verdict PASS_WITH_NOTES
Code locations:
hmp-crm-service/src/feedback/feedback.controller.ts:28-101—@Roles('SUPER_ADMIN','ADMIN','MANAGER'),@RequiresFeature('crm.basic'), GET/POST/feedback/surveys, GET/feedback/responses, etc.hmp-crm-service/src/feedback/feedback.service.ts— survey CRUD, sentiment analysis, auto-assign-to-manager for negative feedbackhmp-crm-service/src/feedback/feedback-trigger.service.ts— BullMQ-backed checkout-trigger (per crm CLAUDE Gotchas — F-024)hmp-crm-service/src/feedback/dto/feedback.dto.ts— CreateSurveyDto, SubmitResponseDtohmp-web-dashboard/src/app/(dashboard)/crm/feedback/page.tsx— list page with ratings, sentiment, respond dialog
Unit + integration tests:
feedback.service.spec.ts— 2 tests (sentiment, auto-assign)feedback-trigger.service.spec.ts— 5 tests (checkout subscribe, queue, SMS fallback)
Flow tests:
hmp-infrastructure/testing/flow-tests/src/flows/whatsapp-feedback.flow.spec.ts— WhatsApp feedback request scheduled at checkout
Use cases referencing this story: none
SME notes (review file US-010-009-review.md):
- Survey builder backend works; no survey-builder UI in dashboard (review GAP-6).
- No
departmentfield on survey questions (story spec extension). feedback.controller.tsand service use@ts-nocheckper review GAP-9 — type safety bypassed.
Drift findings: (none new — covered by D-013 below)
- [SME-PROPOSED] Add AC: "Remove
@ts-nocheckfromfeedback.controller.tsandfeedback.service.ts. Document any remaining type errors in aTODO(D-013)follow-up before merge."
US-010-16 — Automated NPS Calculation
Catalog status: Not Started (P1, 5 points)
Verified status: Not Started — npsScore stored but no aggregation
Code locations:
- GET
/feedback/npscontroller route exists infeedback.controller.ts:45-59— wrapsfeedbackService.getNpsSummary. Per review fileUS-010-009-review.md, the underlyinggetSummaryreturns hardcoded placeholder data — the NPS aggregation path appears to be a stub. Verify.
Unit + integration tests: none for NPS aggregation.
Flow tests: none.
Use cases referencing this story: none
SME notes (review file US-010-009-review.md):
npsScorefield exists onFeedbackResponse; values are stored but no Promoter/Passive/Detractor aggregation computed.getSummary()returns hardcoded averages (review GAP-3 HIGH).- No NPS dashboard card.
- No alerting when NPS drops past threshold (story Scenario 2).
- No
NpsSnapshotmodel for persistence across periods.
Drift findings: (none new — but escalate getSummary's hardcoded data to P0 per review)
- [SME-PROPOSED] Add AC: "
getSummarySHALL aggregate from actualFeedbackResponserows (no hardcoded placeholders).NpsSnapshot(tenantId, propertyId, periodStart, periodEnd, promoters, passives, detractors, nps, sampleSize)persisted nightly at 02:30 IST. Dashboard renders 30/60/90-day rolling NPS + tier-segmented NPS + alert when drop > 15 pts."
US-010-17 — A/B Testing for Campaigns
Catalog status: Partial (P1, 8 points) Verified status: Partial — UI variants exist; backend split logic does not (per EPIC §5 Gap #2 — all recipients get variant A today)
Code locations:
- Dashboard CRM module — A/B variant UI (up to 3 variants + control)
- No backend split logic in
campaigns.service.ts(per EPIC §5)
Unit + integration tests: none for split logic.
Flow tests: none.
Use cases referencing this story: none
SME notes:
- Story Scenario 3 specifies statistical-significance check (p > 0.05). The platform has no stats library wired; would need
simple-statisticsor equivalent.
Drift findings: (none new — EPIC §5 Gap #2 already P1)
- [SME-PROPOSED] Add AC: "Backend
executeBatchSHALL randomly split the test-size cohort (20% default) per configured ratio (50/50/...) using a per-recipient deterministic hash (recipientId+campaignId). Holdout (80%) waits in a separate Kafka topic; auto-winner cron at testEnd fires the holdout with the winning variant. Significance check uses chi-squared on open-rate (single-sided p<0.05) before declaring a winner."
US-010-18 — Campaign Analytics Dashboard
Catalog status: Partial (P1, 8 points) Verified status: Partial — basic metrics; webhook consumers missing (cross-EPIC EP-014)
Code locations:
campaigns.service.ts::getMetricsreturns Campaign row's tracking fields- Campaign columns:
delivered/opened/clicked/converted(per review GAP-7 — never updated post-dispatch) - SendGrid webhook → EMAIL
delivered/openedpartial via notification-service
Unit + integration tests: none for end-to-end webhook → metric increment.
Flow tests: none.
Use cases referencing this story: none
SME notes:
- Conversion attribution (story Scenario 1 — "booked within 7 days of click with promo code SUMMER26") requires booking-engine to surface the promo code on reservation create + a Kafka
reservation.promo.matchedconsumer in crm-service. Not wired.
Drift findings: (none new)
- [SME-PROPOSED] Add AC: "Conversion attribution SHALL be event-driven: a
ReservationConfirmedConsumermatchesreservation.promoCodeto recentCampaign.recipientsrows, incrementsconverted, and writesattributedRevenue = reservation.totalAmount - taxes. Window: 7 days fromRecipient.clickedAt."
US-010-19 — WhatsApp Template Approval Workflow
Catalog status: Not Started (P1, 8 points) Verified status: Not Started — no Meta integration
Code locations: none in crm-service for template-approval workflow. WhatsApp dispatcher exists in notification-service (per EPIC §4 — scaffolded, blocked on Meta Business verification).
Unit + integration tests: none.
Flow tests:
whatsapp-feedback.flow.spec.ts— exercises WhatsApp dispatch but not template approval
Use cases referencing this story: none
SME notes:
- WhatsApp Business API requires pre-approved templates AND the
quality_ratingof the sending number drops on user-reported spam. Story does not address rating governance. - Indian context: Meta requires Indian phone numbers under WhatsApp Business to use a verified business name displayed to recipients. Template approval is per-sender-number, not per-tenant.
Drift findings: (none new — story-vs-reality matches)
- [SME-PROPOSED] Add AC: "
WhatsAppTemplatePrisma model SHALL trackmetaTemplateId,metaStatus(PENDING/APPROVED/REJECTED/PAUSED),metaQualityRating(GREEN/YELLOW/RED),rejectionReason. A webhook consumer at notification-service/webhooks/meta/template-statusupdatesmetaStatusand emitswhatsapp.template.statusChangedfor crm-service to invalidate its template selectors."
Cross-EPIC drift findings (DSAR / DPDPA / Guardian fields)
D-001 — ID convention drift catalog vs reviews (P0)
Catalog uses US-010-NN (2-digit, 19 stories). Review files under
reviews/EP-010/ use US-010-NNN (3-digit; only 9 review files exist —
covering -001..-009 which correspond to catalog -01..-09). Reviews -010
through -019 are missing entirely. Cross-references in this RTM and
downstream business-flow docs WILL diverge.
- [SME-PROPOSED] Adopt the catalog (2-digit,
US-010-NN) as canonical for the EPIC body and per-EPIC RTM. Rename existing review filesUS-010-NNN-review.md→US-010-NN-review.mdAND backfill the missing 10 review files (US-010-10..US-010-19).
D-013 — DPDPA guardian-consent field (cross-EPIC, P0)
EP-010 stories repeatedly reference SMS/WhatsApp/email dispatch to guests (US-010-02 enrollment SMS, US-010-08 birthday auto-target, US-010-10 multi-channel dispatch, US-010-15 post-stay feedback, US-010-19 WhatsApp). None of the stories address minors. Under India's DPDPA 2023 §9, processing personal data of children (under 18) requires verifiable parental/guardian consent, and behavioural tracking + targeted ads to children are prohibited. A 16-year-old loyalty member opted-in by a parent at check-in receives birthday SMS at age 17 with no guardian re-consent trail.
- [SME-PROPOSED] Add AC (cross-EPIC, primary owner EP-004 guest-service,
consumer EP-010): "
Guest.dateOfBirthSHALL be required for loyalty enrolment. Ifage < 18at any campaign dispatch time, dispatch SHALL be blocked unlessGuest.guardianConsent.recordedAtis non-null AND the guardian'sphoneNumberHashis on file. Behavioural tracking + targeted promotional campaigns SHALL be entirely disabled for minor members." - [SME-PROPOSED] Add AC: "Loyalty enrolment for minors SHALL collect
guardianName,guardianPhone,guardianRelation(parent / legal guardian),guardianConsent: { recordedAt, recordingMethod (VERBAL / SIGNATURE / DIGITAL_OTP) }. UI presents the consent screen separately from the guest-data screen."
D-014 — GDPR/DPDPA right-to-erasure cascade (cross-EPIC, P1)
hmp-crm-service SHIPS a GuestErasedConsumer (per crm CLAUDE — AP-T08-005)
that nulls PII on Guest read-model + GuestCommunication.content. But
loyalty data is NOT cascaded: LoyaltyMember.memberNumber, full
PointsTransaction history, FeedbackResponse.comments (free-text PII
risk), and Campaign.recipients (per-guest engagement log) remain intact.
Under DPDPA §12 + GDPR Art. 17 the erasure cascade must reach every
identifiable record.
- [SME-PROPOSED] Add AC: "On
guest.erased,GuestErasedConsumerSHALL ALSO: (a) anonymiseLoyaltyMemberby settingmemberNumber='[erased]',firstName='[erased]',lastName='[erased]',email/phonenull; (b) preservepointsBalanceaggregate for financial-liability accounting but nullPointsTransaction.metadata.guestNotes; (c) replaceFeedbackResponse.comments+answers(free-text fields) with '[erased]'; (d) hard-deleteCampaign.recipientsrows for the guest (engagement log is not financial); (e) emitloyalty.guest.erased.ack."
D-015 — Loyalty point GST treatment (cross-EPIC, P1)
EPIC §8 AC-1 says "INR-based spend thresholds and points-per-rupee earn rates" but never addresses the GST liability when points are redeemed. Indian GST law treats loyalty points redeemed against room/F&B as discount IF declared on the original tax invoice, OR as separate taxable supply if added at redemption time. Today: redeem path deducts INR off folio with no e-invoice (EP-005B/EP-012C) re-mapping.
- [SME-PROPOSED] Add AC: "Loyalty redemption against a folio SHALL appear
as a folio line
LOYALTY_REDEMPTIONflaggedpricingMode='DISCOUNT'(default — reduces taxable base) ORpricingMode='TAXABLE_SUPPLY'per property setting. The line carriespointsBurned,inrValue, and a GST treatment tag for the IRN payload (cross-EPIC: EP-005B folio + EP-012C e-invoice)."
Cross-references: drift findings D-013/D-014/D-015 span EP-004 (guest-service), EP-014 (notification-service), EP-005B (folios/billing), EP-012C (e-invoice/IRN). Recorded here because EP-010 is the dispatch point that surfaces the gaps. Per the playbook §"Lift D-NNN ids to a global namespace" recommendation, these cross-EPIC findings are candidates for the future
BF-NNN-D-XXXglobal scheme.