diff --git a/README.md b/README.md index bd9f144..d37b2e2 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,11 @@ and post-commit watch notifications. This service includes the forums schema, Prisma client export, topic read workflows, transactional command-side workflows, per-member post reactions, centralized forums authorization and runtime moderation for topics, posts, watches, and explicit read-state updates, plus best-effort watch notification publishing for new posts and successful allowed child-topic starter posts. The policy resolves inherited topic restrictions, challenge access, resource-role/copilot elevation, role matching, ownership, scoped M2M write access, and M2M on-behalf target-member visibility before writes, reads, or notification delivery. Runtime moderation enforces active member bans, trusted exact-IP bans for human request traffic, and locked-topic mutation rules. -Topic reads are exposed under `read:forums-topics`. `GET /v6/forums/topics` returns visible non-challenge root topics; `GET /v6/forums/topics/challenges/:challengeId` checks base challenge visibility before returning visible challenge roots; `GET /v6/forums/topics/:topicId/children` requires parent visibility before filtering direct children; and `GET /v6/forums/topics/:topicId` returns topic detail with an embedded post tree. Active member bans and trusted exact-IP bans return 403 before read policy or query work. Topic summaries include `locked`, `lockedBy`, and `lockedAt`; `lockedBy` and `lockedAt` may be null for imported legacy locked topics. Locked topics remain readable for callers that otherwise pass moderation and visibility checks. Detail embeds posts under `read:forums-topics`, keeps deleted post placeholders with null content, counts only non-deleted posts, derives unread state from `TopicReadState.lastReadAt`, and includes each post's shared `thumbsUpCount`/`thumbsDownCount` plus the authenticated member's nullable `viewerReaction`. `read:forums-posts` remains reserved for future post-specific read APIs in v1. +Topic reads are exposed under `read:forums-topics`. `GET /v6/forums/topics` returns visible non-challenge root topics; `GET /v6/forums/topics/challenges/:challengeId` checks base challenge visibility before returning visible challenge roots; `GET /v6/forums/topics/:topicId/children` requires parent visibility before filtering direct children; and `GET /v6/forums/topics/:topicId` returns topic detail with an embedded post tree. Active member bans and trusted exact-IP bans return 403 before read policy or query work. Topic summaries include `locked`, `lockedBy`, and `lockedAt`; `lockedBy` and `lockedAt` may be null for imported legacy locked topics. Locked topics remain readable for callers that otherwise pass moderation and visibility checks. Detail embeds posts under `read:forums-topics`, orders every top-level and nested sibling list chronologically from oldest to newest, keeps deleted post placeholders with null content, counts only non-deleted posts, derives unread state from `TopicReadState.lastReadAt`, and includes each post's current challenge-resource `authorIsCopilot` projection, shared `thumbsUpCount`/`thumbsDownCount`, and the authenticated member's nullable `viewerReaction`. `read:forums-posts` remains reserved for future post-specific read APIs in v1. Human members set or switch their one-per-post reaction with `PUT /v6/forums/posts/:postId/reaction` and `{ "reaction": "THUMBS_UP" }` or `{ "reaction": "THUMBS_DOWN" }`. `DELETE` on the same route idempotently removes the member's reaction. Both commands return the resulting `viewerReaction` and current shared counts, enforce runtime bans and inherited post visibility, and reject deleted posts. They remain available on locked topics because reactions do not change discussion content. The reaction routes are human-member-only; M2M callers cannot own reaction state. -Top-level non-challenge topics may be created by human admins and scoped M2M callers. Top-level challenge topics may be created by eligible challenge members, challenge copilots, and admins; M2M callers cannot create challenge roots. Regular authenticated members may create child topics only under parents they can see and only when the resolved effective child context remains non-challenge; requests that inherit or introduce a non-null `challengeId` under a parent are rejected before writes. Allowed child topics must keep monotonic role restrictions: inherited roles cannot be cleared or replaced. Challenge-scoped visibility is verified through the configured challenge and resource adapters, including challenge-resource membership and challenge-copilot elevation. Challenge copilots may access or moderate challenge-scoped forums only when any effective `roleName` forum restriction is also satisfied; admin and scoped M2M bypass behavior is unchanged. Active member bans and trusted exact-IP bans return 403 for human writes before content, watch, or read-state changes. M2M on-behalf watch and read-state commands enforce active bans on the resolved target member and do not evaluate IP bans. Locked topics reject child-topic creation under the locked parent, replies, topic updates/deletes, and post updates/deletes unless the actor is an administrator or a human challenge copilot acting on a challenge-scoped topic. Topic authors may edit their own topics, but deletion remains limited to administrators and scoped M2M callers; challenge-copilot elevation does not grant deletion. Optional command DTO fields whose transformed runtime value is `undefined` are treated as omitted, including post parent fields and announcement state. +Top-level non-challenge topics may be created by human admins and scoped M2M callers. Top-level challenge topics may be created by eligible challenge members, challenge copilots, and admins; M2M callers cannot create challenge roots. Regular authenticated members may create child topics only under parents they can see and only when the resolved effective child context remains non-challenge; requests that inherit or introduce a non-null `challengeId` under a parent are rejected before writes. Allowed child topics must keep monotonic role restrictions: inherited roles cannot be cleared or replaced. Challenge-scoped visibility is verified through the configured challenge and resource adapters, including challenge-resource membership and challenge-copilot elevation. Challenge copilots may access or moderate challenge-scoped forums only when any effective `roleName` forum restriction is also satisfied; admin and scoped M2M bypass behavior is unchanged. Active member bans and trusted exact-IP bans return 403 for human writes before content, watch, or read-state changes. M2M on-behalf watch and read-state commands enforce active bans on the resolved target member and do not evaluate IP bans. Locked topics reject child-topic creation under the locked parent, replies, topic updates/deletes, and post updates/deletes unless the actor is an administrator or a human challenge copilot acting on a challenge-scoped topic. Topic and post authors may edit their own content, but deletion remains limited to administrators and scoped M2M callers; challenge-copilot elevation does not grant deletion. Optional command DTO fields whose transformed runtime value is `undefined` are treated as omitted, including post parent fields and announcement state. Moderation management is exposed under `/v6/forums/moderation`. Human callers must have the case-insensitive `administrator` role; M2M callers must have `moderate:forums`. Human tokens do not gain moderation-route access from scopes alone, and M2M tokens do not gain access from roles alone. Challenge copilots do not gain moderation-endpoint access unless they are also administrators. Topic lock/unlock endpoints return `topicId`, `locked`, `lockedBy`, `lockedAt`, and `updatedAt`. Member and IP ban endpoints return the persisted ban row plus an `active` flag. Ban audit columns store the human administrator member id when available; M2M moderation stores null audit member ids. IP moderation accepts only exact bare IPv4 or IPv6 host values and rejects CIDR, wildcards, comma-delimited values, bracketed IPv6, host:port, quoted values, and invalid text. @@ -75,8 +75,9 @@ DATABASE_URL="postgresql://user:password@localhost:5432/forums" AUTH_SECRET="replace-with-a-secure-secret" VALID_ISSUERS='["https://topcoder-dev.auth0.com/","https://auth.topcoder-dev.com/","https://topcoder.auth0.com/","https://auth.topcoder.com/","https://api.topcoder.com","https://api.topcoder-dev.com"]' SENDGRID_NOTIFICATION_TEMPLATE="sendgrid-template-id" -BUS_API_URL="http://localhost:4000/eventBus" -BUSAPI_URL="http://localhost:4000/eventBus" +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" KAFKA_ERROR_TOPIC="common.error.reporting" AUTH0_URL="https://auth.topcoder-dev.com/" AUTH0_AUDIENCE="https://m2m.topcoder-dev.com/" @@ -96,7 +97,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. -`BUS_API_URL` or `BUSAPI_URL` configures the shared event-bus endpoint for `external.action.email`. `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. +`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. Health and readiness checks intentionally remain DB-only; they do not validate diff --git a/src/config/notifications.config.ts b/src/config/notifications.config.ts index f7d0ca4..d8dff96 100644 --- a/src/config/notifications.config.ts +++ b/src/config/notifications.config.ts @@ -5,13 +5,15 @@ * notification publisher. Missing template configuration disables notification * publishing without affecting forum writes. * - * @returns SendGrid template, event bus, Kafka error topic, and Auth0 M2M values. + * @returns SendGrid template, Bus API aliases/base, Kafka error topic, and Auth0 M2M values. * @throws Does not throw; consumers decide whether optional values are required. */ export default () => ({ notifications: { sendgridNotificationTemplate: process.env.SENDGRID_NOTIFICATION_TEMPLATE, - busApiUrl: process.env.BUS_API_URL ?? process.env.BUSAPI_URL, + busApiUrl: process.env.BUSAPI_URL, + busApiUrlAlias: process.env.BUS_API_URL, + topcoderApiUrlBase: process.env.TOPCODER_API_URL_BASE, kafkaErrorTopic: process.env.KAFKA_ERROR_TOPIC, auth0Url: process.env.AUTH0_URL, auth0Audience: process.env.AUTH0_AUDIENCE, diff --git a/src/forums/dto/forums-read.dto.ts b/src/forums/dto/forums-read.dto.ts index b236add..982cc45 100644 --- a/src/forums/dto/forums-read.dto.ts +++ b/src/forums/dto/forums-read.dto.ts @@ -273,6 +273,12 @@ export class ForumsPostTreeNodeDto { @ApiProperty({ description: 'Handle captured on the post author snapshot.' }) authorHandle: string; + @ApiProperty({ + description: + 'Whether the author currently holds a copilot resource role on the effective challenge.', + }) + authorIsCopilot: boolean; + @ApiProperty({ description: 'Number of non-deleted posts by this author in the current topic.', @@ -312,7 +318,7 @@ export class ForumsPostTreeNodeDto { viewerReaction: PostReactionType | null; @ApiProperty({ - description: 'Nested replies ordered by newest visible subtree activity.', + description: 'Nested replies ordered chronologically from oldest to newest.', type: () => [ForumsPostTreeNodeDto], }) replies: ForumsPostTreeNodeDto[]; @@ -333,7 +339,8 @@ export class ForumsTopicDetailDto { topic: ForumsTopicSummaryDto; @ApiProperty({ - description: 'Top-level posts and nested replies for the topic.', + description: + 'Top-level posts and nested replies ordered chronologically from oldest to newest.', type: [ForumsPostTreeNodeDto], }) posts: ForumsPostTreeNodeDto[]; diff --git a/src/forums/event-bus.service.spec.ts b/src/forums/event-bus.service.spec.ts new file mode 100644 index 0000000..f477d52 --- /dev/null +++ b/src/forums/event-bus.service.spec.ts @@ -0,0 +1,69 @@ +import { ConfigService } from '@nestjs/config'; +import { resolveV6BusApiBase } from './event-bus.service'; + +/** + * Creates a nested notification configuration test double. + * + * @param values Configuration values keyed by their resolved Nest paths. + * @returns ConfigService-compatible lookup object. + * @throws Does not throw. + */ +function configWith(values: Record): ConfigService { + return { + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService; +} + +describe('resolveV6BusApiBase', () => { + it('normalizes the canonical v6 base and the legacy full-endpoint alias', () => { + expect( + resolveV6BusApiBase( + configWith({ + 'notifications.busApiUrl': 'https://api.topcoder-dev.com/v6/', + 'notifications.busApiUrlAlias': + 'https://api.topcoder-dev.com/v6/bus/events', + }), + ), + ).toBe('https://api.topcoder-dev.com/v6'); + }); + + it('derives the v6 Bus base from the shared Topcoder API host', () => { + expect( + resolveV6BusApiBase( + configWith({ + 'notifications.topcoderApiUrlBase': + 'https://api.topcoder-dev.com/', + }), + ), + ).toBe('https://api.topcoder-dev.com/v6'); + }); + + it.each([ + ['http://localhost:4000/eventBus', 'Bus API base ending in /v6'], + ['https://api.topcoder-dev.com/v5', 'Bus API base ending in /v6'], + ['not-a-url', 'absolute Bus API v6 URL'], + ])('rejects an invalid configured Bus URL %s', (configured, message) => { + expect(() => + resolveV6BusApiBase( + configWith({ 'notifications.busApiUrl': configured }), + ), + ).toThrow(message); + }); + + it('rejects conflicting canonical and alias bases', () => { + expect(() => + resolveV6BusApiBase( + configWith({ + 'notifications.busApiUrl': 'https://api.topcoder-dev.com/v6', + 'notifications.busApiUrlAlias': 'https://api.topcoder.com/v6', + }), + ), + ).toThrow('resolve to different Bus API v6 bases'); + }); + + it('rejects missing Bus and shared API configuration', () => { + expect(() => resolveV6BusApiBase(configWith({}))).toThrow( + 'BUSAPI_URL, BUS_API_URL, or TOPCODER_API_URL_BASE', + ); + }); +}); diff --git a/src/forums/event-bus.service.ts b/src/forums/event-bus.service.ts index 0bd5077..46d1736 100644 --- a/src/forums/event-bus.service.ts +++ b/src/forums/event-bus.service.ts @@ -18,6 +18,97 @@ type BusApiClient = { postEvent: (message: EventBusMessage) => Promise; }; +/** + * Resolves the Bus API v6 base consumed by `tc-bus-api-wrapper`. + * + * The wrapper appends `/bus/events` itself. The canonical `BUSAPI_URL` and + * backwards-compatible `BUS_API_URL` alias may therefore provide either the + * v6 base or the complete event URL; complete URLs are reduced to the base. + * When neither is configured, `TOPCODER_API_URL_BASE` supplies the API host. + * + * @param configService Nest configuration service containing notification settings. + * @returns An absolute URL whose path ends in `/v6`. + * @throws Error when configuration is missing, invalid, non-v6, or conflicting. + */ +export function resolveV6BusApiBase(configService: ConfigService): string { + const canonical = configService + .get('notifications.busApiUrl') + ?.trim(); + const alias = configService + .get('notifications.busApiUrlAlias') + ?.trim(); + const canonicalBase = canonical + ? normalizeV6BusApiBase(canonical, 'BUSAPI_URL') + : undefined; + const aliasBase = alias + ? normalizeV6BusApiBase(alias, 'BUS_API_URL') + : undefined; + + if (canonicalBase && aliasBase && canonicalBase !== aliasBase) { + throw new Error( + 'BUSAPI_URL and BUS_API_URL resolve to different Bus API v6 bases.', + ); + } + + if (canonicalBase) { + return canonicalBase; + } + + if (aliasBase) { + return aliasBase; + } + + const topcoderApiUrlBase = configService + .get('notifications.topcoderApiUrlBase') + ?.trim(); + + if (!topcoderApiUrlBase) { + throw new Error( + 'BUSAPI_URL, BUS_API_URL, or TOPCODER_API_URL_BASE must configure the Bus API v6 base.', + ); + } + + return normalizeV6BusApiBase( + `${topcoderApiUrlBase.replace(/\/+$/, '')}/v6`, + 'TOPCODER_API_URL_BASE', + ); +} + +/** + * Normalizes a configured Bus API base or complete event URL. + * + * @param configured Configured URL value. + * @param key Environment key represented by the value. + * @returns Absolute v6 API base without a trailing slash. + * @throws Error when the URL is invalid or does not target Bus API v6. + */ +function normalizeV6BusApiBase(configured: string, key: string): string { + let parsed: URL; + + try { + parsed = new URL(configured); + } catch { + throw new Error(`${key} must be an absolute Bus API v6 URL.`); + } + + let pathname = parsed.pathname.replace(/\/+$/, ''); + + if (pathname.endsWith('/bus/events')) { + pathname = pathname.slice(0, -'/bus/events'.length); + } + + if (!pathname.endsWith('/v6')) { + throw new Error( + `${key} must be the Bus API base ending in /v6 or its /bus/events endpoint.`, + ); + } + + parsed.pathname = pathname; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/$/, ''); +} + /** * Local adapter for publishing forums events to the shared event bus. * @@ -98,9 +189,7 @@ export class EventBusService { AUTH0_CLIENT_SECRET: this.configService.get( 'notifications.m2mClientSecret', ), - BUSAPI_URL: - this.configService.get('notifications.busApiUrl') || - 'http://localhost:4000/eventBus', + BUSAPI_URL: resolveV6BusApiBase(this.configService), KAFKA_ERROR_TOPIC: this.configService.get('notifications.kafkaErrorTopic') || 'common.error.reporting', diff --git a/src/forums/forums-access-policy.service.spec.ts b/src/forums/forums-access-policy.service.spec.ts index ae6aeeb..9b676c9 100644 --- a/src/forums/forums-access-policy.service.spec.ts +++ b/src/forums/forums-access-policy.service.spec.ts @@ -239,7 +239,10 @@ describe('ForumsAccessPolicyService', () => { }); expect(topicDecisions.canControlAnnouncement).toEqual({ allowed: true }); expect(postDecisions.canUpdatePost).toEqual({ allowed: true }); - expect(postDecisions.canDeletePost).toEqual({ allowed: true }); + expect(postDecisions.canDeletePost).toEqual({ + allowed: false, + reason: 'Only an administrator may delete a post.', + }); }); it('denies dual-restricted visibility and elevation for a challenge copilot without the role', async () => { @@ -326,14 +329,22 @@ describe('ForumsAccessPolicyService', () => { expect(topicDecisions.canControlAnnouncement).toEqual({ allowed: true }); }); - it('lets an author edit but not delete their own topic', async () => { + it('lets an author edit but not delete their own topic or post', async () => { const { service } = createPolicyHarness(); - const decisions = await service.decideForTopic( - makePrincipal({ memberId: 'author-1' }), - makeTopicContext({ + const principal = makePrincipal({ memberId: 'author-1' }); + const context = makeTopicContext({ + effectiveChallengeId: null, + effectiveRoleName: null, + isTopicAuthor: true, + }); + const decisions = await service.decideForTopic(principal, context); + const postDecisions = await service.decideForPost( + principal, + makePostContext({ + ...context, effectiveChallengeId: null, effectiveRoleName: null, - isTopicAuthor: true, + isPostAuthor: true, }), ); @@ -342,6 +353,11 @@ describe('ForumsAccessPolicyService', () => { allowed: false, reason: 'Only an administrator may delete a topic.', }); + expect(postDecisions.canUpdatePost).toEqual({ allowed: true }); + expect(postDecisions.canDeletePost).toEqual({ + allowed: false, + reason: 'Only an administrator may delete a post.', + }); }); it('allows general public child-topic creation', async () => { @@ -509,11 +525,23 @@ describe('ForumsAccessPolicyService', () => { machinePrincipal, makeTopicContext({ hasRestrictionConflict: true }), ); + const adminPostDecisions = await service.decideForPost( + adminPrincipal, + makePostContext({ hasRestrictionConflict: true }), + ); + const machinePostDecisions = await service.decideForPost( + machinePrincipal, + makePostContext({ hasRestrictionConflict: true }), + ); expect(adminTopicDecisions.canUpdateTopic).toEqual({ allowed: true }); expect(machineTopicDecisions.canUpdateTopic).toEqual({ allowed: true }); expect(adminTopicDecisions.canDeleteTopic).toEqual({ allowed: true }); expect(machineTopicDecisions.canDeleteTopic).toEqual({ allowed: true }); + expect(adminPostDecisions.canUpdatePost).toEqual({ allowed: true }); + expect(machinePostDecisions.canUpdatePost).toEqual({ allowed: true }); + expect(adminPostDecisions.canDeletePost).toEqual({ allowed: true }); + expect(machinePostDecisions.canDeletePost).toEqual({ allowed: true }); expect( challengeAccessService.getChallengeAccessFacts, ).not.toHaveBeenCalled(); diff --git a/src/forums/forums-access-policy.service.ts b/src/forums/forums-access-policy.service.ts index cdd4712..772f42c 100644 --- a/src/forums/forums-access-policy.service.ts +++ b/src/forums/forums-access-policy.service.ts @@ -147,13 +147,16 @@ export class ForumsAccessPolicyService { : this.deny( 'Only the author or an elevated forums actor may modify it.', ); + const canDeletePost = this.evaluatePostDeletion(evaluation); return { ...topicDecisions, canUpdatePost: context.post.deletedAt ? this.deny('Post not found.') : canMutateContent, - canDeletePost: canMutateContent, + canDeletePost: context.post.deletedAt + ? this.deny('Post not found.') + : canDeletePost, }; } @@ -512,6 +515,21 @@ export class ForumsAccessPolicyService { : this.deny('Only an administrator may delete a topic.'); } + /** + * Applies legacy-compatible post deletion rules independently of editing. + * + * @param evaluation Restriction evaluation for the persisted post's topic. + * @returns Decision allowing administrators and scoped M2M callers, but not authors or challenge copilots. + * @throws Does not throw. + */ + private evaluatePostDeletion( + evaluation: RestrictionEvaluation, + ): ForumsAccessDecision { + return evaluation.isElevated && !evaluation.isChallengeCopilot + ? this.allow() + : this.deny('Only an administrator may delete a post.'); + } + /** * Applies the approved root-topic creation rules. * diff --git a/src/forums/forums-notification-read.integration.spec.ts b/src/forums/forums-notification-read.integration.spec.ts index 7d73714..59bc590 100644 --- a/src/forums/forums-notification-read.integration.spec.ts +++ b/src/forums/forums-notification-read.integration.spec.ts @@ -771,6 +771,16 @@ describe('forums notification/read integration', () => { }) .overrideProvider(ResourceAccessService) .useValue({ + getChallengeCopilotMemberIds: jest.fn( + (challengeId: string, memberIds: readonly string[]) => + new Set( + challengeId === 'challenge-1' + ? memberIds.filter((memberId) => + challengeCopilots.has(memberId), + ) + : [], + ), + ), getResourceAccessFacts: jest.fn( (_challengeId: string, memberId: string | null) => ({ configured: true, @@ -837,6 +847,36 @@ describe('forums notification/read integration', () => { await app?.close(); }); + it('notifies a member who watches through the API while excluding the posting author', async () => { + seedData.topicWatches = [{ topicId: 'parent-1', memberId: '1' }]; + + await request(app.getHttpServer()) + .put('/topics/parent-1/watch') + .set('x-member-id', '2') + .send({}) + .expect(200); + + await request(app.getHttpServer()) + .post('/topics/parent-1/posts') + .set('x-member-id', '1') + .send({ content: 'A watched-topic update.' }) + .expect(201); + + expect(seedData.topicWatches).toEqual([ + { topicId: 'parent-1', memberId: '1' }, + { topicId: 'parent-1', memberId: '2' }, + ]); + expect(publishedEvents).toHaveLength(1); + expect(publishedEvents[0]).toEqual({ + topic: 'external.action.email', + payload: expect.objectContaining({ + recipients: ['two@example.com'], + sendgrid_template_id: 'template-id', + version: 'v3', + }), + }); + }); + it('matches role-restricted child-topic notification recipients to read-visible ancestor watchers while excluding the author', async () => { const createResponse = await request(app.getHttpServer()) .post('/topics') @@ -990,6 +1030,79 @@ describe('forums notification/read integration', () => { ); }); + it('returns challenge posts and replies oldest-first with copilot author metadata', async () => { + seedData.topics.push( + makeTopic({ + id: 'challenge-topic', + challengeId: 'challenge-1', + title: 'Challenge topic', + }), + ); + seedData.topicClosures.push({ + ancestorTopicId: 'challenge-topic', + descendantTopicId: 'challenge-topic', + depth: 0, + }); + seedData.posts.push( + makePost({ + id: 'root-newest', + topicId: 'challenge-topic', + parentId: 'challenge-topic', + authorMemberId: '2', + authorHandle: 'member-2', + createdAt: new Date('2026-06-08T00:00:00.000Z'), + }), + makePost({ + id: 'root-oldest', + topicId: 'challenge-topic', + parentId: 'challenge-topic', + authorMemberId: '6', + authorHandle: 'member-6', + createdAt: new Date('2026-06-05T00:00:00.000Z'), + }), + makePost({ + id: 'reply-newest', + topicId: 'challenge-topic', + parentType: 'POST', + parentId: 'root-oldest', + authorMemberId: '2', + authorHandle: 'member-2', + createdAt: new Date('2026-06-07T00:00:00.000Z'), + }), + makePost({ + id: 'reply-oldest', + topicId: 'challenge-topic', + parentType: 'POST', + parentId: 'root-oldest', + authorMemberId: '6', + authorHandle: 'member-6', + createdAt: new Date('2026-06-06T00:00:00.000Z'), + }), + ); + + const response = await request(app.getHttpServer()) + .get('/topics/challenge-topic') + .set('x-member-id', '1') + .expect(200); + + expect(response.body.posts.map((post: Post) => post.id)).toEqual([ + 'root-oldest', + 'root-newest', + ]); + expect( + response.body.posts[0].replies.map((post: Post) => post.id), + ).toEqual(['reply-oldest', 'reply-newest']); + expect(response.body.posts[0]).toEqual( + expect.objectContaining({ authorIsCopilot: true }), + ); + expect(response.body.posts[0].replies[0]).toEqual( + expect.objectContaining({ authorIsCopilot: true }), + ); + expect(response.body.posts[1]).toEqual( + expect.objectContaining({ authorIsCopilot: false }), + ); + }); + it('shares post reaction counts while preserving each member viewer state', async () => { seedData.posts.push(makePost({ topicId: 'parent-1', parentId: 'parent-1' })); seedData.postReactions.push( diff --git a/src/forums/forums-read.service.ts b/src/forums/forums-read.service.ts index 8daa091..631892b 100644 --- a/src/forums/forums-read.service.ts +++ b/src/forums/forums-read.service.ts @@ -1,6 +1,7 @@ import { ForbiddenException, Injectable, + Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -30,12 +31,13 @@ import { } from './forums-read-query.service'; import { ForumsModerationService } from './forums-moderation.service'; import { ForumsTopicContextService } from './forums-topic-context.service'; +import { ResourceAccessService } from './resource-access.service'; const POST_PARENT_POST = 'POST'; const POST_PARENT_TOPIC = 'TOPIC'; /** - * Internal post tree node carrying subtree sort metadata. + * Internal post tree node carrying presentation metadata. */ interface ForumsPostTreeNodeInternal { id: string; @@ -44,6 +46,7 @@ interface ForumsPostTreeNodeInternal { parentId: string; authorMemberId: string; authorHandle: string; + authorIsCopilot: boolean; authorPostsCount: number; content: string | null; createdAt: Date; @@ -53,7 +56,6 @@ interface ForumsPostTreeNodeInternal { thumbsDownCount: number; viewerReaction: PostReactionType | null; replies: ForumsPostTreeNodeInternal[]; - subtreeLatestActivityAt: Date | null; } /** @@ -67,6 +69,8 @@ interface ForumsPostTreeNodeInternal { */ @Injectable() export class ForumsReadService { + private readonly logger = new Logger(ForumsReadService.name); + /** * Creates a forums read service. * @@ -74,6 +78,7 @@ export class ForumsReadService { * @param topicContextService Loader for effective topic restrictions. * @param readQueryService Side-effect-free raw-query reader. * @param moderationService Shared runtime ban and lock gate. + * @param resourceAccessService Adapter for challenge copilot assignments. * @throws Does not throw directly; dependencies are resolved by Nest. */ constructor( @@ -81,6 +86,7 @@ export class ForumsReadService { private readonly topicContextService: ForumsTopicContextService, private readonly readQueryService: ForumsReadQueryService, private readonly moderationService: ForumsModerationService, + private readonly resourceAccessService: ResourceAccessService, ) {} /** @@ -279,9 +285,18 @@ export class ForumsReadService { throw new NotFoundException('Topic not found.'); } + const copilotMemberIds = await this.resolvePostAuthorCopilots( + context.effectiveChallengeId, + detailSnapshot.postRows, + ); + return { topic: this.mapTopicSummary(detailSnapshot.summaryRow), - posts: this.buildPostTree(topicId, detailSnapshot.postRows), + posts: this.buildPostTree( + topicId, + detailSnapshot.postRows, + copilotMemberIds, + ), }; } @@ -537,26 +552,62 @@ export class ForumsReadService { } /** - * Assembles post rows into a newest-active embedded thread tree. + * Resolves copilot presentation metadata for post authors without making a + * readable forum unavailable when the resource-domain projection fails. + * + * @param challengeId Effective challenge restriction for the topic. + * @param rows Post rows returned by the detail query. + * @returns Candidate author member ids currently assigned a copilot role. + * @throws Does not throw; resource lookup failures are logged and omitted. + */ + private async resolvePostAuthorCopilots( + challengeId: string | null, + rows: readonly ForumsPostTreeRow[], + ): Promise> { + if (!challengeId || rows.length === 0) { + return new Set(); + } + + try { + return await this.resourceAccessService.getChallengeCopilotMemberIds( + challengeId, + rows.map((row) => row.authorMemberId), + ); + } catch (error) { + this.logger.warn( + `Unable to resolve forum post copilot badges for challenge ${challengeId}.`, + error instanceof Error ? error.message : String(error), + ); + return new Set(); + } + } + + /** + * Assembles post rows into an oldest-first embedded thread tree. * * Top-level `TOPIC` children and nested `POST` replies are preserved. Deleted - * rows stay as placeholders with null content, and branch ordering is based on - * each subtree's latest non-deleted post timestamp. + * rows stay as placeholders with null content, and every sibling list is + * ordered chronologically with a stable id fallback. * * @param topicId Topic id used to identify top-level post parentage. * @param rows Post rows returned by the detail query. + * @param copilotMemberIds Post authors assigned a challenge copilot role. * @returns Public post tree DTOs. * @throws Does not throw. */ private buildPostTree( topicId: string, rows: readonly ForumsPostTreeRow[], + copilotMemberIds: ReadonlySet, ): ForumsPostTreeNodeDto[] { const nodesById = new Map(); const roots: ForumsPostTreeNodeInternal[] = []; for (const row of rows) { - nodesById.set(row.id, this.mapPostNode(row)); + nodesById.set( + row.id, + this.mapPostNode(row, copilotMemberIds.has(row.authorMemberId)), + ); } for (const row of rows) { @@ -584,7 +635,7 @@ export class ForumsReadService { } for (const root of roots) { - this.refreshSubtreeLatestActivity(root); + this.sortRepliesChronologically(root); } roots.sort((left, right) => this.comparePostNodes(left, right)); @@ -596,10 +647,14 @@ export class ForumsReadService { * Maps a raw post row into an internal post tree node. * * @param row Raw post row from the detail query. - * @returns Internal node with subtree sort metadata. + * @param authorIsCopilot Whether the author holds a challenge copilot role. + * @returns Internal post tree node. * @throws Does not throw. */ - private mapPostNode(row: ForumsPostTreeRow): ForumsPostTreeNodeInternal { + private mapPostNode( + row: ForumsPostTreeRow, + authorIsCopilot: boolean, + ): ForumsPostTreeNodeInternal { const deleted = Boolean(row.deletedAt); return { @@ -609,6 +664,7 @@ export class ForumsReadService { parentId: row.parentId, authorMemberId: row.authorMemberId, authorHandle: row.authorHandle, + authorIsCopilot, authorPostsCount: Number(row.authorPostsCount), content: deleted ? null : row.content, createdAt: row.createdAt, @@ -618,41 +674,26 @@ export class ForumsReadService { thumbsDownCount: Number(row.thumbsDownCount), viewerReaction: row.viewerReaction, replies: [], - subtreeLatestActivityAt: deleted ? null : row.createdAt, }; } /** - * Computes subtree latest visible activity and sorts each reply list. + * Sorts every nested reply list chronologically. * - * @param node Internal post tree node to refresh. - * @returns Latest non-deleted post timestamp for the subtree, or null. + * @param node Internal post tree node to sort recursively. + * @returns Nothing. * @throws Does not throw. */ - private refreshSubtreeLatestActivity( - node: ForumsPostTreeNodeInternal, - ): Date | null { - let latest = node.deleted ? null : node.createdAt; - + private sortRepliesChronologically(node: ForumsPostTreeNodeInternal): void { for (const reply of node.replies) { - const replyLatest = this.refreshSubtreeLatestActivity(reply); - - if ( - replyLatest && - (!latest || replyLatest.getTime() > latest.getTime()) - ) { - latest = replyLatest; - } + this.sortRepliesChronologically(reply); } - node.subtreeLatestActivityAt = latest; node.replies.sort((left, right) => this.comparePostNodes(left, right)); - - return latest; } /** - * Compares post tree nodes by subtree activity and stable fallback fields. + * Compares post tree nodes by creation time and a stable id fallback. * * @param left First post tree node. * @param right Second post tree node. @@ -663,15 +704,8 @@ export class ForumsReadService { left: ForumsPostTreeNodeInternal, right: ForumsPostTreeNodeInternal, ): number { - const leftActivity = left.subtreeLatestActivityAt?.getTime() ?? -Infinity; - const rightActivity = right.subtreeLatestActivityAt?.getTime() ?? -Infinity; - - if (leftActivity !== rightActivity) { - return rightActivity - leftActivity; - } - const createdAtDifference = - right.createdAt.getTime() - left.createdAt.getTime(); + left.createdAt.getTime() - right.createdAt.getTime(); return createdAtDifference !== 0 ? createdAtDifference @@ -679,7 +713,7 @@ export class ForumsReadService { } /** - * Strips internal subtree sort metadata from a post node. + * Maps an internal post tree node to the public response contract. * * @param node Internal post tree node. * @returns Public post tree DTO. @@ -695,6 +729,7 @@ export class ForumsReadService { parentId: node.parentId, authorMemberId: node.authorMemberId, authorHandle: node.authorHandle, + authorIsCopilot: node.authorIsCopilot, authorPostsCount: node.authorPostsCount, content: node.content, createdAt: node.createdAt, diff --git a/src/forums/posts.controller.ts b/src/forums/posts.controller.ts index 595cd4b..4587993 100644 --- a/src/forums/posts.controller.ts +++ b/src/forums/posts.controller.ts @@ -202,7 +202,7 @@ export class PostsController { @ApiOperation({ summary: 'Delete a forum post placeholder', description: - 'Command route for post soft deletion. Post deletion requires an authenticated member token or `delete:forums-posts`, then passes inherited topic visibility, ownership, and elevated challenge-access policy checks. Active member bans and trusted exact-IP bans return 403 before writes. Locked owning topics reject deletion unless the caller is an administrator or eligible challenge copilot.', + 'Command route for post soft deletion. Human deletion is administrator-only; M2M callers require `delete:forums-posts`. Authors and challenge copilots may edit eligible posts but cannot delete them. Active member bans and trusted exact-IP bans return 403 before writes, and locked owning topics reject non-administrator deletion.', }) @ApiParam({ name: 'postId', description: 'Post id.' }) @ApiResponse({ diff --git a/src/forums/resource-access.service.ts b/src/forums/resource-access.service.ts index aa297c2..eb695a3 100644 --- a/src/forums/resource-access.service.ts +++ b/src/forums/resource-access.service.ts @@ -84,6 +84,41 @@ export class ResourceAccessService implements OnModuleDestroy { }; } + /** + * Resolves which candidate members hold a copilot resource role on a challenge. + * + * @param challengeId Challenge id inherited by the forum topic. + * @param memberIds Candidate post-author member ids. + * @returns Set containing candidate member ids assigned a copilot role. + * @throws Prisma errors when a configured resource database lookup fails. + */ + async getChallengeCopilotMemberIds( + challengeId: string, + memberIds: readonly string[], + ): Promise> { + const client = this.resolveClient(); + const candidates = Array.from( + new Set(memberIds.map((memberId) => memberId.trim()).filter(Boolean)), + ); + + if (!client || candidates.length === 0) { + return new Set(); + } + + const resources = await client.resource.findMany({ + where: { + challengeId, + memberId: { in: candidates }, + resourceRole: { + nameLower: { contains: 'copilot' }, + }, + }, + select: { memberId: true }, + }); + + return new Set(resources.map((resource) => resource.memberId)); + } + /** * Disconnects the resource Prisma client during Nest module shutdown. * diff --git a/src/forums/topics.controller.ts b/src/forums/topics.controller.ts index 664128e..9374b78 100644 --- a/src/forums/topics.controller.ts +++ b/src/forums/topics.controller.ts @@ -362,7 +362,7 @@ export class TopicsController { @ApiOperation({ summary: 'Soft-delete a forum topic', description: - 'Command route for topic soft deletion. Topic deletion requires an authenticated member token or `delete:forums-topics`, then passes centralized forums policy checks for visibility, ownership, and elevated challenge access. Active member bans and trusted exact-IP bans return 403 before writes. Locked topics reject deletion unless the caller is an administrator or eligible challenge copilot.', + 'Command route for topic soft deletion. Human deletion is administrator-only; M2M callers require `delete:forums-topics`. Authors and challenge copilots may edit eligible topics but cannot delete them. Active member bans and trusted exact-IP bans return 403 before writes, and locked topics reject non-administrator deletion.', }) @ApiParam({ name: 'topicId', description: 'Topic id.' }) @ApiResponse({ diff --git a/src/main.ts b/src/main.ts index 3185704..3764273 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,7 +37,7 @@ async function bootstrap(): Promise { const swaggerConfig = new DocumentBuilder() .setTitle('Topcoder Forums API') .setDescription( - 'Topcoder forums API. The service exposes topic read routes protected by `read:forums-topics` plus transactional topic, post, human-member post-reaction, watch, and read-state mutations with centralized forums authorization, runtime member/IP ban checks, locked-topic content-mutation restrictions, inherited topic restrictions, challenge/resource access checks, granular M2M scopes, and post-commit best-effort watch notification emails for created posts and child-topic starter posts. Dedicated moderation management routes let human administrators or M2M callers with `moderate:forums` lock/unlock topics and manage member or exact-IP bans; challenge copilots do not gain moderation-endpoint access. Topic detail embeds posts with shared reaction counts and current-member reaction state under `read:forums-topics`; `read:forums-posts` remains reserved for future post-specific reads. Health and readiness remain DB-only.', + 'Topcoder forums API. The service exposes topic read routes protected by `read:forums-topics` plus transactional topic, post, human-member post-reaction, watch, and read-state mutations with centralized forums authorization, runtime member/IP ban checks, locked-topic content-mutation restrictions, inherited topic restrictions, challenge/resource access checks, granular M2M scopes, and post-commit best-effort watch notification emails for created posts and child-topic starter posts. Dedicated moderation management routes let human administrators or M2M callers with `moderate:forums` lock/unlock topics and manage member or exact-IP bans; challenge copilots do not gain moderation-endpoint access. Topic detail embeds oldest-first posts and replies with challenge-copilot author metadata, shared reaction counts, and current-member reaction state under `read:forums-topics`; `read:forums-posts` remains reserved for future post-specific reads. Topic and post deletion is limited to administrators and appropriately scoped M2M callers. Health and readiness remain DB-only.', ) .setVersion('6.0') .addBearerAuth({