From 2f06aacc27bbe6ac38d30a913b0b50a07e2b188b Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 22 Mar 2026 00:14:41 -0700 Subject: [PATCH] =?UTF-8?q?fix(platform-service):=20resolve=20P1=20TODOs?= =?UTF-8?q?=20=E2=80=94=20delivery=20email=20subscribers=20+=20survey=20in?= =?UTF-8?q?centives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - delivery/subscribers: add resolveUserEmail() helper using auth getById() - payment.failed: look up user email, dispatch payment-failed template - trial_expiring: look up user, compute daysLeft from expiresAt, dispatch - trial_expired: look up user, dispatch trial-expired template with upgradeUrl - surveys/routes: wire incentive fulfillment to subscriptions module - pro_days: extend currentPeriodEnd by incentive amount - credits: add bonus tokensIncluded via subscriptions repo - Update WORKSPACE_TODO_AUDIT.md — P0+P1 all resolved (7/18) - Typecheck clean, 1483/1483 tests pass --- docs/WORKSPACE_TODO_AUDIT.md | 16 +-- .../src/modules/delivery/subscribers.ts | 125 ++++++++++++++++-- .../src/modules/surveys/routes.ts | 17 ++- 3 files changed, 135 insertions(+), 23 deletions(-) diff --git a/docs/WORKSPACE_TODO_AUDIT.md b/docs/WORKSPACE_TODO_AUDIT.md index 72cded84..721663fe 100644 --- a/docs/WORKSPACE_TODO_AUDIT.md +++ b/docs/WORKSPACE_TODO_AUDIT.md @@ -37,16 +37,16 @@ --- -### P1 — Medium Impact, Medium Effort (address in next sprint) +### P1 — Medium Impact, Medium Effort (resolved) -| # | Location | TODO | Impact if NOT Addressed | Benefit of Addressing | Effort | -| --- | ------------------------------------------------------ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------ | -| 4 | `platform-service/modules/delivery/subscribers.ts:112` | `[delivery/subscriber] Payment failed — email delivery requires user lookup (TODO)` | Users whose payments fail get NO email notification — they may not know their payment was rejected | Users get proactive "please update your card" emails, reducing involuntary churn | **M** (needs user lookup → email dispatch) | -| 5 | `platform-service/modules/delivery/subscribers.ts:126` | `[delivery/subscriber] Trial expiring — email delivery requires user lookup (TODO)` | Users about to lose their trial get no warning email | Significantly improves trial→paid conversion rate | **M** | -| 6 | `platform-service/modules/delivery/subscribers.ts:134` | `[delivery/subscriber] Trial expired — email delivery requires user lookup (TODO)` | Users whose trial just expired get no notification | Win-back opportunity via "your trial ended" email | **M** | -| 7 | `platform-service/modules/surveys/routes.ts:544` | `// TODO: Integrate with billing/subscriptions module to grant pro_days or credits` | Survey incentives (promised credits/pro days) are marked as "claimed" but never actually granted | Users complete surveys expecting rewards but don't receive them — trust issue | **M** (wire billing API call) | +| # | Location | TODO | Effort | Status | +| --- | ------------------------------------------- | ---------------------------------------------- | ------ | -------- | +| 4 | `delivery/subscribers.ts` — payment.failed | User lookup + payment-failed email dispatch | **M** | ✅ FIXED | +| 5 | `delivery/subscribers.ts` — trial_expiring | User lookup + trial-expiring email dispatch | **M** | ✅ FIXED | +| 6 | `delivery/subscribers.ts` — trial_expired | User lookup + trial-expired email dispatch | **M** | ✅ FIXED | +| 7 | `surveys/routes.ts` — incentive fulfillment | Wire subscriptions module for pro_days/credits | **M** | ✅ FIXED | -**Recommendation:** Items 4–6 are closely related — all need a user lookup step before email dispatch. Implement once as a shared helper. Item 7 is a trust issue with end users — should be prioritized if surveys are actively running. +**Status:** All 4 resolved. Added `resolveUserEmail()` helper using auth repo `getById()`. Survey incentives now extend `currentPeriodEnd` for pro_days and add `tokensIncluded` for credits via subscriptions module. --- diff --git a/services/platform-service/src/modules/delivery/subscribers.ts b/services/platform-service/src/modules/delivery/subscribers.ts index b8afb523..c9fa3939 100644 --- a/services/platform-service/src/modules/delivery/subscribers.ts +++ b/services/platform-service/src/modules/delivery/subscribers.ts @@ -1,5 +1,6 @@ import { bus } from '../../lib/event-bus.js'; import { getProduct } from '../products/cache.js'; +import { getById as getUserById } from '../auth/repository.js'; import { dispatchEmail } from './dispatcher.js'; // ── Event Bus Subscribers ──────────────────────────────────── @@ -105,11 +106,31 @@ export function registerDeliverySubscribers( // Payment failed notification bus.on('payment.failed', async event => { try { - // We don't have the user's email in the payment event payload, - // so this subscriber would need to look up the user. For now, log only. - log.info( - { userId: event.payload.userId, invoiceId: event.payload.invoiceId }, - '[delivery/subscriber] Payment failed — email delivery requires user lookup (TODO)' + const user = await resolveUserEmail(event.payload.userId); + if (!user) { + log.error( + { userId: event.payload.userId }, + '[delivery/subscriber] payment.failed — user not found, skipping email' + ); + return; + } + const productId = event.payload.productId || 'bytelyst'; + const baseUrl = resolveDashboardUrl(productId); + await dispatchEmail( + { + to: user.email, + templateId: 'payment-failed', + variables: { + displayName: user.displayName, + productName: resolveProductName(productId), + amount: String(event.payload.amount ?? ''), + currency: '', + billingUrl: `${baseUrl}/billing`, + }, + productId, + userId: event.payload.userId, + }, + log ); } catch (err) { log.error( @@ -121,18 +142,81 @@ export function registerDeliverySubscribers( // Subscription trial expiring bus.on('subscription.trial_expiring', async event => { - log.info( - { userId: event.payload.userId, expiresAt: event.payload.expiresAt }, - '[delivery/subscriber] Trial expiring — email delivery requires user lookup (TODO)' - ); + try { + const user = await resolveUserEmail(event.payload.userId); + if (!user) { + log.error( + { userId: event.payload.userId }, + '[delivery/subscriber] trial_expiring — user not found, skipping email' + ); + return; + } + const productId = event.payload.productId || 'bytelyst'; + const baseUrl = resolveDashboardUrl(productId); + const daysLeft = event.payload.expiresAt + ? String( + Math.max( + 0, + Math.ceil((new Date(event.payload.expiresAt).getTime() - Date.now()) / 86_400_000) + ) + ) + : '3'; + await dispatchEmail( + { + to: user.email, + templateId: 'trial-expiring', + variables: { + displayName: user.displayName, + productName: resolveProductName(productId), + daysLeft: String(daysLeft), + upgradeUrl: `${baseUrl}/billing`, + }, + productId, + userId: event.payload.userId, + }, + log + ); + } catch (err) { + log.error( + { err, eventId: event.id }, + '[delivery/subscriber] Failed to handle subscription.trial_expiring' + ); + } }); // Subscription trial expired bus.on('subscription.trial_expired', async event => { - log.info( - { userId: event.payload.userId }, - '[delivery/subscriber] Trial expired — email delivery requires user lookup (TODO)' - ); + try { + const user = await resolveUserEmail(event.payload.userId); + if (!user) { + log.error( + { userId: event.payload.userId }, + '[delivery/subscriber] trial_expired — user not found, skipping email' + ); + return; + } + const productId = event.payload.productId || 'bytelyst'; + const baseUrl = resolveDashboardUrl(productId); + await dispatchEmail( + { + to: user.email, + templateId: 'trial-expired', + variables: { + displayName: user.displayName, + productName: resolveProductName(productId), + upgradeUrl: `${baseUrl}/billing`, + }, + productId, + userId: event.payload.userId, + }, + log + ); + } catch (err) { + log.error( + { err, eventId: event.id }, + '[delivery/subscriber] Failed to handle subscription.trial_expired' + ); + } }); log.info('[delivery] Registered event bus subscribers'); @@ -140,6 +224,21 @@ export function registerDeliverySubscribers( // ── Helpers ────────────────────────────────────────────────── +async function resolveUserEmail( + userId: string +): Promise<{ email: string; displayName: string } | null> { + try { + const user = await getUserById(userId); + if (!user?.email) return null; + return { + email: user.email, + displayName: user.displayName || user.email.split('@')[0], + }; + } catch { + return null; + } +} + function resolveDashboardUrl(productId: string): string { const envKey = `${productId.toUpperCase()}_DASHBOARD_URL`; const envUrl = process.env[envKey]; diff --git a/services/platform-service/src/modules/surveys/routes.ts b/services/platform-service/src/modules/surveys/routes.ts index 52abd4b3..d4af8232 100644 --- a/services/platform-service/src/modules/surveys/routes.ts +++ b/services/platform-service/src/modules/surveys/routes.ts @@ -12,6 +12,7 @@ import { } from '../../lib/errors.js'; import { getRequestProductId } from '../../lib/request-context.js'; import { evaluateTarget } from '../broadcasts/targeting.js'; +import * as subsRepo from '../subscriptions/repository.js'; import * as repo from './repository.js'; import { CreateSurveySchema, @@ -541,8 +542,20 @@ async function publicRoutes(app: FastifyInstance): Promise { // Handle incentive fulfillment let incentiveClaimed = false; if (survey.incentive) { - // TODO: Integrate with billing/subscriptions module - // to grant pro_days or credits + const sub = await subsRepo.getByUserId(userId, productId); + if (sub) { + if (survey.incentive.type === 'pro_days') { + const currentEnd = new Date(sub.currentPeriodEnd); + currentEnd.setDate(currentEnd.getDate() + survey.incentive.amount); + await subsRepo.updateSubscription(sub.id, userId, { + currentPeriodEnd: currentEnd.toISOString(), + }); + } else if (survey.incentive.type === 'credits') { + await subsRepo.updateSubscription(sub.id, userId, { + tokensIncluded: sub.tokensIncluded + survey.incentive.amount, + }); + } + } incentiveClaimed = true; await repo.updateResponse(id, userId, { incentiveClaimed: true,