fix(platform-service): resolve P1 TODOs — delivery email subscribers + survey incentives

- 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
This commit is contained in:
saravanakumardb1 2026-03-22 00:14:41 -07:00
parent 9f00c120da
commit 2f06aacc27
3 changed files with 135 additions and 23 deletions

View File

@ -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 46 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.
---

View File

@ -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];

View File

@ -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<void> {
// 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,