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:
parent
9f00c120da
commit
2f06aacc27
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user