diff --git a/README.md b/README.md index d37b2e2..1410d93 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,42 @@ list and `SENDGRID_NOTIFICATION_TEMPLATE`. Missing template or missing recipient email skips notification delivery and is logged without rolling back the content write. +For challenge-scoped notifications, the publisher fetches the effective challenge +from `GET /v6/challenges/:challengeId` using the configured M2M credentials and +includes its `name` as `data.challengeTitle`. This lookup runs once per outgoing +notification, after recipient authorization, and uses a five-second HTTP timeout. +Lookup failures are logged and the email still publishes with `challengeId` but +without `challengeTitle`. + +The [forum notification HTML template](docs/email-templates/forum-notification.html) +adapts the Topcoder support email design for these notifications. Use the subject +`New forum post: {{topicTitle}}` in SendGrid and paste the HTML into the template's +code editor. The HTML `` does not configure the email subject. After +activating the template version, configure its ID as +`SENDGRID_NOTIFICATION_TEMPLATE`. + +Template variables come from the event payload's `data` object: + +| Field | Value | +| --- | --- | +| `challengeId` | Effective challenge ID; omitted when absent. The template hides this row when absent. | +| `challengeTitle` | Challenge API `name`; omitted for non-challenge topics or failed lookups. The template hides this row when absent. | +| `topicId` | Created content's topic ID. | +| `topicTitle` | Topic title. | +| `postContent` | Persisted post content, or an empty string when null. | +| `authorHandle` | Persisted post author's handle. | +| `createdAt` | Post creation timestamp in UTC ISO 8601 format. | + +Use the [sample template data](docs/email-templates/forum-notification.sample.json) +in SendGrid's preview editor; it contains only the `data` fields, without the +event envelope. Remove `challengeId` and `challengeTitle` to preview a non-challenge notification. +User content uses escaped double-brace substitutions and is displayed as text, +with line breaks preserved where the email client supports `white-space: pre-wrap`; +Markdown and HTML are not rendered. See SendGrid's +[Handlebars documentation](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/using-handlebars) +for substitution and conditional syntax. The current payload has no discussion +URL, so the template currently displays the topic ID without a discussion link. + ## Environment ```bash @@ -78,6 +114,7 @@ SENDGRID_NOTIFICATION_TEMPLATE="sendgrid-template-id" BUSAPI_URL="https://api.topcoder-dev.com/v6" BUS_API_URL="https://api.topcoder-dev.com/v6/bus/events" TOPCODER_API_URL_BASE="https://api.topcoder-dev.com" +CHALLENGE_API_URL="https://api.topcoder-dev.com/v6/challenges" KAFKA_ERROR_TOPIC="common.error.reporting" AUTH0_URL="https://auth.topcoder-dev.com/" AUTH0_AUDIENCE="https://m2m.topcoder-dev.com/" @@ -97,6 +134,7 @@ PORT=3000 `VANILLA_DB_URL` is used only by the standalone Vanilla import CLI for legacy MySQL reads. The runtime HTTP service does not connect to Vanilla. `AUTH_SECRET` is required; the service fails during startup when it is omitted. `SENDGRID_NOTIFICATION_TEMPLATE` enables forum watch notification emails. When omitted, notification publishing is skipped and content writes still succeed. +`CHALLENGE_API_URL` optionally configures the challenges collection endpoint for notification titles. When omitted, it defaults to `${TOPCODER_API_URL_BASE}/v6/challenges`. The lookup uses the same Auth0 configuration and `M2M_CLIENT_ID` / `M2M_CLIENT_SECRET` as outbound bus publishing; the M2M client must have challenge read access. `BUSAPI_URL` configures the shared Bus API v6 base for `external.action.email`; the backwards-compatible `BUS_API_URL` alias may contain either that base or the complete `/v6/bus/events` endpoint. Both values are normalized to the `/v6` base because `tc-bus-api-wrapper` appends `/bus/events`, and conflicting aliases or legacy `/eventBus` and `/v5` values are rejected. When neither alias is set, the service derives the v6 base from `TOPCODER_API_URL_BASE`. `KAFKA_ERROR_TOPIC`, `AUTH0_URL`, `AUTH0_AUDIENCE`, `TOKEN_CACHE_TIME`, `M2M_CLIENT_ID`, `M2M_CLIENT_SECRET`, and `AUTH0_PROXY_SERVER_URL` are passed to the standard bus wrapper for outbound authenticated publishing. `TRUST_FORWARDED_CLIENT_IP=true` enables forwarded client-IP moderation using the first exact IPv4/IPv6 host from trusted forwarding headers. When disabled, or when the forwarded value is missing, malformed, CIDR, wildcard, or otherwise non-exact, no client IP is resolved and IP-ban enforcement is skipped for that request. Do not enable this unless the service is behind infrastructure that strips or controls inbound forwarding headers. diff --git a/docs/email-templates/forum-notification.html b/docs/email-templates/forum-notification.html new file mode 100644 index 0000000..2e3932b --- /dev/null +++ b/docs/email-templates/forum-notification.html @@ -0,0 +1,67 @@ +<!DOCTYPE html> +<html lang="en" style="font-family: 'Roboto', Verdana, sans-serif;"> + <head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>New forum post: {{topicTitle}} + + + + +
+ {{authorHandle}} posted in {{topicTitle}} on Topcoder. +
+ + + + +
+ + + + + + + + + + + +
+ Topcoder +
+

