Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<title>` 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
Expand All @@ -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/"
Expand All @@ -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.

Expand Down
67 changes: 67 additions & 0 deletions docs/email-templates/forum-notification.html
Original file line number Diff line number Diff line change
@@ -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}}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Barlow+Condensed:500" type="text/css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto" type="text/css">
</head>
<body bgcolor="#E9E9E9" style="margin: 0; padding: 0; font-family: 'Roboto', Verdana, sans-serif; font-size: 16px; color: #2A2A2A; background-color: #E9E9E9;">
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; mso-hide: all;">
{{authorHandle}} posted in {{topicTitle}} on Topcoder.
</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" bgcolor="#E9E9E9" style="width: 100%; background-color: #E9E9E9;">
<tr>
<td align="center" style="padding: 52px 16px 32px;">
<!--[if mso]>
<table role="presentation" width="600" align="center" cellspacing="0" cellpadding="0" border="0"><tr><td>
<![endif]-->
<table role="presentation" align="center" width="100%" cellspacing="0" cellpadding="0" border="0" style="width: 100%; max-width: 600px;">
<tr>
<td align="center" style="padding: 0 0 24px;">
<img src="https://cdn.mcauto-images-production.sendgrid.net/a5c0eaad02bb03cd/96566d5e-c763-4bb7-8ed1-e1f0d372eab5/383x58.png" alt="Topcoder" width="198" height="30" style="display: block; width: 198px; height: 30px; border: 0;">
</td>
</tr>
<tr>
<td bgcolor="#FFFFFF" style="padding: 28px; background-color: #FFFFFF; border-radius: 8px; font-family: 'Roboto', Verdana, sans-serif; font-size: 16px; line-height: 24px;">
<h1 style="margin: 0 0 24px; font-family: 'Barlow Condensed', 'Roboto', Verdana, sans-serif; font-size: 34px; line-height: 40px; font-weight: 500; text-align: center; color: #0D61BF;">New forum post</h1>
<p style="margin: 0 0 24px;"><strong>{{authorHandle}}</strong> posted in <strong>{{topicTitle}}</strong> on Topcoder.</p>

<h2 style="margin: 0 0 12px; font-size: 18px; line-height: 26px; font-weight: 700;">Post details</h2>
<p style="margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-word;"><strong>Topic:</strong> {{topicTitle}}</p>
<p style="margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-word;"><strong>Topic ID:</strong> {{topicId}}</p>
{{#if challengeTitle}}
<p style="margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-word;"><strong>Challenge:</strong> {{challengeTitle}}</p>
{{/if}}
{{#if challengeId}}
<p style="margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-word;"><strong>Challenge ID:</strong> {{challengeId}}</p>
{{/if}}
<p style="margin: 0 0 12px;"><strong>Author:</strong> {{authorHandle}}</p>
<p style="margin: 0 0 24px;"><strong>Posted at (UTC):</strong> {{createdAt}}</p>

<h2 style="margin: 0 0 12px; font-size: 18px; line-height: 26px; font-weight: 700;">Post content</h2>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="width: 100%; table-layout: fixed;">
<tr>
<td bgcolor="#F5F5F5" style="padding: 16px; background-color: #F5F5F5; border-left: 3px solid #0D61BF; font-family: 'Roboto', Verdana, sans-serif; font-size: 16px; line-height: 24px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word;">{{postContent}}</td>
</tr>
</table>

<p style="margin: 24px 0;">You received this notification because you watch this topic or one of its parent topics.</p>
<p style="margin: 0;">Thanks,<br>Topcoder</p>
</td>
</tr>
<tr>
<td align="center" style="padding: 24px 0 0; font-family: 'Roboto', Verdana, sans-serif; font-size: 12px; line-height: 18px; color: #555555;">
Copyright &copy; Topcoder, All rights reserved.
</td>
</tr>
</table>
<!--[if mso]>
</td></tr></table>
<![endif]-->
</td>
</tr>
</table>
</body>
</html>
9 changes: 9 additions & 0 deletions docs/email-templates/forum-notification.sample.json
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 2 additions & 1 deletion src/config/notifications.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand All @@ -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,
Expand Down
129 changes: 129 additions & 0 deletions src/forums/challenge-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {}) {
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<typeof fetch>;
let m2mSpy: jest.SpiedFunction<typeof auth.m2m>;
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,
);
});
});
Loading
Loading