fix(platform-service): 5 bugs in recent P2/P3 implementations

- diagnostics/subscribers: use correct template IDs
  'diagnostics-session-cancelled' and 'diagnostics-session-completed'
  instead of non-existent 'generic' (would throw at runtime)
- delivery/templates: add missing 'broadcast' email template used by
  broadcast delivery route (dispatchEmail would throw on unknown ID)
- broadcasts/routes: replace broken dot-path 'metrics.sent' update
  with proper updateBroadcastMetrics() call, add productName variable
- exports/routes: store serialized data on job doc, add download
  endpoint GET /exports/:id/download with content-type headers,
  exclude data payload from metadata GET endpoint
- waitlist/routes: store invitation doc ID (inv_...) instead of
  code string (WL-...) in invitationCodeId field
- delivery/delivery.test.ts: update template count 12 -> 13
- Typecheck clean, 1483/1483 tests pass
This commit is contained in:
saravanakumardb1 2026-03-22 01:14:55 -07:00
parent 1576b699b0
commit 73b07c2c3a
7 changed files with 65 additions and 23 deletions

View File

@ -225,6 +225,7 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
displayName: user.displayName, displayName: user.displayName,
subject: broadcast.title, subject: broadcast.title,
body: broadcast.body ?? '', body: broadcast.body ?? '',
productName: productId,
}, },
productId, productId,
userId: user.id, userId: user.id,
@ -242,9 +243,11 @@ async function adminRoutes(app: FastifyInstance): Promise<void> {
await repo.updateBroadcast(id, productId, { await repo.updateBroadcast(id, productId, {
status: BroadcastStatus.SENT, status: BroadcastStatus.SENT,
'metrics.sent': sent, });
'metrics.targetCount': recipients.length, await repo.updateBroadcastMetrics(id, productId, {
} as Record<string, unknown>); sentCount: sent,
targetedCount: recipients.length,
});
log.info( log.info(
{ broadcastId: id, sent, total: recipients.length }, { broadcastId: id, sent, total: recipients.length },
'[broadcasts] Delivery complete' '[broadcasts] Delivery complete'

View File

@ -123,8 +123,8 @@ describe('SendSlackSchema', () => {
// ── Template Tests ─────────────────────────────────────────── // ── Template Tests ───────────────────────────────────────────
describe('templates', () => { describe('templates', () => {
it('should have 12 built-in templates', () => { it('should have 13 built-in templates', () => {
expect(BUILT_IN_TEMPLATES.length).toBe(12); expect(BUILT_IN_TEMPLATES.length).toBe(13);
}); });
it('should find template by ID', () => { it('should find template by ID', () => {

View File

@ -122,6 +122,19 @@ export const BUILT_IN_TEMPLATES: EmailTemplate[] = [
bodyText: `License expires in {{daysLeft}} days.\n\nRenew: {{renewUrl}}\n\n— The {{productName}} Team`, bodyText: `License expires in {{daysLeft}} days.\n\nRenew: {{renewUrl}}\n\n— The {{productName}} Team`,
variables: ['displayName', 'productName', 'daysLeft', 'renewUrl'], variables: ['displayName', 'productName', 'daysLeft', 'renewUrl'],
}, },
// ── Broadcast Template ──────────────────────────────────────────
{
id: 'broadcast',
name: 'Broadcast Message',
subject: '{{subject}}',
bodyHtml: `
<p>Hi {{displayName}},</p>
{{body}}
<p> The {{productName}} Team</p>
`.trim(),
bodyText: `Hi {{displayName}},\n\n{{body}}\n\n— The {{productName}} Team`,
variables: ['displayName', 'subject', 'body', 'productName'],
},
// ── Diagnostics Templates ───────────────────────────────────── // ── Diagnostics Templates ─────────────────────────────────────
{ {
id: 'diagnostics-session-created', id: 'diagnostics-session-created',

View File

@ -132,11 +132,14 @@ export function registerDiagnosticsSubscribers(
await dispatchEmail( await dispatchEmail(
{ {
to: admin.email, to: admin.email,
templateId: 'generic', templateId: 'diagnostics-session-cancelled',
variables: { variables: {
displayName: admin.displayName, productName: event.payload.productId,
subject: 'Debug session cancelled', sessionId: event.payload.sessionId,
body: `Your debug session (${event.payload.sessionId}) was cancelled${event.payload.reason ? `: ${event.payload.reason}` : '.'}.`, targetUserId: session.targetUserId ?? 'unknown',
cancelledBy: event.payload.cancelledBy,
reason: event.payload.reason ?? 'No reason provided',
cancelledAt: new Date().toISOString(),
}, },
productId: event.payload.productId, productId: event.payload.productId,
userId: session.createdBy, userId: session.createdBy,
@ -185,21 +188,24 @@ export function registerDiagnosticsSubscribers(
const admin = await getUserById(session.createdBy); const admin = await getUserById(session.createdBy);
if (admin) { if (admin) {
const stats = event.payload.stats; const stats = event.payload.stats;
const summary = [ const startedAt = session.startedAt ?? session.createdAt;
`Debug session ${event.payload.sessionId} completed.`, const endedAt = event.payload.endedAt;
`Logs collected: ${stats.logCount}`, const durationMs =
`Traces collected: ${stats.traceCount}`, startedAt && endedAt
`Screenshots captured: ${stats.screenshotCount}`, ? new Date(endedAt).getTime() - new Date(startedAt).getTime()
`Ended at: ${event.payload.endedAt}`, : 0;
].join('\n'); const durationMinutes = String(Math.round(durationMs / 60_000));
await dispatchEmail( await dispatchEmail(
{ {
to: admin.email, to: admin.email,
templateId: 'generic', templateId: 'diagnostics-session-completed',
variables: { variables: {
displayName: admin.displayName, productName: event.payload.productId,
subject: 'Debug session completed — summary', sessionId: event.payload.sessionId,
body: summary, durationMinutes,
logCount: String(stats.logCount),
traceCount: String(stats.traceCount),
screenshotCount: String(stats.screenshotCount),
}, },
productId: event.payload.productId, productId: event.payload.productId,
userId: session.createdBy, userId: session.createdBy,

View File

@ -75,6 +75,7 @@ export async function exportRoutes(app: FastifyInstance) {
await repo.updateExportJob({ await repo.updateExportJob({
...created, ...created,
status: 'ready', status: 'ready',
data: serialized,
rowCount: rows.length, rowCount: rows.length,
fileSizeBytes: Buffer.byteLength(serialized, 'utf8'), fileSizeBytes: Buffer.byteLength(serialized, 'utf8'),
fileName, fileName,
@ -106,13 +107,31 @@ export async function exportRoutes(app: FastifyInstance) {
return { exports: jobs, count: jobs.length }; return { exports: jobs, count: jobs.length };
}); });
// Get a specific export job // Get a specific export job (metadata only, no data payload)
app.get('/exports/:id', async req => { app.get('/exports/:id', async req => {
const access = await requireExportRead(req); const access = await requireExportRead(req);
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const job = await repo.getExportJob(id, access.productId); const job = await repo.getExportJob(id, access.productId);
if (!job) throw new BadRequestError('Export job not found'); if (!job) throw new BadRequestError('Export job not found');
return job; const meta = { ...job };
delete meta.data;
return meta;
});
// Download export data
app.get('/exports/:id/download', async (req, reply) => {
const access = await requireExportRead(req);
const { id } = req.params as { id: string };
const job = await repo.getExportJob(id, access.productId);
if (!job) throw new BadRequestError('Export job not found');
if (job.status !== 'ready' || !job.data) {
throw new BadRequestError('Export is not ready for download');
}
const contentType = job.format === 'json' ? 'application/json' : 'text/csv';
return reply
.header('Content-Type', contentType)
.header('Content-Disposition', `attachment; filename="${job.fileName || 'export'}"`)
.send(job.data);
}); });
} }

View File

@ -15,6 +15,7 @@ export interface ExportJobDoc {
status: ExportStatus; status: ExportStatus;
requestedBy: string; requestedBy: string;
blobUrl?: string; blobUrl?: string;
data?: string;
fileName?: string; fileName?: string;
rowCount?: number; rowCount?: number;
fileSizeBytes?: number; fileSizeBytes?: number;

View File

@ -455,7 +455,7 @@ export async function waitlistRoutes(app: FastifyInstance) {
await repo.update(entry.id, entry.email, { await repo.update(entry.id, entry.email, {
status: 'invited', status: 'invited',
invitedAt: now, invitedAt: now,
invitationCodeId: code, invitationCodeId: invDoc.id,
}); });
invited++; invited++;
} catch { } catch {