New forum post

+

{{authorHandle}} posted in {{topicTitle}} on Topcoder.

+ +

Post details

+

Topic: {{topicTitle}}

+

Topic ID: {{topicId}}

+ {{#if challengeTitle}} +

Challenge: {{challengeTitle}}

+ {{/if}} + {{#if challengeId}} +

Challenge ID: {{challengeId}}

+ {{/if}} +

Author: {{authorHandle}}

+

Posted at (UTC): {{createdAt}}

+ +

Post content

+ + + + +
{{postContent}}
+ +

You received this notification because you watch this topic or one of its parent topics.

+

Thanks,
Topcoder

+
+ Copyright © Topcoder, All rights reserved. +
+ +
+ + diff --git a/docs/email-templates/forum-notification.sample.json b/docs/email-templates/forum-notification.sample.json new file mode 100644 index 0000000..4b08282 --- /dev/null +++ b/docs/email-templates/forum-notification.sample.json @@ -0,0 +1,9 @@ +{ + "challengeId": "12345678-1234-4234-8234-123456789abc", + "challengeTitle": "Topcoder Forum Export Challenge", + "topicId": "87654321-4321-4321-8321-cba987654321", + "topicTitle": "Challenge questions and clarifications", + "postContent": "Hi everyone,\n\nThe requirements have been updated with an example for the export flow. Please review the discussion and share any questions.\n\nThanks!", + "authorHandle": "sampleCopilot", + "createdAt": "2026-09-08T04:30:00.000Z" +} diff --git a/src/config/notifications.config.ts b/src/config/notifications.config.ts index d8dff96..3d516fb 100644 --- a/src/config/notifications.config.ts +++ b/src/config/notifications.config.ts @@ -5,7 +5,7 @@ * notification publisher. Missing template configuration disables notification * publishing without affecting forum writes. * - * @returns SendGrid template, Bus API aliases/base, Kafka error topic, and Auth0 M2M values. + * @returns SendGrid template, Bus and Challenge API bases, Kafka error topic, and Auth0 M2M values. * @throws Does not throw; consumers decide whether optional values are required. */ export default () => ({ @@ -14,6 +14,7 @@ export default () => ({ busApiUrl: process.env.BUSAPI_URL, busApiUrlAlias: process.env.BUS_API_URL, topcoderApiUrlBase: process.env.TOPCODER_API_URL_BASE, + challengeApiUrl: process.env.CHALLENGE_API_URL, kafkaErrorTopic: process.env.KAFKA_ERROR_TOPIC, auth0Url: process.env.AUTH0_URL, auth0Audience: process.env.AUTH0_AUDIENCE, diff --git a/src/forums/challenge-api.service.spec.ts b/src/forums/challenge-api.service.spec.ts new file mode 100644 index 0000000..05e4d7d --- /dev/null +++ b/src/forums/challenge-api.service.spec.ts @@ -0,0 +1,129 @@ +import { ConfigService } from '@nestjs/config'; +import { auth } from 'tc-core-library-js'; +import { ChallengeApiService } from './challenge-api.service'; + +/** + * Creates a Challenge API adapter with test M2M and endpoint configuration. + * + * @param overrides Configuration replacements, including undefined for missing settings. + * @returns Service configured for mocked outbound requests. + * @throws Does not throw. + */ +function createService(overrides: Record = {}) { + const values = { + 'notifications.topcoderApiUrlBase': 'https://api.topcoder-dev.com/', + 'notifications.auth0Url': 'https://auth.example.com/oauth/token', + 'notifications.auth0Audience': 'https://m2m.example.com/', + 'notifications.tokenCacheTime': '86400', + 'notifications.auth0ProxyServerUrl': 'https://proxy.example.com/token', + 'notifications.m2mClientId': 'client-id', + 'notifications.m2mClientSecret': 'client-secret', + ...overrides, + }; + return new ChallengeApiService({ + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService); +} + +describe('ChallengeApiService', () => { + let fetchSpy: jest.SpiedFunction; + let m2mSpy: jest.SpiedFunction; + let getMachineToken: jest.Mock; + + beforeEach(() => { + getMachineToken = jest.fn().mockResolvedValue('machine-token'); + m2mSpy = jest.spyOn(auth, 'm2m').mockReturnValue({ getMachineToken }); + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(Response.json({ id: 'challenge-1', name: 'Challenge title' })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('looks up the challenge name using the configured M2M credentials', async () => { + const service = createService(); + + expect(m2mSpy).not.toHaveBeenCalled(); + await expect(service.getChallengeTitle('challenge-1')).resolves.toBe( + 'Challenge title', + ); + expect(m2mSpy).toHaveBeenCalledWith({ + AUTH0_URL: 'https://auth.example.com/oauth/token', + AUTH0_AUDIENCE: 'https://m2m.example.com/', + TOKEN_CACHE_TIME: 86400, + AUTH0_PROXY_SERVER_URL: 'https://proxy.example.com/token', + }); + expect(getMachineToken).toHaveBeenCalledWith('client-id', 'client-secret'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.topcoder-dev.com/v6/challenges/challenge-1', + { + headers: { Authorization: 'Bearer machine-token' }, + signal: expect.any(AbortSignal), + }, + ); + }); + + it('uses an explicit collection URL and encodes the challenge id', async () => { + const service = createService({ + 'notifications.challengeApiUrl': ' http://localhost:4000/v6/challenges/ ', + }); + + await service.getChallengeTitle('challenge/id?query'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:4000/v6/challenges/challenge%2Fid%3Fquery', + expect.any(Object), + ); + }); + + it.each([ + ['notifications.topcoderApiUrlBase', 'CHALLENGE_API_URL'], + ['notifications.m2mClientId', 'M2M_CLIENT_ID'], + ['notifications.m2mClientSecret', 'M2M_CLIENT_SECRET'], + ])('fails before outbound requests when %s is missing', async (key, message) => { + await expect( + createService({ [key]: undefined }).getChallengeTitle('challenge-1'), + ).rejects.toThrow(message); + expect(getMachineToken).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('propagates token failures without calling the Challenge API', async () => { + getMachineToken.mockRejectedValue(new Error('Token unavailable')); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toThrow( + 'Token unavailable', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([403, 404, 500])('rejects HTTP %s responses', async (status) => { + fetchSpy.mockResolvedValue(new Response(null, { status })); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toThrow( + `Challenge API returned HTTP ${status}`, + ); + }); + + it.each([null, {}, { name: '' }, { name: ' ' }, { name: 123 }])( + 'rejects a response without a usable title: %p', + async (body) => { + fetchSpy.mockResolvedValue(Response.json(body)); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toThrow( + 'no usable challenge name', + ); + }, + ); + + it('propagates HTTP timeouts for best-effort notification handling', async () => { + const timeoutError = new DOMException('Request timed out', 'TimeoutError'); + fetchSpy.mockRejectedValue(timeoutError); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toBe( + timeoutError, + ); + }); +}); diff --git a/src/forums/challenge-api.service.ts b/src/forums/challenge-api.service.ts new file mode 100644 index 0000000..6296542 --- /dev/null +++ b/src/forums/challenge-api.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { auth } from 'tc-core-library-js'; + +const CHALLENGE_REQUEST_TIMEOUT_MS = 5_000; + +/** + * Looks up challenge titles for forum notifications through the Challenge API. + * The shared M2M library reuses cached tokens for the bus publisher's credentials; + * client initialization is lazy so missing outbound configuration does not block startup. + */ +@Injectable() +export class ChallengeApiService { + private m2mClient?: ReturnType; + + /** + * Creates the notification challenge lookup adapter. + * + * @param configService Notification API and Auth0 M2M configuration. + * @throws Does not throw; configuration is validated on lookup. + */ + constructor(private readonly configService: ConfigService) {} + + /** + * Fetches a challenge's name for the notification's `challengeTitle` field. + * + * @param challengeId Effective challenge id inherited by the notified topic. + * @returns Non-empty challenge name from the Challenge API. + * @throws Error for missing configuration, token failures, unsuccessful or invalid + * API responses, and HTTP requests exceeding five seconds. The publisher catches + * these errors and sends the notification without a challenge title. + */ + async getChallengeTitle(challengeId: string): Promise { + const apiBase = this.resolveApiBase(); + const clientId = this.configService.get( + 'notifications.m2mClientId', + ); + const clientSecret = this.configService.get( + 'notifications.m2mClientSecret', + ); + + if (!clientId || !clientSecret) { + throw new Error( + 'M2M_CLIENT_ID and M2M_CLIENT_SECRET must configure Challenge API access.', + ); + } + + if (!this.m2mClient) { + const tokenCacheTime = Number( + this.configService.get('notifications.tokenCacheTime'), + ); + this.m2mClient = auth.m2m({ + AUTH0_URL: this.configService.get('notifications.auth0Url'), + AUTH0_AUDIENCE: this.configService.get( + 'notifications.auth0Audience', + ), + TOKEN_CACHE_TIME: Number.isFinite(tokenCacheTime) + ? tokenCacheTime + : undefined, + AUTH0_PROXY_SERVER_URL: this.configService.get( + 'notifications.auth0ProxyServerUrl', + ), + }); + } + + const token = await this.m2mClient.getMachineToken(clientId, clientSecret); + const response = await fetch( + `${apiBase}/${encodeURIComponent(challengeId)}`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(CHALLENGE_REQUEST_TIMEOUT_MS), + }, + ); + + if (!response.ok) { + throw new Error(`Challenge API returned HTTP ${response.status}.`); + } + + const challenge: unknown = await response.json(); + + if ( + !challenge || + typeof challenge !== 'object' || + !('name' in challenge) || + typeof challenge.name !== 'string' || + !challenge.name.trim() + ) { + throw new Error('Challenge API returned no usable challenge name.'); + } + + return challenge.name; + } + + /** + * Resolves the challenges collection URL from explicit or shared API settings. + * + * @returns API base without trailing slashes, ready for a challenge id suffix. + * @throws Error when neither CHALLENGE_API_URL nor TOPCODER_API_URL_BASE is set. + */ + private resolveApiBase(): string { + const configured = this.configService + .get('notifications.challengeApiUrl') + ?.trim(); + + if (configured) { + return configured.replace(/\/+$/, ''); + } + + const sharedBase = this.configService + .get('notifications.topcoderApiUrlBase') + ?.trim(); + + if (!sharedBase) { + throw new Error( + 'CHALLENGE_API_URL or TOPCODER_API_URL_BASE must configure the Challenge API.', + ); + } + + return `${sharedBase.replace(/\/+$/, '')}/v6/challenges`; + } +} diff --git a/src/forums/forums-watch-notification.service.spec.ts b/src/forums/forums-watch-notification.service.spec.ts index 69e1513..7affc0f 100644 --- a/src/forums/forums-watch-notification.service.spec.ts +++ b/src/forums/forums-watch-notification.service.spec.ts @@ -100,6 +100,9 @@ function createService(templateId: string | null = 'template-id') { const eventBusService = { postEvent: jest.fn().mockResolvedValue(undefined), }; + const challengeApiService = { + getChallengeTitle: jest.fn().mockResolvedValue('Challenge title'), + }; const configService = { get: jest.fn((key: string) => key === 'notifications.sendgridNotificationTemplate' @@ -115,10 +118,12 @@ function createService(templateId: string | null = 'template-id') { moderationService as any, eventBusService as any, configService as any, + challengeApiService as any, ); return { accessPolicyService, + challengeApiService, configService, db, eventBusService, @@ -135,7 +140,7 @@ describe('ForumsWatchNotificationService', () => { }); it('dedupes topic and ancestor watches into one recipient email event', async () => { - const { db, eventBusService, service } = createService(); + const { challengeApiService, db, eventBusService, service } = createService(); db.topicWatch.findMany.mockResolvedValue([ { memberId: '2' }, { memberId: '2' }, @@ -161,6 +166,10 @@ describe('ForumsWatchNotificationService', () => { select: { memberId: true }, }); expect(eventBusService.postEvent).toHaveBeenCalledTimes(1); + expect(challengeApiService.getChallengeTitle).not.toHaveBeenCalled(); + expect(eventBusService.postEvent.mock.calls[0][1].data).not.toHaveProperty( + 'challengeTitle', + ); expect(eventBusService.postEvent).toHaveBeenCalledWith( 'external.action.email', expect.objectContaining({ @@ -197,7 +206,8 @@ describe('ForumsWatchNotificationService', () => { }); it('filters watched members who cannot view a restricted child topic', async () => { - const { accessPolicyService, eventBusService, service } = createService(); + const { accessPolicyService, challengeApiService, eventBusService, service } = + createService(); accessPolicyService.decideForRestrictionVisibility.mockResolvedValue({ allowed: false, reason: 'Required forum role is missing.', @@ -226,6 +236,7 @@ describe('ForumsWatchNotificationService', () => { }, ); expect(eventBusService.postEvent).not.toHaveBeenCalled(); + expect(challengeApiService.getChallengeTitle).not.toHaveBeenCalled(); }); it('filters banned watched members out of recipient email events', async () => { @@ -427,6 +438,7 @@ describe('ForumsWatchNotificationService', () => { }); expect(missingTemplate.db.topicClosure.findMany).not.toHaveBeenCalled(); + expect(missingTemplate.challengeApiService.getChallengeTitle).not.toHaveBeenCalled(); expect(missingTemplate.eventBusService.postEvent).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -466,4 +478,86 @@ describe('ForumsWatchNotificationService', () => { }), ); }); + + it.each(['createPost', 'createTopic'] as const)( + 'includes the inherited challenge title once for all %s recipients', + async (operationName) => { + const { + challengeApiService, + db, + eventBusService, + memberDirectoryService, + service, + } = createService(); + db.topicWatch.findMany.mockResolvedValue([ + { memberId: '2' }, + { memberId: '3' }, + ]); + memberDirectoryService.getMembersByIds.mockResolvedValue([ + { memberId: '2', email: 'two@example.com', handle: 'two' }, + { memberId: '3', email: 'three@example.com', handle: 'three' }, + ]); + + await service.publishPostNotification({ + topic: makeTopic({ parentTopicId: 'root-1', challengeId: null }), + post: makePost(), + restrictions: { + challengeId: 'challenge-1', + roleName: null, + hasRestrictionConflict: false, + }, + operationName, + }); + + expect(challengeApiService.getChallengeTitle).toHaveBeenCalledTimes(1); + expect(challengeApiService.getChallengeTitle).toHaveBeenCalledWith( + 'challenge-1', + ); + expect(eventBusService.postEvent).toHaveBeenCalledWith( + 'external.action.email', + { + data: { + challengeId: 'challenge-1', + challengeTitle: 'Challenge title', + topicId: 'topic-1', + topicTitle: 'Topic title', + postContent: 'Persisted post content', + authorHandle: 'author', + createdAt: createdAt.toISOString(), + }, + recipients: ['two@example.com', 'three@example.com'], + sendgrid_template_id: 'template-id', + version: 'v3', + }, + ); + }, + ); + + it('still publishes without a title when the challenge lookup fails', async () => { + const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { challengeApiService, eventBusService, service } = createService(); + challengeApiService.getChallengeTitle.mockRejectedValue( + new Error('Challenge API unavailable'), + ); + + const result = await service.publishPostNotification({ + topic: makeTopic(), + post: makePost(), + restrictions: { + challengeId: 'challenge-1', + roleName: null, + hasRestrictionConflict: false, + }, + operationName: 'createPost', + }); + + expect(result).toEqual({ attemptedRecipientCount: 1, published: true }); + expect(eventBusService.postEvent).toHaveBeenCalledTimes(1); + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.data.challengeId).toBe('challenge-1'); + expect(payload.data).not.toHaveProperty('challengeTitle'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('challenge challenge-1 title lookup failed'), + ); + }); }); diff --git a/src/forums/forums-watch-notification.service.ts b/src/forums/forums-watch-notification.service.ts index c65c3e3..10e6518 100644 --- a/src/forums/forums-watch-notification.service.ts +++ b/src/forums/forums-watch-notification.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Post, Topic } from '../../prisma/generated/client'; import { DbService } from '../db/db.service'; +import { ChallengeApiService } from './challenge-api.service'; import { EventBusService } from './event-bus.service'; import { ForumsAccessPolicyService } from './forums-access-policy.service'; import { @@ -72,9 +73,9 @@ export class ForumsNotificationPublishError extends Error { * The service resolves explicit watches on the created post's topic and all * ancestors, dedupes by member id, excludes the persisted author member id, * filters active member bans and remaining recipients through the shared forums - * access policy, and publishes one SendGrid email event for the final recipient - * list. IP-ban checks are intentionally excluded because notification delivery - * is not bound to a trusted request client IP. + * access policy, resolves the challenge title when applicable, and publishes one + * SendGrid email event for the final recipient list. IP-ban checks are intentionally + * excluded because notification delivery is not bound to a trusted request client IP. */ @Injectable() export class ForumsWatchNotificationService { @@ -90,6 +91,7 @@ export class ForumsWatchNotificationService { * @param moderationService Shared runtime member-ban gate. * @param eventBusService Local event-bus adapter. * @param configService Nest configuration service containing notification settings. + * @param challengeApiService M2M-authenticated lookup for challenge titles. * @throws Does not throw directly; dependencies are resolved by Nest. */ constructor( @@ -100,6 +102,7 @@ export class ForumsWatchNotificationService { private readonly moderationService: ForumsModerationService, private readonly eventBusService: EventBusService, private readonly configService: ConfigService, + private readonly challengeApiService: ChallengeApiService, ) {} /** @@ -157,7 +160,13 @@ export class ForumsWatchNotificationService { return { attemptedRecipientCount: 0, published: false }; } - const payload = this.buildEmailPayload(params, templateId, recipientEmails); + const challengeTitle = await this.resolveChallengeTitle(params); + const payload = this.buildEmailPayload( + params, + templateId, + recipientEmails, + challengeTitle, + ); try { await this.eventBusService.postEvent(EMAIL_EVENT_TOPIC, payload); @@ -364,12 +373,40 @@ export class ForumsWatchNotificationService { return results; } + /** + * Looks up the effective challenge title once per outgoing notification. + * + * @param params Current notification parameters with inherited challenge context. + * @returns Challenge title, or `undefined` for non-challenge topics or lookup failures. + * @throws Does not throw; lookup failures are logged so email delivery continues. + */ + private async resolveChallengeTitle( + params: PublishForumsPostNotificationParams, + ): Promise { + const challengeId = params.restrictions.challengeId; + + if (!challengeId) { + return undefined; + } + + try { + return await this.challengeApiService.getChallengeTitle(challengeId); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + this.logger.warn( + `${params.operationName} notification for topic ${params.topic.id}, post ${params.post.id}: challenge ${challengeId} title lookup failed: ${message}`, + ); + return undefined; + } + } + /** * Builds the SendGrid event payload from persisted topic and post rows. * * @param params Current notification publish parameters. * @param templateId Configured SendGrid template id. * @param recipients Final recipient email list. + * @param challengeTitle Challenge API name when the effective challenge resolves. * @returns Event-bus email payload. * @throws Does not throw. */ @@ -377,12 +414,14 @@ export class ForumsWatchNotificationService { params: PublishForumsPostNotificationParams, templateId: string, recipients: string[], + challengeTitle: string | undefined, ): ForumsWatchNotificationEmailPayload { return { data: { ...(params.restrictions.challengeId ? { challengeId: params.restrictions.challengeId } : {}), + ...(challengeTitle ? { challengeTitle } : {}), topicId: params.topic.id, topicTitle: params.topic.title, postContent: params.post.content ?? '', diff --git a/src/forums/forums.module.ts b/src/forums/forums.module.ts index 701543f..be92ae0 100644 --- a/src/forums/forums.module.ts +++ b/src/forums/forums.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { DbModule } from '../db/db.module'; import { ChallengeAccessService } from './challenge-access.service'; +import { ChallengeApiService } from './challenge-api.service'; import { EventBusService } from './event-bus.service'; import { ForumsAccessPolicyService } from './forums-access-policy.service'; import { ForumsCommandService } from './forums-command.service'; @@ -31,6 +32,7 @@ import { TopicsController } from './topics.controller'; controllers: [TopicsController, PostsController, ModerationController], providers: [ ChallengeAccessService, + ChallengeApiService, EventBusService, ForumsAccessPolicyService, ForumsCommandService, diff --git a/src/types/tc-core-library-js.d.ts b/src/types/tc-core-library-js.d.ts index 3caa5e7..81e7816 100644 --- a/src/types/tc-core-library-js.d.ts +++ b/src/types/tc-core-library-js.d.ts @@ -1,4 +1,18 @@ declare module 'tc-core-library-js' { + export const auth: { + m2m: (config: { + AUTH0_URL?: string; + AUTH0_AUDIENCE?: string; + TOKEN_CACHE_TIME?: number; + AUTH0_PROXY_SERVER_URL?: string; + }) => { + getMachineToken: ( + clientId: string, + clientSecret: string, + ) => Promise; + }; + }; + export const middleware: { jwtAuthenticator: (config: { AUTH_SECRET?: string;