diff --git a/README.md b/README.md index 1410d93..e8e617a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. | @@ -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 @@ -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/" @@ -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. diff --git a/docs/email-templates/forum-notification.html b/docs/email-templates/forum-notification.html index 2e3932b..c7b51a5 100644 --- a/docs/email-templates/forum-notification.html +++ b/docs/email-templates/forum-notification.html @@ -31,8 +31,8 @@

Post details

Topic: {{topicTitle}}

Topic ID: {{topicId}}

- {{#if challengeTitle}} -

Challenge: {{challengeTitle}}

+ {{#if challengeName}} +

Challenge: {{challengeName}}

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

Challenge ID: {{challengeId}}

diff --git a/docs/email-templates/forum-notification.sample.json b/docs/email-templates/forum-notification.sample.json index 4b08282..87519a0 100644 --- a/docs/email-templates/forum-notification.sample.json +++ b/docs/email-templates/forum-notification.sample.json @@ -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!", diff --git a/src/config/notifications.config.ts b/src/config/notifications.config.ts index 3d516fb..0d73e12 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 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 () => ({ @@ -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, diff --git a/src/forums/challenge-api.service.spec.ts b/src/forums/challenge-api.service.spec.ts index 05e4d7d..0d39b5c 100644 --- a/src/forums/challenge-api.service.spec.ts +++ b/src/forums/challenge-api.service.spec.ts @@ -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'], diff --git a/src/forums/challenge-api.service.ts b/src/forums/challenge-api.service.ts index 6296542..8462cec 100644 --- a/src/forums/challenge-api.service.ts +++ b/src/forums/challenge-api.service.ts @@ -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. */ @@ -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('notifications.topcoderUrl') + ?.trim(); + const configuredApiBase = this.configService + .get('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. * diff --git a/src/forums/forums-watch-notification.service.spec.ts b/src/forums/forums-watch-notification.service.spec.ts index 7affc0f..63f0722 100644 --- a/src/forums/forums-watch-notification.service.spec.ts +++ b/src/forums/forums-watch-notification.service.spec.ts @@ -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) => @@ -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', ); @@ -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', @@ -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'), + ); + }); }); diff --git a/src/forums/forums-watch-notification.service.ts b/src/forums/forums-watch-notification.service.ts index 10e6518..a053eb4 100644 --- a/src/forums/forums-watch-notification.service.ts +++ b/src/forums/forums-watch-notification.service.ts @@ -26,6 +26,11 @@ interface ForumsWatchNotificationEmailPayload { version: 'v3'; } +interface ForumsNotificationChallengeContext { + title?: string; + url?: string; +} + /** * Parameters used to publish a watch notification for newly persisted content. */ @@ -160,12 +165,12 @@ export class ForumsWatchNotificationService { return { attemptedRecipientCount: 0, published: false }; } - const challengeTitle = await this.resolveChallengeTitle(params); + const challengeContext = await this.resolveChallengeContext(params); const payload = this.buildEmailPayload( params, templateId, recipientEmails, - challengeTitle, + challengeContext, ); try { @@ -374,30 +379,44 @@ export class ForumsWatchNotificationService { } /** - * Looks up the effective challenge title once per outgoing notification. + * Resolves the effective challenge name and public URL 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. + * @returns Available challenge template fields, or an empty object for non-challenge topics. + * @throws Does not throw; title and URL failures are logged independently so email delivery continues. */ - private async resolveChallengeTitle( + private async resolveChallengeContext( params: PublishForumsPostNotificationParams, - ): Promise { + ): Promise { const challengeId = params.restrictions.challengeId; if (!challengeId) { - return undefined; + return {}; } + const context: ForumsNotificationChallengeContext = {}; + try { - return await this.challengeApiService.getChallengeTitle(challengeId); + context.title = 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; } + + try { + context.url = this.challengeApiService.getChallengeUrl(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} URL resolution failed: ${message}`, + ); + } + + return context; } /** @@ -406,7 +425,7 @@ export class ForumsWatchNotificationService { * @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. + * @param challengeContext Challenge API name and public Opportunities URL when available. * @returns Event-bus email payload. * @throws Does not throw. */ @@ -414,14 +433,25 @@ export class ForumsWatchNotificationService { params: PublishForumsPostNotificationParams, templateId: string, recipients: string[], - challengeTitle: string | undefined, + challengeContext: ForumsNotificationChallengeContext, ): ForumsWatchNotificationEmailPayload { return { data: { ...(params.restrictions.challengeId ? { challengeId: params.restrictions.challengeId } : {}), - ...(challengeTitle ? { challengeTitle } : {}), + ...(challengeContext.title + ? { + challengeName: challengeContext.title, + challengeTitle: challengeContext.title, + } + : {}), + ...(challengeContext.url + ? { + challengeURL: challengeContext.url, + challengeUrl: challengeContext.url, + } + : {}), topicId: params.topic.id, topicTitle: params.topic.title, postContent: params.post.content ?? '',