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
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,12 @@ 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`.
includes its name and environment-correct Opportunities link. The established
`challengeName` / `challengeURL` SendGrid keys and the `challengeTitle` /
`challengeUrl` compatibility aliases carry identical values. This lookup runs
once per outgoing notification, after recipient authorization, and uses a
five-second HTTP timeout. Name and URL failures are logged independently, and
the email still publishes with every field that could be resolved.

The [forum notification HTML template](docs/email-templates/forum-notification.html)
adapts the Topcoder support email design for these notifications. Use the subject
Expand All @@ -79,7 +81,10 @@ 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. |
| `challengeName` | Challenge API `name`; omitted for non-challenge topics or failed lookups. The template hides this row when absent. |
| `challengeURL` | Public `/opportunities/challenge/:challengeId` URL. |
| `challengeTitle` | Compatibility alias of `challengeName`. |
| `challengeUrl` | Compatibility alias of `challengeURL`. |
| `topicId` | Created content's topic ID. |
| `topicTitle` | Topic title. |
| `postContent` | Persisted post content, or an empty string when null. |
Expand All @@ -88,13 +93,14 @@ Template variables come from the event payload's `data` object:

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.
event envelope. Remove all challenge fields 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.
for substitution and conditional syntax. The challenge name links to the new
Opportunities challenge-details route; the topic ID remains unlinked because the
page does not yet expose a stable topic-detail URL.

## Environment

Expand All @@ -114,6 +120,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"
TOPCODER_URL="https://www.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/"
Expand All @@ -134,7 +141,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.
`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. `TOPCODER_URL` optionally configures the public web origin used by challenge links. When omitted, the service derives it from `TOPCODER_API_URL_BASE` by replacing the conventional `api.` hostname prefix with `www.`.
`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
4 changes: 2 additions & 2 deletions docs/email-templates/forum-notification.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ <h1 style="margin: 0 0 24px; font-family: 'Barlow Condensed', 'Roboto', Verdana,
<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 challengeName}}
<p style="margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-word;"><strong>Challenge:</strong> <a href="{{challengeURL}}" style="color: #0D61BF; text-decoration: underline;">{{challengeName}}</a></p>
{{/if}}
{{#if challengeId}}
<p style="margin: 0 0 12px; overflow-wrap: anywhere; word-break: break-word;"><strong>Challenge ID:</strong> {{challengeId}}</p>
Expand Down
3 changes: 3 additions & 0 deletions docs/email-templates/forum-notification.sample.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"challengeId": "12345678-1234-4234-8234-123456789abc",
"challengeName": "Topcoder Forum Export Challenge",
"challengeTitle": "Topcoder Forum Export Challenge",
"challengeURL": "https://www.topcoder-dev.com/opportunities/challenge/12345678-1234-4234-8234-123456789abc",
"challengeUrl": "https://www.topcoder-dev.com/opportunities/challenge/12345678-1234-4234-8234-123456789abc",
"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!",
Expand Down
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 and Challenge API bases, Kafka error topic, and Auth0 M2M values.
* @returns SendGrid template, Bus, Challenge API, and public web 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,
topcoderUrl: process.env.TOPCODER_URL,
challengeApiUrl: process.env.CHALLENGE_API_URL,
kafkaErrorTopic: process.env.KAFKA_ERROR_TOPIC,
auth0Url: process.env.AUTH0_URL,
Expand Down
29 changes: 29 additions & 0 deletions src/forums/challenge-api.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,35 @@ describe('ChallengeApiService', () => {
);
});

it('builds an environment-correct Opportunities link from the shared API host', () => {
expect(createService().getChallengeUrl('challenge/id?query')).toBe(
'https://www.topcoder-dev.com/opportunities/challenge/challenge%2Fid%3Fquery',
);
});

it('uses the explicit Topcoder web URL for public challenge links', () => {
const service = createService({
'notifications.topcoderUrl': 'https://community.example.com/ignored/path?query=1',
});

expect(service.getChallengeUrl('challenge-1')).toBe(
'https://community.example.com/opportunities/challenge/challenge-1',
);
});

it.each([
[{ 'notifications.topcoderApiUrlBase': undefined }, 'must configure'],
[
{ 'notifications.topcoderUrl': 'javascript:alert(1)' },
'absolute HTTP(S) URL',
],
[{ 'notifications.topcoderUrl': 'not-a-url' }, 'absolute HTTP(S) URL'],
])('rejects unusable public challenge URL configuration: %p', (overrides, message) => {
expect(() => createService(overrides).getChallengeUrl('challenge-1')).toThrow(
message,
);
});

it.each([
['notifications.topcoderApiUrlBase', 'CHALLENGE_API_URL'],
['notifications.m2mClientId', 'M2M_CLIENT_ID'],
Expand Down
54 changes: 53 additions & 1 deletion src/forums/challenge-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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.
* Resolves challenge titles and public links for forum notifications.
* 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.
*/
Expand Down Expand Up @@ -91,6 +91,58 @@ export class ChallengeApiService {
return challenge.name;
}

/**
* Builds the public Opportunities URL for a challenge notification.
*
* `TOPCODER_URL` is authoritative when configured. Existing deployments can
* fall back to `TOPCODER_API_URL_BASE`; its conventional `api.` host prefix
* is changed to `www.` while preserving the environment domain.
*
* @param challengeId Effective challenge id inherited by the notified topic.
* @returns Absolute public challenge-details URL.
* @throws Error when neither web nor API base configuration is usable.
*/
getChallengeUrl(challengeId: string): string {
const configuredWebBase = this.configService
.get<string>('notifications.topcoderUrl')
?.trim();
const configuredApiBase = this.configService
.get<string>('notifications.topcoderApiUrlBase')
?.trim();
const configuredBase = configuredWebBase || configuredApiBase;

if (!configuredBase) {
throw new Error(
'TOPCODER_URL or TOPCODER_API_URL_BASE must configure public challenge links.',
);
}

let publicUrl: URL;

try {
publicUrl = new URL(configuredBase);
} catch {
throw new Error(
`${configuredWebBase ? 'TOPCODER_URL' : 'TOPCODER_API_URL_BASE'} must be an absolute HTTP(S) URL.`,
);
}

if (!['http:', 'https:'].includes(publicUrl.protocol)) {
throw new Error(
`${configuredWebBase ? 'TOPCODER_URL' : 'TOPCODER_API_URL_BASE'} must be an absolute HTTP(S) URL.`,
);
}

if (!configuredWebBase && publicUrl.hostname.startsWith('api.')) {
publicUrl.hostname = `www.${publicUrl.hostname.slice('api.'.length)}`;
}

publicUrl.pathname = `/opportunities/challenge/${encodeURIComponent(challengeId)}`;
publicUrl.search = '';
publicUrl.hash = '';
return publicUrl.toString();
}

/**
* Resolves the challenges collection URL from explicit or shared API settings.
*
Expand Down
45 changes: 45 additions & 0 deletions src/forums/forums-watch-notification.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ function createService(templateId: string | null = 'template-id') {
};
const challengeApiService = {
getChallengeTitle: jest.fn().mockResolvedValue('Challenge title'),
getChallengeUrl: jest
.fn()
.mockReturnValue(
'https://www.topcoder-dev.com/opportunities/challenge/challenge-1',
),
};
const configService = {
get: jest.fn((key: string) =>
Expand Down Expand Up @@ -167,6 +172,7 @@ describe('ForumsWatchNotificationService', () => {
});
expect(eventBusService.postEvent).toHaveBeenCalledTimes(1);
expect(challengeApiService.getChallengeTitle).not.toHaveBeenCalled();
expect(challengeApiService.getChallengeUrl).not.toHaveBeenCalled();
expect(eventBusService.postEvent.mock.calls[0][1].data).not.toHaveProperty(
'challengeTitle',
);
Expand Down Expand Up @@ -513,12 +519,21 @@ describe('ForumsWatchNotificationService', () => {
expect(challengeApiService.getChallengeTitle).toHaveBeenCalledWith(
'challenge-1',
);
expect(challengeApiService.getChallengeUrl).toHaveBeenCalledTimes(1);
expect(challengeApiService.getChallengeUrl).toHaveBeenCalledWith(
'challenge-1',
);
expect(eventBusService.postEvent).toHaveBeenCalledWith(
'external.action.email',
{
data: {
challengeId: 'challenge-1',
challengeName: 'Challenge title',
challengeTitle: 'Challenge title',
challengeURL:
'https://www.topcoder-dev.com/opportunities/challenge/challenge-1',
challengeUrl:
'https://www.topcoder-dev.com/opportunities/challenge/challenge-1',
topicId: 'topic-1',
topicTitle: 'Topic title',
postContent: 'Persisted post content',
Expand Down Expand Up @@ -556,8 +571,38 @@ describe('ForumsWatchNotificationService', () => {
const payload = eventBusService.postEvent.mock.calls[0][1];
expect(payload.data.challengeId).toBe('challenge-1');
expect(payload.data).not.toHaveProperty('challengeTitle');
expect(payload.data.challengeURL).toBe(
'https://www.topcoder-dev.com/opportunities/challenge/challenge-1',
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('challenge challenge-1 title lookup failed'),
);
});

it('still publishes the challenge name when public URL configuration is invalid', async () => {
const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const { challengeApiService, eventBusService, service } = createService();
challengeApiService.getChallengeUrl.mockImplementation(() => {
throw new Error('TOPCODER_URL is invalid');
});

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 });
const payload = eventBusService.postEvent.mock.calls[0][1];
expect(payload.data.challengeName).toBe('Challenge title');
expect(payload.data).not.toHaveProperty('challengeURL');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('challenge challenge-1 URL resolution failed'),
);
});
});
Loading
Loading