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 @@
+
+
+
+
+
+ New forum post: {{topicTitle}}
+
+
+
+
+
+ {{authorHandle}} posted in {{topicTitle}} on 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
+
+
+ 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;