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

View File

@ -123,8 +123,8 @@ describe('SendSlackSchema', () => {
// ── Template Tests ───────────────────────────────────────────
describe('templates', () => {
it('should have 12 built-in templates', () => {
expect(BUILT_IN_TEMPLATES.length).toBe(12);
it('should have 13 built-in templates', () => {
expect(BUILT_IN_TEMPLATES.length).toBe(13);
});
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`,
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 ─────────────────────────────────────
{
id: 'diagnostics-session-created',

View File

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

View File

@ -75,6 +75,7 @@ export async function exportRoutes(app: FastifyInstance) {
await repo.updateExportJob({
...created,
status: 'ready',
data: serialized,
rowCount: rows.length,
fileSizeBytes: Buffer.byteLength(serialized, 'utf8'),
fileName,
@ -106,13 +107,31 @@ export async function exportRoutes(app: FastifyInstance) {
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 => {
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');
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;
requestedBy: string;
blobUrl?: string;
data?: string;
fileName?: string;
rowCount?: number;
fileSizeBytes?: number;

View File

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