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:
parent
1576b699b0
commit
73b07c2c3a
@ -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'
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user