From daba18780e1fd621f8ab074d052a99c7d916ae16 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 26 Aug 2026 20:36:54 +1000 Subject: [PATCH 1/9] feat: enrich challenge forum read models --- src/forums/challenge-access.service.ts | 13 ++-- src/forums/dto/forums-read.dto.ts | 53 ++++++++++++- ...rums-notification-read.integration.spec.ts | 40 +++++++++- src/forums/forums-read-query.service.ts | 77 ++++++++++++++++++- src/forums/forums-read.service.ts | 8 ++ .../vanilla-challenge-lookup.service.ts | 13 ++-- 6 files changed, 185 insertions(+), 19 deletions(-) diff --git a/src/forums/challenge-access.service.ts b/src/forums/challenge-access.service.ts index 45bf5aa..4fbbf1b 100644 --- a/src/forums/challenge-access.service.ts +++ b/src/forums/challenge-access.service.ts @@ -1,6 +1,9 @@ import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PrismaClient as ChallengePrismaClient } from '../../../challenge-api-v6/packages/challenge-prisma-client'; +import { + createChallengePrismaClient, + PrismaClient as ChallengePrismaClient, +} from '../../../challenge-api-v6/packages/challenge-prisma-client'; /** * Challenge-domain access facts normalized for forum authorization. @@ -130,13 +133,7 @@ export class ChallengeAccessService implements OnModuleDestroy { return undefined; } - this.client = new ChallengePrismaClient({ - datasources: { - db: { - url: databaseUrl, - }, - }, - }); + this.client = createChallengePrismaClient(databaseUrl); return this.client; } diff --git a/src/forums/dto/forums-read.dto.ts b/src/forums/dto/forums-read.dto.ts index 71e4db7..c64409a 100644 --- a/src/forums/dto/forums-read.dto.ts +++ b/src/forums/dto/forums-read.dto.ts @@ -86,13 +86,29 @@ export class ForumsLatestActivityDto { createdAt: Date; } +/** + * Compact participant snapshot embedded in a forum topic summary. + * + * The snapshot uses the immutable author identity captured on visible posts so + * list clients can render participant groups without additional forum reads. + */ +export class ForumsTopicParticipantDto { + @ApiProperty({ description: 'Member id captured on a visible post.' }) + memberId: string; + + @ApiProperty({ description: 'Latest handle captured for that member.' }) + handle: string; +} + /** * Topic summary returned by list routes and reused as the topic detail header. * * `postsCount` counts only non-deleted posts. `latestActivity` is nullable when * no non-deleted posts remain, `unread` is derived from * `TopicReadState.lastReadAt` compared with the latest visible activity, and - * lock metadata may be null for imported legacy locked topics. + * lock metadata may be null for imported legacy locked topics. `viewsCount` + * counts unique authenticated members with a read-state row, while participant + * snapshots are limited and paired with the complete `participantsCount`. */ export class ForumsTopicSummaryDto { @ApiProperty({ description: 'Topic id.' }) @@ -162,6 +178,35 @@ export class ForumsTopicSummaryDto { }) postsCount: number; + @ApiProperty({ + description: + 'Number of unique authenticated members who have read or authored the topic.', + }) + viewsCount: number; + + @ApiProperty({ + description: 'Whether the authenticated member is watching this topic.', + }) + watching: boolean; + + @ApiPropertyOptional({ + description: + 'Bounded plain starter-post excerpt for topic-list presentation.', + nullable: true, + }) + starterPostExcerpt: string | null; + + @ApiProperty({ + description: 'Total number of distinct visible post participants.', + }) + participantsCount: number; + + @ApiProperty({ + description: 'First five visible participants in activity order.', + type: [ForumsTopicParticipantDto], + }) + participants: ForumsTopicParticipantDto[]; + @ApiPropertyOptional({ description: 'Newest non-deleted post snapshot, or null when none remain.', nullable: true, @@ -227,6 +272,12 @@ export class ForumsPostTreeNodeDto { @ApiProperty({ description: 'Handle captured on the post author snapshot.' }) authorHandle: string; + @ApiProperty({ + description: + 'Number of non-deleted posts by this author in the current topic.', + }) + authorPostsCount: number; + @ApiPropertyOptional({ description: 'Markdown content, or null for deleted placeholders.', nullable: true, diff --git a/src/forums/forums-notification-read.integration.spec.ts b/src/forums/forums-notification-read.integration.spec.ts index d92db6b..390cd50 100644 --- a/src/forums/forums-notification-read.integration.spec.ts +++ b/src/forums/forums-notification-read.integration.spec.ts @@ -138,7 +138,7 @@ function createSeededForumsDb(seed: SeededForumsData) { return ban ? [{ id: ban.id }] : []; } - if (queryText.includes('FROM "Post" p')) { + if (queryText.includes('SELECT\n p.id')) { return seed.posts .filter((post) => post.topicId === topicId) .sort(compareCreatedAtThenId) @@ -149,6 +149,12 @@ function createSeededForumsDb(seed: SeededForumsData) { parentId: post.parentId, authorMemberId: post.authorMemberId, authorHandle: post.authorHandle, + authorPostsCount: seed.posts.filter( + (candidate) => + candidate.topicId === post.topicId && + candidate.authorMemberId === post.authorMemberId && + !candidate.deletedAt, + ).length, content: post.content, createdAt: post.createdAt, updatedAt: post.updatedAt, @@ -497,6 +503,19 @@ function buildTopicSummaryRow( const readState = seed.topicReadStates.find( (state) => state.topicId === topic.id && state.memberId === memberId, ); + const participantsByMemberId = new Map< + string, + { memberId: string; handle: string } + >(); + + for (const post of posts) { + participantsByMemberId.set(post.authorMemberId, { + memberId: post.authorMemberId, + handle: post.authorHandle, + }); + } + + const participants = Array.from(participantsByMemberId.values()); return { id: topic.id, @@ -513,6 +532,15 @@ function buildTopicSummaryRow( createdAt: topic.createdAt, updatedAt: topic.updatedAt, postsCount: posts.length, + viewsCount: seed.topicReadStates.filter( + (state) => state.topicId === topic.id, + ).length, + watching: seed.topicWatches.some( + (watch) => watch.topicId === topic.id && watch.memberId === memberId, + ), + starterPostExcerpt: posts[0]?.content?.slice(0, 280) ?? null, + participantsCount: participants.length, + participants: participants.slice(0, 5), latestPostId: latestPost?.id ?? null, latestPostAuthorMemberId: latestPost?.authorMemberId ?? null, latestPostAuthorHandle: latestPost?.authorHandle ?? null, @@ -771,8 +799,18 @@ describe('forums notification/read integration', () => { locked: false, lockedBy: null, lockedAt: null, + participants: [ + { handle: 'author', memberId: '1' }, + ], + participantsCount: 1, + starterPostExcerpt: 'Restricted starter content', + viewsCount: 1, + watching: true, }), ); + expect(authorDetailResponse.body.posts[0]).toEqual( + expect.objectContaining({ authorPostsCount: 1 }), + ); expect(publishedEvents).toHaveLength(1); expect(publishedEvents[0]).toEqual( expect.objectContaining({ topic: 'external.action.email' }), diff --git a/src/forums/forums-read-query.service.ts b/src/forums/forums-read-query.service.ts index 0b4bdce..36571a1 100644 --- a/src/forums/forums-read-query.service.ts +++ b/src/forums/forums-read-query.service.ts @@ -23,6 +23,11 @@ export interface ForumsTopicSummaryRow { createdAt: Date; updatedAt: Date; postsCount: number; + viewsCount: number; + watching: boolean; + starterPostExcerpt: string | null; + participantsCount: number; + participants: ForumsTopicParticipantRow[]; latestPostId: string | null; latestPostAuthorMemberId: string | null; latestPostAuthorHandle: string | null; @@ -30,6 +35,14 @@ export interface ForumsTopicSummaryRow { unread: boolean; } +/** + * Participant snapshot returned by topic-summary SQL aggregation. + */ +export interface ForumsTopicParticipantRow { + memberId: string; + handle: string; +} + /** * Database row returned by forum topic detail post queries. */ @@ -40,6 +53,7 @@ export interface ForumsPostTreeRow { parentId: string; authorMemberId: string; authorHandle: string; + authorPostsCount: number; content: string | null; createdAt: Date; updatedAt: Date; @@ -222,6 +236,11 @@ export class ForumsReadQueryService { p."parentId", p."authorMemberId", p."authorHandle", + CAST( + COUNT(*) FILTER (WHERE p."deletedAt" IS NULL) + OVER (PARTITION BY p."authorMemberId") + AS integer + ) AS "authorPostsCount", p.content, p."createdAt", p."updatedAt", @@ -297,7 +316,7 @@ export class ForumsReadQueryService { * @param whereClause Parameterized SQL fragment limiting candidate topics. * @param memberId Authenticated member id used for unread derivation, or null. * @param client Optional transaction client used to bind this read to a snapshot. - * @returns Ordered candidate topic summary rows with lock and read metadata. + * @returns Ordered candidate topic summary rows with lock, read, watch, excerpt, view, and participant metadata. * @throws Prisma errors when the raw query fails. */ private findTopicSummaryRows( @@ -321,6 +340,11 @@ export class ForumsReadQueryService { t."createdAt", t."updatedAt", post_stats."postsCount", + view_stats."viewsCount", + watch_state."topicId" IS NOT NULL AS watching, + starter."starterPostExcerpt", + participant_stats."participantsCount", + participant_snapshots.participants, latest."latestPostId", latest."latestPostAuthorMemberId", latest."latestPostAuthorHandle", @@ -338,6 +362,57 @@ export class ForumsReadQueryService { WHERE count_post."topicId" = t.id AND count_post."deletedAt" IS NULL ) post_stats ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::integer AS "viewsCount" + FROM "TopicReadState" topic_view + WHERE topic_view."topicId" = t.id + ) view_stats ON true + LEFT JOIN "TopicWatch" watch_state + ON watch_state."topicId" = t.id + AND watch_state."memberId" = CAST(${memberId} AS text) + LEFT JOIN LATERAL ( + SELECT LEFT(starter_post.content, 280) AS "starterPostExcerpt" + FROM "Post" starter_post + WHERE starter_post."topicId" = t.id + AND starter_post."deletedAt" IS NULL + AND starter_post.content IS NOT NULL + ORDER BY starter_post."createdAt" ASC, starter_post.id ASC + LIMIT 1 + ) starter ON true + LEFT JOIN LATERAL ( + SELECT COUNT(DISTINCT participant_post."authorMemberId")::integer + AS "participantsCount" + FROM "Post" participant_post + WHERE participant_post."topicId" = t.id + AND participant_post."deletedAt" IS NULL + ) participant_stats ON true + LEFT JOIN LATERAL ( + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'memberId', participant."authorMemberId", + 'handle', participant."authorHandle" + ) + ORDER BY participant."firstActivityAt" + ), + '[]'::jsonb + ) AS participants + FROM ( + SELECT + participant_post."authorMemberId", + (array_agg( + participant_post."authorHandle" + ORDER BY participant_post."createdAt" DESC, participant_post.id DESC + ))[1] AS "authorHandle", + MIN(participant_post."createdAt") AS "firstActivityAt" + FROM "Post" participant_post + WHERE participant_post."topicId" = t.id + AND participant_post."deletedAt" IS NULL + GROUP BY participant_post."authorMemberId" + ORDER BY MIN(participant_post."createdAt") ASC + LIMIT 5 + ) participant + ) participant_snapshots ON true LEFT JOIN LATERAL ( SELECT latest_post.id AS "latestPostId", diff --git a/src/forums/forums-read.service.ts b/src/forums/forums-read.service.ts index 91a1a62..a269f26 100644 --- a/src/forums/forums-read.service.ts +++ b/src/forums/forums-read.service.ts @@ -43,6 +43,7 @@ interface ForumsPostTreeNodeInternal { parentId: string; authorMemberId: string; authorHandle: string; + authorPostsCount: number; content: string | null; createdAt: Date; updatedAt: Date; @@ -510,6 +511,11 @@ export class ForumsReadService { createdAt: row.createdAt, updatedAt: row.updatedAt, postsCount: Number(row.postsCount), + viewsCount: Number(row.viewsCount), + watching: Boolean(row.watching), + starterPostExcerpt: row.starterPostExcerpt, + participantsCount: Number(row.participantsCount), + participants: Array.isArray(row.participants) ? row.participants : [], latestActivity: row.latestPostId && row.latestPostAuthorMemberId && @@ -599,6 +605,7 @@ export class ForumsReadService { parentId: row.parentId, authorMemberId: row.authorMemberId, authorHandle: row.authorHandle, + authorPostsCount: Number(row.authorPostsCount), content: deleted ? null : row.content, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -681,6 +688,7 @@ export class ForumsReadService { parentId: node.parentId, authorMemberId: node.authorMemberId, authorHandle: node.authorHandle, + authorPostsCount: node.authorPostsCount, content: node.content, createdAt: node.createdAt, updatedAt: node.updatedAt, diff --git a/src/forums/import/vanilla-challenge-lookup.service.ts b/src/forums/import/vanilla-challenge-lookup.service.ts index 3318979..12d01e5 100644 --- a/src/forums/import/vanilla-challenge-lookup.service.ts +++ b/src/forums/import/vanilla-challenge-lookup.service.ts @@ -1,6 +1,9 @@ import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { PrismaClient as ChallengePrismaClient } from '../../../../challenge-api-v6/packages/challenge-prisma-client'; +import { + createChallengePrismaClient, + PrismaClient as ChallengePrismaClient, +} from '../../../../challenge-api-v6/packages/challenge-prisma-client'; /** * Importer-only challenge existence cache for challenge-scoped discussions. @@ -95,13 +98,7 @@ export class VanillaChallengeLookupService implements OnModuleDestroy { ); } - this.client = new ChallengePrismaClient({ - datasources: { - db: { - url: databaseUrl, - }, - }, - }); + this.client = createChallengePrismaClient(databaseUrl); return this.client; } From 4fa8bd0d184a1eb72486c4135b112fd04bc0e0ae Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 26 Aug 2026 21:01:31 +1000 Subject: [PATCH 2/9] fix: resolve external Prisma runtime dependencies --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 03409b5..ee15d77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,10 @@ RUN apk upgrade --no-cache \ && rm -rf /usr/local/lib/node_modules/npm \ && rm -f /usr/local/bin/npm /usr/local/bin/npx -ENV NODE_ENV=production +# External Prisma clients are copied beside the application under /usr/src. +# Expose the application's production dependencies to their CommonJS imports. +ENV NODE_ENV=production \ + NODE_PATH=/usr/src/app/node_modules WORKDIR /usr/src/app COPY --from=build --chown=node:node /usr/src/app/dist ./dist From c6c0d096c1d5cd91d1ba5d12f48ef5fe0ae7a6f6 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 27 Aug 2026 10:08:33 +1000 Subject: [PATCH 3/9] Forum post count to challenge response --- .github/workflows/trivy.yaml | 44 +++++++++++++++++++++++++ packages/forums-prisma-client/README.md | 27 +++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/trivy.yaml diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml new file mode 100644 index 0000000..9706c0a --- /dev/null +++ b/.github/workflows/trivy.yaml @@ -0,0 +1,44 @@ +name: Trivy Scanner + +permissions: + actions: read + contents: read + security-events: write + +on: + push: + branches: + - main + - master + - dev + - develop + pull_request: + workflow_dispatch: + +jobs: + trivy-scan: + name: Trivy SAST and SCA + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy scanner in repo mode + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + version: "v0.73.0" + scan-type: "fs" + scan-ref: "." + ignore-unfixed: true + format: "sarif" + output: "trivy-results.sarif" + severity: "CRITICAL,HIGH,UNKNOWN" + limit-severities-for-sarif: true + scanners: "vuln,secret,misconfig,license" + github-pat: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload Trivy scan results to GitHub Security tab + if: always() + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: "trivy-results.sarif" diff --git a/packages/forums-prisma-client/README.md b/packages/forums-prisma-client/README.md index 790df72..e31401b 100644 --- a/packages/forums-prisma-client/README.md +++ b/packages/forums-prisma-client/README.md @@ -1,5 +1,28 @@ # Forums Prisma Client -Generated output path for the exported forums Prisma client generated from `prisma/schema.prisma`. +Reusable Prisma client generated from `prisma/schema.prisma` for services that +need direct, typed access to forum data. -The Batch 1 scaffold intentionally does not define forum domain models. The generated client is ready for imports and will expose forum models after later batches add the final schema. +The package exports the Forums `Topic`, `Post`, `TopicClosure`, `TopicWatch`, +`TopicReadState`, `MemberBan`, and `IpBan` models. Consumers import this client +from the Forums API package and must supply the Prisma 7 PostgreSQL driver +adapter required by their runtime: + +```ts +import { PrismaClient } from '@topcoder/forums-api-v6/packages/forums-prisma-client'; +import { PrismaPg } from '@prisma/adapter-pg'; + +const forumsDbUrl = process.env.FORUMS_DB_URL; +if (!forumsDbUrl) { + throw new Error('FORUMS_DB_URL is required'); +} +const adapter = new PrismaPg( + { connectionString: forumsDbUrl }, + { schema: 'forums' }, +); +const forums = new PrismaClient({ adapter }); +``` + +The caller owns the client lifecycle and must call `$disconnect()` during +shutdown. Regenerate this package with `pnpm prisma:generate` whenever the +Forums Prisma schema changes. From 90bc204bb2386592ff0b00d285369d8fc2da9420 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 27 Aug 2026 13:03:35 +1000 Subject: [PATCH 4/9] Support for reactions --- README.md | 13 +- packages/forums-prisma-client/README.md | 8 +- packages/forums-prisma-client/edge.js | 22 +- .../forums-prisma-client/index-browser.js | 14 +- packages/forums-prisma-client/index.d.ts | 1748 ++++++++++++++++- packages/forums-prisma-client/index.js | 22 +- packages/forums-prisma-client/package.json | 2 +- packages/forums-prisma-client/schema.prisma | 25 +- .../migration.sql | 22 + prisma/schema.prisma | 25 +- src/forums/dto/forums-command-response.dto.ts | 25 + src/forums/dto/forums-command.dto.ts | 17 + src/forums/dto/forums-read.dto.ts | 18 + src/forums/forums-command.service.spec.ts | 99 +- src/forums/forums-command.service.ts | 165 +- ...rums-notification-read.integration.spec.ts | 178 +- src/forums/forums-read-query.service.ts | 30 +- src/forums/forums-read.service.ts | 10 + src/forums/posts.controller.ts | 111 +- src/main.ts | 2 +- 20 files changed, 2461 insertions(+), 95 deletions(-) create mode 100644 prisma/migrations/20260827120000_post_reactions/migration.sql diff --git a/README.md b/README.md index 0ac3db7..eeddda6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ and post-commit watch notifications. - `PUT /v6/forums/topics/:topicId/read-state` - `PATCH /v6/forums/posts/:postId` - `DELETE /v6/forums/posts/:postId` +- `PUT /v6/forums/posts/:postId/reaction` +- `DELETE /v6/forums/posts/:postId/reaction` - `PUT /v6/forums/moderation/topics/:topicId/lock` - `DELETE /v6/forums/moderation/topics/:topicId/lock` - `PUT /v6/forums/moderation/member-bans/:memberId` @@ -28,9 +30,11 @@ and post-commit watch notifications. - `PUT /v6/forums/moderation/ip-bans/:ipAddress` - `DELETE /v6/forums/moderation/ip-bans/:ipAddress` -This service includes the forums schema, Prisma client export, topic read workflows, transactional command-side workflows, 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. +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, and derives unread state from `TopicReadState.lastReadAt`. `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`, 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. + +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. @@ -158,7 +162,8 @@ Active `MemberBan` rows return 403 for reads, writes, human watch/read-state commands, and M2M on-behalf watch/read-state commands targeting that member. Active `IpBan` rows return 403 only for non-M2M requests with a trusted resolved client IP; exact matching is performed by PostgreSQL `inet` equality. Locked -topics remain listable and readable but reject discussion mutations. Lock bypass +topics remain listable and readable but reject discussion content mutations. +Post reactions remain available to otherwise-authorized human members. Lock bypass is limited to administrators everywhere and human challenge copilots on challenge-scoped topics. Scoped M2M callers do not bypass locks unless they also qualify as administrators. @@ -223,6 +228,6 @@ interrupted, wipe the target forums dataset and rerun the full import. ## Prisma -The forums Prisma schema defines `Topic`, `Post`, `TopicClosure`, `TopicWatch`, `TopicReadState`, `MemberBan`, and `IpBan` in the dedicated `forums` schema. Topics store an explicit lock state plus nullable lock timestamp and lock actor member id. Ban rows keep active and removed audit metadata; the migration enforces one active row per member or exact IP value and validates IP bans as single IPv4/IPv6 host values. `pnpm prisma:generate` emits the local client at `prisma/generated/client` and the reusable exported client at `packages/forums-prisma-client`. +The forums Prisma schema defines `Topic`, `Post`, `PostReaction`, `TopicClosure`, `TopicWatch`, `TopicReadState`, `MemberBan`, and `IpBan` in the dedicated `forums` schema. `PostReaction` uses a composite post/member key so each member has at most one thumbs-up or thumbs-down value per post; deleting a post cascades its reactions. Topics store an explicit lock state plus nullable lock timestamp and lock actor member id. Ban rows keep active and removed audit metadata; the migration enforces one active row per member or exact IP value and validates IP bans as single IPv4/IPv6 host values. `pnpm prisma:generate` emits the local client at `prisma/generated/client` and the reusable exported client at `packages/forums-prisma-client`. Topic creation is transactional: it creates the topic, starter post, closure rows, and, for human authors, author watch and read-state rows together. M2M topic creation uses the system author and skips member watch/read-state side effects. Topic deletion is soft deletion, and post deletion preserves the post row while setting content to null for placeholder reads. Topic summary reads use side-effect-free raw queries for topic lock state, visible-post counts, nullable latest visible activity, and unread derivation, then apply centralized forums policy filtering before pagination. diff --git a/packages/forums-prisma-client/README.md b/packages/forums-prisma-client/README.md index e31401b..e08e727 100644 --- a/packages/forums-prisma-client/README.md +++ b/packages/forums-prisma-client/README.md @@ -3,10 +3,10 @@ Reusable Prisma client generated from `prisma/schema.prisma` for services that need direct, typed access to forum data. -The package exports the Forums `Topic`, `Post`, `TopicClosure`, `TopicWatch`, -`TopicReadState`, `MemberBan`, and `IpBan` models. Consumers import this client -from the Forums API package and must supply the Prisma 7 PostgreSQL driver -adapter required by their runtime: +The package exports the Forums `Topic`, `Post`, `PostReaction`, +`PostReactionType`, `TopicClosure`, `TopicWatch`, `TopicReadState`, `MemberBan`, +and `IpBan` models. Consumers import this client from the Forums API package and +must supply the Prisma 7 PostgreSQL driver adapter required by their runtime: ```ts import { PrismaClient } from '@topcoder/forums-api-v6/packages/forums-prisma-client'; diff --git a/packages/forums-prisma-client/edge.js b/packages/forums-prisma-client/edge.js index f9a3f04..efe60b3 100644 --- a/packages/forums-prisma-client/edge.js +++ b/packages/forums-prisma-client/edge.js @@ -142,6 +142,14 @@ exports.Prisma.PostScalarFieldEnum = { deletedByMemberId: 'deletedByMemberId' }; +exports.Prisma.PostReactionScalarFieldEnum = { + postId: 'postId', + memberId: 'memberId', + reaction: 'reaction', + createdAt: 'createdAt', + updatedAt: 'updatedAt' +}; + exports.Prisma.TopicClosureScalarFieldEnum = { ancestorTopicId: 'ancestorTopicId', descendantTopicId: 'descendantTopicId', @@ -175,13 +183,17 @@ exports.Prisma.NullsOrder = { first: 'first', last: 'last' }; - +exports.PostReactionType = exports.$Enums.PostReactionType = { + THUMBS_UP: 'THUMBS_UP', + THUMBS_DOWN: 'THUMBS_DOWN' +}; exports.Prisma.ModelName = { Topic: 'Topic', MemberBan: 'MemberBan', IpBan: 'IpBan', Post: 'Post', + PostReaction: 'PostReaction', TopicClosure: 'TopicClosure', TopicWatch: 'TopicWatch', TopicReadState: 'TopicReadState' @@ -194,14 +206,14 @@ const config = { "clientVersion": "7.9.0", "engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad", "activeProvider": "postgresql", - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"./generated/client\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/forums-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n schemas = [\"forums\"]\n}\n\nmodel Topic {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n parentTopicId String? @db.VarChar(14)\n challengeId String? @db.VarChar(64)\n roleName String? @db.VarChar(128)\n title String @db.VarChar(255)\n isAnnouncement Boolean @default(false)\n locked Boolean @default(false)\n lockedAt DateTime?\n lockedByMemberId String? @db.VarChar(64)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n parentTopic Topic? @relation(\"TopicChildren\", fields: [parentTopicId], references: [id])\n childTopics Topic[] @relation(\"TopicChildren\")\n posts Post[]\n ancestorClosures TopicClosure[] @relation(\"TopicClosureAncestor\")\n descendantClosures TopicClosure[] @relation(\"TopicClosureDescendant\")\n watches TopicWatch[]\n readStates TopicReadState[]\n\n @@index([parentTopicId])\n @@index([challengeId])\n @@index([roleName])\n @@index([deletedAt])\n @@index([isAnnouncement, createdAt])\n @@schema(\"forums\")\n}\n\nmodel MemberBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([memberId])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel IpBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n ipAddress String @db.VarChar(45)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([ipAddress])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel Post {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n topicId String @db.VarChar(14)\n parentType String @db.VarChar(16)\n parentId String @db.VarChar(14)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n content String? @db.Text\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@index([topicId, createdAt])\n @@index([topicId, parentType, parentId])\n @@index([deletedAt])\n @@schema(\"forums\")\n}\n\nmodel TopicClosure {\n ancestorTopicId String @db.VarChar(14)\n descendantTopicId String @db.VarChar(14)\n depth Int\n\n ancestorTopic Topic @relation(\"TopicClosureAncestor\", fields: [ancestorTopicId], references: [id], onDelete: Cascade)\n descendantTopic Topic @relation(\"TopicClosureDescendant\", fields: [descendantTopicId], references: [id], onDelete: Cascade)\n\n @@id([ancestorTopicId, descendantTopicId])\n @@index([ancestorTopicId, depth])\n @@index([descendantTopicId, depth])\n @@schema(\"forums\")\n}\n\nmodel TopicWatch {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@schema(\"forums\")\n}\n\nmodel TopicReadState {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n lastReadAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@index([lastReadAt])\n @@schema(\"forums\")\n}\n" + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"./generated/client\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/forums-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n schemas = [\"forums\"]\n}\n\nenum PostReactionType {\n THUMBS_UP\n THUMBS_DOWN\n\n @@schema(\"forums\")\n}\n\nmodel Topic {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n parentTopicId String? @db.VarChar(14)\n challengeId String? @db.VarChar(64)\n roleName String? @db.VarChar(128)\n title String @db.VarChar(255)\n isAnnouncement Boolean @default(false)\n locked Boolean @default(false)\n lockedAt DateTime?\n lockedByMemberId String? @db.VarChar(64)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n parentTopic Topic? @relation(\"TopicChildren\", fields: [parentTopicId], references: [id])\n childTopics Topic[] @relation(\"TopicChildren\")\n posts Post[]\n ancestorClosures TopicClosure[] @relation(\"TopicClosureAncestor\")\n descendantClosures TopicClosure[] @relation(\"TopicClosureDescendant\")\n watches TopicWatch[]\n readStates TopicReadState[]\n\n @@index([parentTopicId])\n @@index([challengeId])\n @@index([roleName])\n @@index([deletedAt])\n @@index([isAnnouncement, createdAt])\n @@schema(\"forums\")\n}\n\nmodel MemberBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([memberId])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel IpBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n ipAddress String @db.VarChar(45)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([ipAddress])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel Post {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n topicId String @db.VarChar(14)\n parentType String @db.VarChar(16)\n parentId String @db.VarChar(14)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n content String? @db.Text\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n reactions PostReaction[]\n\n @@index([topicId, createdAt])\n @@index([topicId, parentType, parentId])\n @@index([deletedAt])\n @@schema(\"forums\")\n}\n\nmodel PostReaction {\n postId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n reaction PostReactionType\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n\n post Post @relation(fields: [postId], references: [id], onDelete: Cascade)\n\n @@id([postId, memberId])\n @@index([memberId])\n @@index([postId, reaction])\n @@schema(\"forums\")\n}\n\nmodel TopicClosure {\n ancestorTopicId String @db.VarChar(14)\n descendantTopicId String @db.VarChar(14)\n depth Int\n\n ancestorTopic Topic @relation(\"TopicClosureAncestor\", fields: [ancestorTopicId], references: [id], onDelete: Cascade)\n descendantTopic Topic @relation(\"TopicClosureDescendant\", fields: [descendantTopicId], references: [id], onDelete: Cascade)\n\n @@id([ancestorTopicId, descendantTopicId])\n @@index([ancestorTopicId, depth])\n @@index([descendantTopicId, depth])\n @@schema(\"forums\")\n}\n\nmodel TopicWatch {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@schema(\"forums\")\n}\n\nmodel TopicReadState {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n lastReadAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@index([lastReadAt])\n @@schema(\"forums\")\n}\n" } -config.runtimeDataModel = JSON.parse("{\"models\":{\"Topic\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isAnnouncement\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"locked\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lockedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"lockedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"childTopics\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"posts\",\"kind\":\"object\",\"type\":\"Post\",\"relationName\":\"PostToTopic\"},{\"name\":\"ancestorClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureDescendant\"},{\"name\":\"watches\",\"kind\":\"object\",\"type\":\"TopicWatch\",\"relationName\":\"TopicToTopicWatch\"},{\"name\":\"readStates\",\"kind\":\"object\",\"type\":\"TopicReadState\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null},\"MemberBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"IpBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Post\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"PostToTopic\"}],\"dbName\":null},\"TopicClosure\":{\"fields\":[{\"name\":\"ancestorTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"descendantTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"depth\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ancestorTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureDescendant\"}],\"dbName\":null},\"TopicWatch\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicWatch\"}],\"dbName\":null},\"TopicReadState\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastReadAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Topic\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isAnnouncement\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"locked\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lockedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"lockedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"childTopics\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"posts\",\"kind\":\"object\",\"type\":\"Post\",\"relationName\":\"PostToTopic\"},{\"name\":\"ancestorClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureDescendant\"},{\"name\":\"watches\",\"kind\":\"object\",\"type\":\"TopicWatch\",\"relationName\":\"TopicToTopicWatch\"},{\"name\":\"readStates\",\"kind\":\"object\",\"type\":\"TopicReadState\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null},\"MemberBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"IpBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Post\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"PostToTopic\"},{\"name\":\"reactions\",\"kind\":\"object\",\"type\":\"PostReaction\",\"relationName\":\"PostToPostReaction\"}],\"dbName\":null},\"PostReaction\":{\"fields\":[{\"name\":\"postId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"reaction\",\"kind\":\"enum\",\"type\":\"PostReactionType\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"post\",\"kind\":\"object\",\"type\":\"Post\",\"relationName\":\"PostToPostReaction\"}],\"dbName\":null},\"TopicClosure\":{\"fields\":[{\"name\":\"ancestorTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"descendantTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"depth\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ancestorTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureDescendant\"}],\"dbName\":null},\"TopicWatch\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicWatch\"}],\"dbName\":null},\"TopicReadState\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastReadAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") defineDmmfProperty(exports.Prisma, config.runtimeDataModel) config.parameterizationSchema = { - strings: JSON.parse("[\"where\",\"parentTopic\",\"orderBy\",\"cursor\",\"childTopics\",\"topic\",\"posts\",\"ancestorTopic\",\"descendantTopic\",\"ancestorClosures\",\"descendantClosures\",\"watches\",\"readStates\",\"_count\",\"Topic.findUnique\",\"Topic.findUniqueOrThrow\",\"Topic.findFirst\",\"Topic.findFirstOrThrow\",\"Topic.findMany\",\"data\",\"Topic.createOne\",\"Topic.createMany\",\"Topic.createManyAndReturn\",\"Topic.updateOne\",\"Topic.updateMany\",\"Topic.updateManyAndReturn\",\"create\",\"update\",\"Topic.upsertOne\",\"Topic.deleteOne\",\"Topic.deleteMany\",\"having\",\"_min\",\"_max\",\"Topic.groupBy\",\"Topic.aggregate\",\"MemberBan.findUnique\",\"MemberBan.findUniqueOrThrow\",\"MemberBan.findFirst\",\"MemberBan.findFirstOrThrow\",\"MemberBan.findMany\",\"MemberBan.createOne\",\"MemberBan.createMany\",\"MemberBan.createManyAndReturn\",\"MemberBan.updateOne\",\"MemberBan.updateMany\",\"MemberBan.updateManyAndReturn\",\"MemberBan.upsertOne\",\"MemberBan.deleteOne\",\"MemberBan.deleteMany\",\"MemberBan.groupBy\",\"MemberBan.aggregate\",\"IpBan.findUnique\",\"IpBan.findUniqueOrThrow\",\"IpBan.findFirst\",\"IpBan.findFirstOrThrow\",\"IpBan.findMany\",\"IpBan.createOne\",\"IpBan.createMany\",\"IpBan.createManyAndReturn\",\"IpBan.updateOne\",\"IpBan.updateMany\",\"IpBan.updateManyAndReturn\",\"IpBan.upsertOne\",\"IpBan.deleteOne\",\"IpBan.deleteMany\",\"IpBan.groupBy\",\"IpBan.aggregate\",\"Post.findUnique\",\"Post.findUniqueOrThrow\",\"Post.findFirst\",\"Post.findFirstOrThrow\",\"Post.findMany\",\"Post.createOne\",\"Post.createMany\",\"Post.createManyAndReturn\",\"Post.updateOne\",\"Post.updateMany\",\"Post.updateManyAndReturn\",\"Post.upsertOne\",\"Post.deleteOne\",\"Post.deleteMany\",\"Post.groupBy\",\"Post.aggregate\",\"TopicClosure.findUnique\",\"TopicClosure.findUniqueOrThrow\",\"TopicClosure.findFirst\",\"TopicClosure.findFirstOrThrow\",\"TopicClosure.findMany\",\"TopicClosure.createOne\",\"TopicClosure.createMany\",\"TopicClosure.createManyAndReturn\",\"TopicClosure.updateOne\",\"TopicClosure.updateMany\",\"TopicClosure.updateManyAndReturn\",\"TopicClosure.upsertOne\",\"TopicClosure.deleteOne\",\"TopicClosure.deleteMany\",\"_avg\",\"_sum\",\"TopicClosure.groupBy\",\"TopicClosure.aggregate\",\"TopicWatch.findUnique\",\"TopicWatch.findUniqueOrThrow\",\"TopicWatch.findFirst\",\"TopicWatch.findFirstOrThrow\",\"TopicWatch.findMany\",\"TopicWatch.createOne\",\"TopicWatch.createMany\",\"TopicWatch.createManyAndReturn\",\"TopicWatch.updateOne\",\"TopicWatch.updateMany\",\"TopicWatch.updateManyAndReturn\",\"TopicWatch.upsertOne\",\"TopicWatch.deleteOne\",\"TopicWatch.deleteMany\",\"TopicWatch.groupBy\",\"TopicWatch.aggregate\",\"TopicReadState.findUnique\",\"TopicReadState.findUniqueOrThrow\",\"TopicReadState.findFirst\",\"TopicReadState.findFirstOrThrow\",\"TopicReadState.findMany\",\"TopicReadState.createOne\",\"TopicReadState.createMany\",\"TopicReadState.createManyAndReturn\",\"TopicReadState.updateOne\",\"TopicReadState.updateMany\",\"TopicReadState.updateManyAndReturn\",\"TopicReadState.upsertOne\",\"TopicReadState.deleteOne\",\"TopicReadState.deleteMany\",\"TopicReadState.groupBy\",\"TopicReadState.aggregate\",\"AND\",\"OR\",\"NOT\",\"topicId\",\"memberId\",\"lastReadAt\",\"updatedAt\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"not\",\"contains\",\"startsWith\",\"endsWith\",\"createdAt\",\"ancestorTopicId\",\"descendantTopicId\",\"depth\",\"id\",\"parentType\",\"parentId\",\"authorMemberId\",\"authorHandle\",\"content\",\"deletedAt\",\"deletedByMemberId\",\"ipAddress\",\"createdByMemberId\",\"removedAt\",\"removedByMemberId\",\"parentTopicId\",\"challengeId\",\"roleName\",\"title\",\"isAnnouncement\",\"locked\",\"lockedAt\",\"lockedByMemberId\",\"topicId_memberId\",\"ancestorTopicId_descendantTopicId\",\"every\",\"some\",\"none\",\"is\",\"isNot\",\"connectOrCreate\",\"upsert\",\"createMany\",\"set\",\"disconnect\",\"delete\",\"connect\",\"updateMany\",\"deleteMany\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"), - graph: "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_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_AEAIIkBAQAAAAGKAQEAAAABmAFAAAAAAQETAACYAQAgA4kBAQAAAAGKAQEAAAABmAFAAAAAAQETAACaAQAwARMAAJoBADAEBQAA-wEAIIkBAQD0AQAhigEBAPQBACGYAUAA9QEAIQIAAAASACATAACdAQAgA4kBAQD0AQAhigEBAPQBACGYAUAA9QEAIQIAAAAQACATAACfAQAgAgAAABAAIBMAAJ8BACADAAAAEgAgGgAAmAEAIBsAAJ0BACABAAAAEgAgAQAAABAAIAMNAAD4AQAgIAAA-gEAICEAAPkBACAGhgEAAMcBADCHAQAApgEAEIgBAADHAQAwiQEBAMABACGKAQEAwAEAIZgBQADBAQAhAwAAABAAIAIAAKUBADAfAACmAQAgAwAAABAAIAIAABEAMAMAABIAIAEAAAAWACABAAAAFgAgAwAAABQAIAIAABUAMAMAABYAIAMAAAAUACACAAAVADADAAAWACADAAAAFAAgAgAAFQAwAwAAFgAgBQUAAPcBACCJAQEAAAABigEBAAAAAYsBQAAAAAGMAUAAAAABARMAAK4BACAEiQEBAAAAAYoBAQAAAAGLAUAAAAABjAFAAAAAAQETAACwAQAwARMAALABADAFBQAA9gEAIIkBAQD0AQAhigEBAPQBACGLAUAA9QEAIYwBQAD1AQAhAgAAABYAIBMAALMBACAEiQEBAPQBACGKAQEA9AEAIYsBQAD1AQAhjAFAAPUBACECAAAAFAAgEwAAtQEAIAIAAAAUACATAAC1AQAgAwAAABYAIBoAAK4BACAbAACzAQAgAQAAABYAIAEAAAAUACADDQAA8QEAICAAAPMBACAhAADyAQAgB4YBAAC_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_wIAIL0BAAABACADGgAA_QIAILcBAAD-AgAgvQEAAAEAIAAAAAAAAAAAAAG6ASAAAAABBxoAAPICACAbAAD7AgAgtwEAAPMCACC4AQAA-gIAILsBAAADACC8AQAAAwAgvQEAAAEAIAsaAADZAgAwGwAA3gIAMLcBAADaAgAwuAEAANsCADC5AQAA3AIAILoBAADdAgAwuwEAAN0CADC8AQAA3QIAML0BAADdAgAwvgEAAN8CADC_AQAA4AIAMAsaAADNAgAwGwAA0gIAMLcBAADOAgAwuAEAAM8CADC5AQAA0AIAILoBAADRAgAwuwEAANECADC8AQAA0QIAML0BAADRAgAwvgEAANMCADC_AQAA1AIAMAsaAADEAgAwGwAAyAIAMLcBAADFAgAwuAEAAMYCADC5AQAAxwIAILoBAAC8AgAwuwEAALwCADC8AQAAvAIAML0BAAC8AgAwvgEAAMkCADC_AQAAvwIAMAsaAAC4AgAwGwAAvQIAMLcBAAC5AgAwuAEAALoCADC5AQAAuwIAILoBAAC8AgAwuwEAALwCADC8AQAAvAIAML0BAAC8AgAwvgEAAL4CADC_AQAAvwIAMAsaAACsAgAwGwAAsQIAMLcBAACtAgAwuAEAAK4CADC5AQAArwIAILoBAACwAgAwuwEAALACADC8AQAAsAIAML0BAACwAgAwvgEAALICADC_AQAAswIAMAsaAACgAgAwGwAApQIAMLcBAAChAgAwuAEAAKICADC5AQAAowIAILoBAACkAgAwuwEAAKQCADC8AQAApAIAML0BAACkAgAwvgEAAKYCADC_AQAApwIAMAOKAQEAAAABiwFAAAAAAYwBQAAAAAECAAAAFgAgGgAAqwIAIAMAAAAWACAaAACrAgAgGwAAqgIAIAETAAD5AgAwCQUAAOIBACCGAQAA4QEAMIcBAAAUABCIAQAA4QEAMIkBAQDWAQAhigEBANYBACGLAUAA1wEAIYwBQADXAQAhsAEAAOABACACAAAAFgAgEwAAqgIAIAIAAACoAgAgEwAAqQIAIAeGAQAApwIAMIcBAACoAgAQiAEAAKcCADCJAQEA1gEAIYoBAQDWAQAhiwFAANcBACGMAUAA1wEAIQeGAQAApwIAMIcBAACoAgAQiAEAAKcCADCJAQEA1gEAIYoBAQDWAQAhiwFAANcBACGMAUAA1wEAIQOKAQEA9AEAIYsBQAD1AQAhjAFAAPUBACEDigEBAPQBACGLAUAA9QEAIYwBQAD1AQAhA4oBAQAAAAGLAUAAAAABjAFAAAAAAQKKAQEAAAABmAFAAAAAAQIAAAASACAaAAC3AgAgAwAAABIAIBoAALcCACAbAAC2AgAgARMAAPgCADAIBQAA4gEAIIYBAADkAQAwhwEAABAAEIgBAADkAQAwiQEBANYBACGKAQEA1gEAIZgBQADXAQAhsAEAAOMBACACAAAAEgAgEwAAtgIAIAIAAAC0AgAgEwAAtQIAIAaGAQAAswIAMIcBAAC0AgAQiAEAALMCADCJAQEA1gEAIYoBAQDWAQAhmAFAANcBACEGhgEAALMCADCHAQAAtAIAEIgBAACzAgAwiQEBANYBACGKAQEA1gEAIZgBQADXAQAhAooBAQD0AQAhmAFAAPUBACECigEBAPQBACGYAUAA9QEAIQKKAQEAAAABmAFAAAAAAQMHAACFAgAgmQEBAAAAAZsBAgAAAAECAAAADQAgGgAAwwIAIAMAAAANACAaAADDAgAgGwAAwgIAIAETAAD3AgAwCQcAAOIBACAIAADiAQAghgEAAOYBADCHAQAACwAQiAEAAOYBADCZAQEA1gEAIZoBAQDWAQAhmwECAOcBACGxAQAA5QEAIAIAAAANACATAADCAgAgAgAAAMACACATAADBAgAgBoYBAAC_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_AIAIIwBQAD1AQAhmAFAAPUBACGcAQEA9AEAIZ8BAQD0AQAhoAEBAPQBACGiAUAAjAIAIaMBAQCLAgAhqAEBAIsCACGpAQEAiwIAIaoBAQCLAgAhqwEBAPQBACGsASAAmAIAIa0BIACYAgAhrgFAAIwCACGvAQEAiwIAIRUBAACZAgAgBgAAmwIAIAkAAJwCACAKAACdAgAgCwAAngIAIAwAAJ8CACCMAUAA9QEAIZgBQAD1AQAhnAEBAPQBACGfAQEA9AEAIaABAQD0AQAhogFAAIwCACGjAQEAiwIAIagBAQCLAgAhqQEBAIsCACGqAQEAiwIAIasBAQD0AQAhrAEgAJgCACGtASAAmAIAIa4BQACMAgAhrwEBAIsCACEVAQAA6wIAIAQAAOUCACAJAADnAgAgCgAA6AIAIAsAAOkCACAMAADqAgAgjAFAAAAAAZgBQAAAAAGcAQEAAAABnwEBAAAAAaABAQAAAAGiAUAAAAABowEBAAAAAagBAQAAAAGpAQEAAAABqgEBAAAAAasBAQAAAAGsASAAAAABrQEgAAAAAa4BQAAAAAGvAQEAAAABAgAAAAEAIBoAAP0CACADAAAAAwAgGgAA_QIAIBsAAIEDACAXAAAAAwAgAQAAmQIAIAQAAJoCACAJAACcAgAgCgAAnQIAIAsAAJ4CACAMAACfAgAgEwAAgQMAIIwBQAD1AQAhmAFAAPUBACGcAQEA9AEAIZ8BAQD0AQAhoAEBAPQBACGiAUAAjAIAIaMBAQCLAgAhqAEBAIsCACGpAQEAiwIAIaoBAQCLAgAhqwEBAPQBACGsASAAmAIAIa0BIACYAgAhrgFAAIwCACGvAQEAiwIAIRUBAACZAgAgBAAAmgIAIAkAAJwCACAKAACdAgAgCwAAngIAIAwAAJ8CACCMAUAA9QEAIZgBQAD1AQAhnAEBAPQBACGfAQEA9AEAIaABAQD0AQAhogFAAIwCACGjAQEAiwIAIagBAQCLAgAhqQEBAIsCACGqAQEAiwIAIasBAQD0AQAhrAEgAJgCACGtASAAmAIAIa4BQACMAgAhrwEBAIsCACEVAQAA6wIAIAQAAOUCACAGAADmAgAgCQAA5wIAIAsAAOkCACAMAADqAgAgjAFAAAAAAZgBQAAAAAGcAQEAAAABnwEBAAAAAaABAQAAAAGiAUAAAAABowEBAAAAAagBAQAAAAGpAQEAAAABqgEBAAAAAasBAQAAAAGsASAAAAABrQEgAAAAAa4BQAAAAAGvAQEAAAABAgAAAAEAIBoAAIIDACAVAQAA6wIAIAQAAOUCACAGAADmAgAgCgAA6AIAIAsAAOkCACAMAADqAgAgjAFAAAAAAZgBQAAAAAGcAQEAAAABnwEBAAAAAaABAQAAAAGiAUAAAAABowEBAAAAAagBAQAAAAGpAQEAAAABqgEBAAAAAasBAQAAAAGsASAAAAABrQEgAAAAAa4BQAAAAAGvAQEAAAABAgAAAAEAIBoAAIQDACADAAAAAwAgGgAAggMAIBsAAIgDACAXAAAAAwAgAQAAmQIAIAQAAJoCACAGAACbAgAgCQAAnAIAIAsAAJ4CACAMAACfAgAgEwAAiAMAIIwBQAD1AQAhmAFAAPUBACGcAQEA9AEAIZ8BAQD0AQAhoAEBAPQBACGiAUAAjAIAIaMBAQCLAgAhqAEBAIsCACGpAQEAiwIAIaoBAQCLAgAhqwEBAPQBACGsASAAmAIAIa0BIACYAgAhrgFAAIwCACGvAQEAiwIAIRUBAACZAgAgBAAAmgIAIAYAAJsCACAJAACcAgAgCwAAngIAIAwAAJ8CACCMAUAA9QEAIZgBQAD1AQAhnAEBAPQBACGfAQEA9AEAIaABAQD0AQAhogFAAIwCACGjAQEAiwIAIagBAQCLAgAhqQEBAIsCACGqAQEAiwIAIasBAQD0AQAhrAEgAJgCACGtASAAmAIAIa4BQACMAgAhrwEBAIsCACEDAAAAAwAgGgAAhAMAIBsAAIsDACAXAAAAAwAgAQAAmQIAIAQAAJoCACAGAACbAgAgCgAAnQIAIAsAAJ4CACAMAACfAgAgEwAAiwMAIIwBQAD1AQAhmAFAAPUBACGcAQEA9AEAIZ8BAQD0AQAhoAEBAPQBACGiAUAAjAIAIaMBAQCLAgAhqAEBAIsCACGpAQEAiwIAIaoBAQCLAgAhqwEBAPQBACGsASAAmAIAIa0BIACYAgAhrgFAAIwCACGvAQEAiwIAIRUBAACZAgAgBAAAmgIAIAYAAJsCACAKAACdAgAgCwAAngIAIAwAAJ8CACCMAUAA9QEAIZgBQAD1AQAhnAEBAPQBACGfAQEA9AEAIaABAQD0AQAhogFAAIwCACGjAQEAiwIAIagBAQCLAgAhqQEBAIsCACGqAQEAiwIAIasBAQD0AQAhrAEgAJgCACGtASAAmAIAIa4BQACMAgAhrwEBAIsCACEVAQAA6wIAIAQAAOUCACAGAADmAgAgCQAA5wIAIAoAAOgCACAMAADqAgAgjAFAAAAAAZgBQAAAAAGcAQEAAAABnwEBAAAAAaABAQAAAAGiAUAAAAABowEBAAAAAagBAQAAAAGpAQEAAAABqgEBAAAAAasBAQAAAAGsASAAAAABrQEgAAAAAa4BQAAAAAGvAQEAAAABAgAAAAEAIBoAAIwDACADAAAAAwAgGgAAjAMAIBsAAJADACAXAAAAAwAgAQAAmQIAIAQAAJoCACAGAACbAgAgCQAAnAIAIAoAAJ0CACAMAACfAgAgEwAAkAMAIIwBQAD1AQAhmAFAAPUBACGcAQEA9AEAIZ8BAQD0AQAhoAEBAPQBACGiAUAAjAIAIaMBAQCLAgAhqAEBAIsCACGpAQEAiwIAIaoBAQCLAgAhqwEBAPQBACGsASAAmAIAIa0BIACYAgAhrgFAAIwCACGvAQEAiwIAIRUBAACZAgAgBAAAmgIAIAYAAJsCACAJAACcAgAgCgAAnQIAIAwAAJ8CACCMAUAA9QEAIZgBQAD1AQAhnAEBAPQBACGfAQEA9AEAIaABAQD0AQAhogFAAIwCACGjAQEAiwIAIagBAQCLAgAhqQEBAIsCACGqAQEAiwIAIasBAQD0AQAhrAEgAJgCACGtASAAmAIAIa4BQACMAgAhrwEBAIsCACEVAQAA6wIAIAQAAOUCACAGAADmAgAgCQAA5wIAIAoAAOgCACALAADpAgAgjAFAAAAAAZgBQAAAAAGcAQEAAAABnwEBAAAAAaABAQAAAAGiAUAAAAABowEBAAAAAagBAQAAAAGpAQEAAAABqgEBAAAAAasBAQAAAAGsASAAAAABrQEgAAAAAa4BQAAAAAGvAQEAAAABAgAAAAEAIBoAAJEDACADAAAAAwAgGgAAkQMAIBsAAJUDACAXAAAAAwAgAQAAmQIAIAQAAJoCACAGAACbAgAgCQAAnAIAIAoAAJ0CACALAACeAgAgEwAAlQMAIIwBQAD1AQAhmAFAAPUBACGcAQEA9AEAIZ8BAQD0AQAhoAEBAPQBACGiAUAAjAIAIaMBAQCLAgAhqAEBAIsCACGpAQEAiwIAIaoBAQCLAgAhqwEBAPQBACGsASAAmAIAIa0BIACYAgAhrgFAAIwCACGvAQEAiwIAIRUBAACZAgAgBAAAmgIAIAYAAJsCACAJAACcAgAgCgAAnQIAIAsAAJ4CACCMAUAA9QEAIZgBQAD1AQAhnAEBAPQBACGfAQEA9AEAIaABAQD0AQAhogFAAIwCACGjAQEAiwIAIagBAQCLAgAhqQEBAIsCACGqAQEAiwIAIasBAQD0AQAhrAEgAJgCACGtASAAmAIAIa4BQACMAgAhrwEBAIsCACEIAQQBBAYBBgoCCQ4DCg8DCxMEDBcFDQAGAQUAAQIHAAEIAAEBBQABAQUAAQYEGAAGGQAJGgAKGwALHAAMHQAAAQEnAQEBLQEDDQALIAAMIQANAAAAAw0ACyAADCEADQAAAAMNABMgABQhABUAAAADDQATIAAUIQAVAAAAAw0AGyAAHCEAHQAAAAMNABsgABwhAB0BBQABAQUAAQMNACIgACMhACQAAAADDQAiIAAjIQAkAgcAAQgAAQIHAAEIAAEFDQApIAAsIQAtYgAqYwArAAAAAAAFDQApIAAsIQAtYgAqYwArAQUAAQEFAAEDDQAyIAAzIQA0AAAAAw0AMiAAMyEANAEFAAEBBQABAw0AOSAAOiEAOwAAAAMNADkgADohADsOAgEPHgEQHwERIAESIQEUIwEVJQcWJggXKQEYKwcZLAkcLgEdLwEeMAciMwojNA4kNg8lNw8mOg8nOw8oPA8pPg8qQAcrQRAsQw8tRQcuRhEvRw8wSA8xSQcyTBIzTRY0Txc1UBc2Uxc3VBc4VRc5Vxc6WQc7Whg8XBc9Xgc-Xxk_YBdAYRdBYgdCZRpDZh5EZwJFaAJGaQJHagJIawJJbQJKbwdLcB9McgJNdAdOdSBPdgJQdwJReAdSeyFTfCVUfQNVfgNWfwNXgAEDWIEBA1mDAQNahQEHW4YBJlyIAQNdigEHXosBJ1-MAQNgjQEDYY4BB2SRAShlkgEuZpMBBGeUAQRolQEEaZYBBGqXAQRrmQEEbJsBB22cAS9ungEEb6ABB3ChATBxogEEcqMBBHOkAQd0pwExdagBNXapAQV3qgEFeKsBBXmsAQV6rQEFe68BBXyxAQd9sgE2frQBBX-2AQeAAbcBN4EBuAEFggG5AQWDAboBB4QBvQE4hQG-ATw" + strings: JSON.parse("[\"where\",\"parentTopic\",\"orderBy\",\"cursor\",\"childTopics\",\"topic\",\"post\",\"reactions\",\"_count\",\"posts\",\"ancestorTopic\",\"descendantTopic\",\"ancestorClosures\",\"descendantClosures\",\"watches\",\"readStates\",\"Topic.findUnique\",\"Topic.findUniqueOrThrow\",\"Topic.findFirst\",\"Topic.findFirstOrThrow\",\"Topic.findMany\",\"data\",\"Topic.createOne\",\"Topic.createMany\",\"Topic.createManyAndReturn\",\"Topic.updateOne\",\"Topic.updateMany\",\"Topic.updateManyAndReturn\",\"create\",\"update\",\"Topic.upsertOne\",\"Topic.deleteOne\",\"Topic.deleteMany\",\"having\",\"_min\",\"_max\",\"Topic.groupBy\",\"Topic.aggregate\",\"MemberBan.findUnique\",\"MemberBan.findUniqueOrThrow\",\"MemberBan.findFirst\",\"MemberBan.findFirstOrThrow\",\"MemberBan.findMany\",\"MemberBan.createOne\",\"MemberBan.createMany\",\"MemberBan.createManyAndReturn\",\"MemberBan.updateOne\",\"MemberBan.updateMany\",\"MemberBan.updateManyAndReturn\",\"MemberBan.upsertOne\",\"MemberBan.deleteOne\",\"MemberBan.deleteMany\",\"MemberBan.groupBy\",\"MemberBan.aggregate\",\"IpBan.findUnique\",\"IpBan.findUniqueOrThrow\",\"IpBan.findFirst\",\"IpBan.findFirstOrThrow\",\"IpBan.findMany\",\"IpBan.createOne\",\"IpBan.createMany\",\"IpBan.createManyAndReturn\",\"IpBan.updateOne\",\"IpBan.updateMany\",\"IpBan.updateManyAndReturn\",\"IpBan.upsertOne\",\"IpBan.deleteOne\",\"IpBan.deleteMany\",\"IpBan.groupBy\",\"IpBan.aggregate\",\"Post.findUnique\",\"Post.findUniqueOrThrow\",\"Post.findFirst\",\"Post.findFirstOrThrow\",\"Post.findMany\",\"Post.createOne\",\"Post.createMany\",\"Post.createManyAndReturn\",\"Post.updateOne\",\"Post.updateMany\",\"Post.updateManyAndReturn\",\"Post.upsertOne\",\"Post.deleteOne\",\"Post.deleteMany\",\"Post.groupBy\",\"Post.aggregate\",\"PostReaction.findUnique\",\"PostReaction.findUniqueOrThrow\",\"PostReaction.findFirst\",\"PostReaction.findFirstOrThrow\",\"PostReaction.findMany\",\"PostReaction.createOne\",\"PostReaction.createMany\",\"PostReaction.createManyAndReturn\",\"PostReaction.updateOne\",\"PostReaction.updateMany\",\"PostReaction.updateManyAndReturn\",\"PostReaction.upsertOne\",\"PostReaction.deleteOne\",\"PostReaction.deleteMany\",\"PostReaction.groupBy\",\"PostReaction.aggregate\",\"TopicClosure.findUnique\",\"TopicClosure.findUniqueOrThrow\",\"TopicClosure.findFirst\",\"TopicClosure.findFirstOrThrow\",\"TopicClosure.findMany\",\"TopicClosure.createOne\",\"TopicClosure.createMany\",\"TopicClosure.createManyAndReturn\",\"TopicClosure.updateOne\",\"TopicClosure.updateMany\",\"TopicClosure.updateManyAndReturn\",\"TopicClosure.upsertOne\",\"TopicClosure.deleteOne\",\"TopicClosure.deleteMany\",\"_avg\",\"_sum\",\"TopicClosure.groupBy\",\"TopicClosure.aggregate\",\"TopicWatch.findUnique\",\"TopicWatch.findUniqueOrThrow\",\"TopicWatch.findFirst\",\"TopicWatch.findFirstOrThrow\",\"TopicWatch.findMany\",\"TopicWatch.createOne\",\"TopicWatch.createMany\",\"TopicWatch.createManyAndReturn\",\"TopicWatch.updateOne\",\"TopicWatch.updateMany\",\"TopicWatch.updateManyAndReturn\",\"TopicWatch.upsertOne\",\"TopicWatch.deleteOne\",\"TopicWatch.deleteMany\",\"TopicWatch.groupBy\",\"TopicWatch.aggregate\",\"TopicReadState.findUnique\",\"TopicReadState.findUniqueOrThrow\",\"TopicReadState.findFirst\",\"TopicReadState.findFirstOrThrow\",\"TopicReadState.findMany\",\"TopicReadState.createOne\",\"TopicReadState.createMany\",\"TopicReadState.createManyAndReturn\",\"TopicReadState.updateOne\",\"TopicReadState.updateMany\",\"TopicReadState.updateManyAndReturn\",\"TopicReadState.upsertOne\",\"TopicReadState.deleteOne\",\"TopicReadState.deleteMany\",\"TopicReadState.groupBy\",\"TopicReadState.aggregate\",\"AND\",\"OR\",\"NOT\",\"topicId\",\"memberId\",\"lastReadAt\",\"updatedAt\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"not\",\"contains\",\"startsWith\",\"endsWith\",\"createdAt\",\"ancestorTopicId\",\"descendantTopicId\",\"depth\",\"postId\",\"PostReactionType\",\"reaction\",\"id\",\"parentType\",\"parentId\",\"authorMemberId\",\"authorHandle\",\"content\",\"deletedAt\",\"deletedByMemberId\",\"ipAddress\",\"createdByMemberId\",\"removedAt\",\"removedByMemberId\",\"parentTopicId\",\"challengeId\",\"roleName\",\"title\",\"isAnnouncement\",\"locked\",\"lockedAt\",\"lockedByMemberId\",\"topicId_memberId\",\"ancestorTopicId_descendantTopicId\",\"postId_memberId\",\"every\",\"some\",\"none\",\"is\",\"isNot\",\"connectOrCreate\",\"upsert\",\"createMany\",\"set\",\"disconnect\",\"delete\",\"connect\",\"updateMany\",\"deleteMany\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"), + graph: "1QNFgAEZAQAAjwIAIAQAAJACACAJAACRAgAgDAAAkgIAIA0AAJICACAOAACTAgAgDwAAlAIAIJgBAACNAgAwmQEAAAMAEJoBAACNAgAwngFAAPYBACGqAUAA9gEAIbEBAQAAAAG0AQEA9QEAIbUBAQD1AQAhtwFAAPgBACG4AQEA9wEAIb0BAQD3AQAhvgEBAPcBACG_AQEA9wEAIcABAQD1AQAhwQEgAI4CACHCASAAjgIAIcMBQAD4AQAhxAEBAPcBACEBAAAAAQAgGQEAAI8CACAEAACQAgAgCQAAkQIAIAwAAJICACANAACSAgAgDgAAkwIAIA8AAJQCACCYAQAAjQIAMJkBAAADABCaAQAAjQIAMJ4BQAD2AQAhqgFAAPYBACGxAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG3AUAA-AEAIbgBAQD3AQAhvQEBAPcBACG-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_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_ABCaAQAA6wEAMJsBAQDbAQAhngFAANwBACGqAUAA3AEAIbEBAQDbAQAhsgEBANsBACGzAQEA2wEAIbQBAQDbAQAhtQEBANsBACG2AQEA7AEAIbcBQADtAQAhuAEBAOwBACEDAAAABwAgAgAAfgAwIQAAfwAgAwAAAAcAIAIAAAgAMAMAAAkAIAEAAAANACABAAAADQAgAwAAAAsAIAIAAAwAMAMAAA0AIAMAAAALACACAAAMADADAAANACADAAAACwAgAgAADAAwAwAADQAgBgYAALACACCcAQEAAAABngFAAAAAAaoBQAAAAAGuAQEAAAABsAEAAACwAQIBFQAAhwEAIAWcAQEAAAABngFAAAAAAaoBQAAAAAGuAQEAAAABsAEAAACwAQIBFQAAiQEAMAEVAACJAQAwBgYAAK8CACCcAQEAmAIAIZ4BQACZAgAhqgFAAJkCACGuAQEAmAIAIbABAACuArABIgIAAAANACAVAACMAQAgBZwBAQCYAgAhngFAAJkCACGqAUAAmQIAIa4BAQCYAgAhsAEAAK4CsAEiAgAAAAsAIBUAAI4BACACAAAACwAgFQAAjgEAIAMAAAANACAcAACHAQAgHQAAjAEAIAEAAAANACABAAAACwAgAwgAAKsCACAiAACtAgAgIwAArAIAIAiYAQAA5wEAMJkBAACVAQAQmgEAAOcBADCcAQEA2wEAIZ4BQADcAQAhqgFAANwBACGuAQEA2wEAIbABAADoAbABIgMAAAALACACAACUAQAwIQAAlQEAIAMAAAALACACAAAMADADAAANACABAAAAEgAgAQAAABIAIAMAAAAQACACAAARADADAAASACADAAAAEAAgAgAAEQAwAwAAEgAgAwAAABAAIAIAABEAMAMAABIAIAUKAACpAgAgCwAAqgIAIKsBAQAAAAGsAQEAAAABrQECAAAAAQEVAACdAQAgA6sBAQAAAAGsAQEAAAABrQECAAAAAQEVAACfAQAwARUAAJ8BADAFCgAApwIAIAsAAKgCACCrAQEAmAIAIawBAQCYAgAhrQECAKYCACECAAAAEgAgFQAAogEAIAOrAQEAmAIAIawBAQCYAgAhrQECAKYCACECAAAAEAAgFQAApAEAIAIAAAAQACAVAACkAQAgAwAAABIAIBwAAJ0BACAdAACiAQAgAQAAABIAIAEAAAAQ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AAABoAEBAAAABaEBAQAAAAWiAQEAAAABowEBAAAAAaQBAQAAAAGlAQEAAAABpgEBAPIBACGnAQEAAAABqAEBAAAAAakBAQAAAAEJmAEAAPMBADCZAQAAaQAQmgEAAPMBADCqAUAA3AEAIbEBAQDbAQAhuQEBANsBACG6AQEA7AEAIbsBQADtAQAhvAEBAOwBACEJmAEAAPQBADCZAQAAVgAQmgEAAPQBADCqAUAA9gEAIbEBAQD1AQAhuQEBAPUBACG6AQEA9wEAIbsBQAD4AQAhvAEBAPcBACELnwEBAAAAAaABAQAAAAShAQEAAAAEogEBAAAAAaMBAQAAAAGkAQEAAAABpQEBAAAAAaYBAQDhAQAhpwEBAAAAAagBAQAAAAGpAQEAAAABCJ8BQAAAAAGgAUAAAAAEoQFAAAAABKIBQAAAAAGjAUAAAAABpAFAAAAAAaUBQAAAAAGmAUAA3wEAIQufAQEAAAABoAEBAAAABaEBAQAAAAWiAQEAAAABowEBAAAAAaQBAQAAAAGlAQEAAAABpgEBAPIBACGnAQEAAAABqAEBAAAAAakBAQAAAAEInwFAAAAAAaABQAAAAAWhAUAAAAAFogFAAAAAAaMBQAAAAAGkAUAAAAABpQFAAAAAAaYBQADwAQAhCZgBAAD5AQAwmQEAAFAAEJoBAAD5AQAwnAEBANsBACGqAUAA3AEAIbEBAQDbAQAhugEBAOwBACG7AUAA7QEAIbwBAQDsAQAhCZgBAAD6AQAwmQEAAD0AEJoBAAD6AQAwnAEBAPUBACGqAUAA9gEAIbEBAQD1AQAhugEBAPcBACG7AUAA-AEAIbwBAQD3AQAhEpgBAAD7AQAwmQEAADcAEJoBAAD7AQAwngFAANwBACGqAUAA3AEAIbEBAQDbAQAhtAEBANsBACG1AQEA2wEAIbcBQADtAQAhuAEBAOwBACG9AQEA7AEAIb4BAQDsAQAhvwEBAOwBACHAAQEA2wEAIcEBIAD8AQAhwgEgAPwBACHDAUAA7QEAIcQBAQDsAQAhBQgAAN4BACAiAAD-AQAgIwAA_gEAIJ8BIAAAAAGmASAA_QEAIQUIAADeAQAgIgAA_gEAICMAAP4BACCfASAAAAABpgEgAP0BACECnwEgAAAAAaYBIAD-AQAhApsBAQAAAAGcAQEAAAABCAUAAIECACCYAQAAgAIAMJkBAAAZABCaAQAAgAIAMJsBAQD1AQAhnAEBAPUBACGdAUAA9gEAIZ4BQAD2AQAhGwEAAI8CACAEAACQAgAgCQAAkQIAIAwAAJICACANAACSAgAgDgAAkwIAIA8AAJQCACCYAQAAjQIAMJkBAAADABCaAQAAjQIAMJ4BQAD2AQAhqgFAAPYBACGxAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG3AUAA-AEAIbgBAQD3AQAhvQEBAPcBACG-AQEA9wEAIb8BAQD3AQAhwAEBAPUBACHBASAAjgIAIcIBIACOAgAhwwFAAPgBACHEAQEA9wEAIcsBAAADACDMAQAAAwAgApsBAQAAAAGcAQEAAAABBwUAAIECACCYAQAAgwIAMJkBAAAVABCaAQAAgwIAMJsBAQD1AQAhnAEBAPUBACGqAUAA9gEAIQKrAQEAAAABrAEBAAAAAQgKAACBAgAgCwAAgQIAIJgBAACFAgAwmQEAABAAEJoBAACFAgAwqwEBAPUBACGsAQEA9QEAIa0BAgCGAgAhCJ8BAgAAAAGgAQIAAAAEoQECAAAABKIBAgAAAAGjAQIAAAABpAECAAAAAaUBAgAAAAGmAQIA3gEAIQKcAQEAAAABrgEBAAAAAQkGAACKAgAgmAEAAIgCADCZAQAACwAQmgEAAIgCADCcAQEA9QEAIZ4BQAD2AQAhqgFAAPYBACGuAQEA9QEAIbABAACJArABIgSfAQAAALABAqABAAAAsAEIoQEAAACwAQimAQAA6gGwASISBQAAgQIAIAcAAIwCACCYAQAAiwIAMJkBAAAHABCaAQAAiwIAMJsBAQD1AQAhngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhsgEBAPUBACGzAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG2AQEA9wEAIbcBQAD4AQAhuAEBAPcBACHLAQAABwAgzAEAAAcAIBAFAACBAgAgBwAAjAIAIJgBAACLAgAwmQEAAAcAEJoBAACLAgAwmwEBAPUBACGeAUAA9gEAIaoBQAD2AQAhsQEBAPUBACGyAQEA9QEAIbMBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbYBAQD3AQAhtwFAAPgBACG4AQEA9wEAIQPIAQAACwAgyQEAAAsAIMoBAAALACAZAQAAjwIAIAQAAJACACAJAACRAgAgDAAAkgIAIA0AAJICACAOAACTAgAgDwAAlAIAIJgBAACNAgAwmQEAAAMAEJoBAACNAgAwngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbcBQAD4AQAhuAEBAPcBACG9AQEA9wEAIb4BAQD3AQAhvwEBAPcBACHAAQEA9QEAIcEBIACOAgAhwgEgAI4CACHDAUAA-AEAIcQBAQD3AQAhAp8BIAAAAAGmASAA_gEAIRsBAACPAgAgBAAAkAIAIAkAAJECACAMAACSAgAgDQAAkgIAIA4AAJMCACAPAACUAgAgmAEAAI0CADCZAQAAAwAQmgEAAI0CADCeAUAA9gEAIaoBQAD2AQAhsQEBAPUBACG0AQEA9QEAIbUBAQD1AQAhtwFAAPgBACG4AQEA9wEAIb0BAQD3AQAhvgEBAPcBACG_AQEA9wEAIcABAQD1AQAhwQEgAI4CACHCASAAjgIAIcMBQAD4AQAhxAEBAPcBACHLAQAAAwAgzAEAAAMAIAPIAQAAAwAgyQEAAAMAIMoBAAADACADyAEAAAcAIMkBAAAHACDKAQAABwAgA8gBAAAQACDJAQAAEAAgygEAABAAIAPIAQAAFQAgyQEAABUAIMoBAAAVACADyAEAABkAIMkBAAAZACDKAQAAGQAgAAAAAdABAQAAAAEB0AFAAAAAAQUcAADRAwAgHQAA1AMAIM0BAADSAwAgzgEAANMDACDTAQAAAQAgAxwAANEDACDNAQAA0gMAINMBAAABACAAAAAFHAAAzAMAIB0AAM8DACDNAQAAzQMAIM4BAADOAwAg0wEAAAEAIAMcAADMAwAgzQEAAM0DACDTAQAAAQAgAAAAAAAF0AECAAAAAdYBAgAAAAHXAQIAAAAB2AECAAAAAdkBAgAAAAEFHAAAxAMAIB0AAMoDACDNAQAAxQMAIM4BAADJAwAg0wEAAAEAIAUcAADCAwAgHQAAxwMAIM0BAADDAwAgzgEAAMYDACDTAQAAAQAgAxwAAMQDACDNAQAAxQMAINMBAAABACADHAAAwgMAIM0BAADDAwAg0wEAAAEAIAAAAAHQAQAAALABAgUcAAC9AwAgHQAAwAMAIM0BAAC-AwAgzgEAAL8DACDTAQAACQAgAxwAAL0DACDNAQAAvgMAINMBAAAJACAAAAAAAdABAQAAAAEB0AFAAAAAAQUcAAC3AwAgHQAAuwMAIM0BAAC4AwAgzgEAALoDACDTAQAAAQAgCxwAALkCADAdAAC-AgAwzQEAALoCADDOAQAAuwIAMM8BAAC8AgAg0AEAAL0CADDRAQAAvQIAMNIBAAC9AgAw0wEAAL0CADDUAQAAvwIAMNUBAADAAgAwBJwBAQAAAAGeAUAAAAABqgFAAAAAAbABAAAAsAECAgAAAA0AIBwAAMQCACADAAAADQAgHAAAxAIAIB0AAMMCACABFQAAuQMAMAoGAACKAgAgmAEAAIgCADCZAQAACwAQmgEAAIgCADCcAQEA9QEAIZ4BQAD2AQAhqgFAAPYBACGuAQEA9QEAIbABAACJArABIscBAACHAgAgAgAAAA0AIBUAAMMCACACAAAAwQIAIBUAAMICACAImAEAAMACADCZAQAAwQIAEJoBAADAAgAwnAEBAPUBACGeAUAA9gEAIaoBQAD2AQAhrgEBAPUBACGwAQAAiQKwASIImAEAAMACADCZAQAAwQIAEJoBAADAAgAwnAEBAPUBACGeAUAA9gEAIaoBQAD2AQAhrgEBAPUBACGwAQAAiQKwASIEnAEBAJgCACGeAUAAmQIAIaoBQACZAgAhsAEAAK4CsAEiBJwBAQCYAgAhngFAAJkCACGqAUAAmQIAIbABAACuArABIgScAQEAAAABngFAAAAAAaoBQAAAAAGwAQAAALABAgMcAAC3AwAgzQEAALgDACDTAQAAAQAgBBwAALkCADDNAQAAugIAMM8BAAC8AgAg0wEAAL0CADAAAAAAAAAAAAAB0AEgAAAAAQccAACsAwAgHQAAtQMAIM0BAACtAwAgzgEAALQDACDRAQAAAwAg0gEAAAMAINMBAAABACALHAAAkQMAMB0AAJYDADDNAQAAkgMAMM4BAACTAwAwzwEAAJQDACDQAQAAlQMAMNEBAACVAwAw0gEAAJUDADDTAQAAlQMAMNQBAACXAwAw1QEAAJgDADALHAAAhQMAMB0AAIoDADDNAQAAhgMAMM4BAACHAwAwzwEAAIgDACDQAQAAiQMAMNEBAACJAwAw0gEAAIkDADDTAQAAiQMAMNQBAACLAwAw1QEAAIwDADALHAAA_AIAMB0AAIADADDNAQAA_QIAMM4BAAD-AgAwzwEAAP8CACDQAQAA9AIAMNEBAAD0AgAw0gEAAPQCADDTAQAA9AIAMNQBAACBAwAw1QEAAPcCADALHAAA8AIAMB0AAPUCADDNAQAA8QIAMM4BAADyAgAwzwEAAPMCACDQAQAA9AIAMNEBAAD0AgAw0gEAAPQCADDTAQAA9AIAMNQBAAD2AgAw1QEAAPcCADALHAAA5AIAMB0AAOkCADDNAQAA5QIAMM4BAADmAgAwzwEAAOcCACDQAQAA6AIAMNEBAADoAgAw0gEAAOgCADDTAQAA6AIAMNQBAADqAgAw1QEAAOsCADALHAAA2AIAMB0AAN0CADDNAQAA2QIAMM4BAADaAgAwzwEAANsCACDQAQAA3AIAMNEBAADcAgAw0gEAANwCADDTAQAA3AIAMNQBAADeAgAw1QEAAN8CADADnAEBAAAAAZ0BQAAAAAGeAUAAAAABAgAAABsAIBwAAOMCACADAAAAGwAgHAAA4wIAIB0AAOICACABFQAAswMAMAkFAACBAgAgmAEAAIACADCZAQAAGQAQmgEAAIACADCbAQEA9QEAIZwBAQD1AQAhnQFAAPYBACGeAUAA9gEAIcUBAAD_AQAgAgAAABsAIBUAAOICACACAAAA4AIAIBUAAOECACAHmAEAAN8CADCZAQAA4AIAEJoBAADfAgAwmwEBAPUBACGcAQEA9QEAIZ0BQAD2AQAhngFAAPYBACEHmAEAAN8CADCZAQAA4AIAEJoBAADfAgAwmwEBAPUBACGcAQEA9QEAIZ0BQAD2AQAhngFAAPYBACEDnAEBAJgCACGdAUAAmQIAIZ4BQACZAgAhA5wBAQCYAgAhnQFAAJkCACGeAUAAmQIAIQOcAQEAAAABnQFAAAAAAZ4BQAAAAAECnAEBAAAAAaoBQAAAAAECAAAAFwAgHAAA7wIAIAMAAAAXACAcAADvAgAgHQAA7gIAIAEVAACyAwAwCAUAAIECACCYAQAAgwIAMJkBAAAVABCaAQAAgwIAMJsBAQD1AQAhnAEBAPUBACGqAUAA9gEAIcUBAACCAgAgAgAAABcAIBUAAO4CACACAAAA7AIAIBUAAO0CACAGmAEAAOsCADCZAQAA7AIAEJoBAADrAgAwmwEBAPUBACGcAQEA9QEAIaoBQAD2AQAhBpgBAADrAgAwmQEAAOwCABCaAQAA6wIAMJsBAQD1AQAhnAEBAPUBACGqAUAA9gEAIQKcAQEAmAIAIaoBQACZAgAhApwBAQCYAgAhqgFAAJkCACECnAEBAAAAAaoBQAAAAAEDCgAAqQIAIKsBAQAAAAGtAQIAAAABAgAAABIAIBwAAPsCACADAAAAEgAgHAAA-wIAIB0AAPoCACABFQAAsQMAMAkKAACBAgAgCwAAgQIAIJgBAACFAgAwmQEAABAAEJoBAACFAgAwqwEBAPUBACGsAQEA9QEAIa0BAgCGAgAhxgEAAIQCACACAAAAEgAgFQAA-gIAIAIAAAD4AgAgFQAA-QIAIAaYAQAA9wIAMJkBAAD4AgAQmgEAAPcCADCrAQEA9QEAIawBAQD1AQAhrQECAIYCACEGmAEAAPcCADCZAQAA-AIAEJoBAAD3AgAwqwEBAPUBACGsAQEA9QEAIa0BAgCGAgAhAqsBAQCYAgAhrQECAKYCACEDCgAApwIAIKsBAQCYAgAhrQECAKYCACEDCgAAqQIAIKsBAQAAAAGtAQIAAAABAwsAAKoCACCsAQEAAAABrQECAAAAAQIAAAASACAcAACEAwAgAwAAABIAIBwAAIQDACAdAACDAwAgARUAALADADACAAAAEgAgFQAAgwMAIAIAAAD4AgAgFQAAggMAIAKsAQEAmAIAIa0BAgCmAgAhAwsAAKgCACCsAQEAmAIAIa0BAgCmAgAhAwsAAKoCACCsAQEAAAABrQECAAAAAQsHAADGAgAgngFAAAAAAaoBQAAAAAGxAQEAAAABsgEBAAAAAbMBAQAAAAG0AQEAAAABtQEBAAAAAbYBAQAAAAG3AUAAAAABuAEBAAAAAQIAAAAJACAcAACQAwAgAwAAAAkAIBwAAJADACAdAACPAwAgARUAAK8DADAQBQAAgQIAIAcAAIwCACCYAQAAiwIAMJkBAAAHABCaAQAAiwIAMJsBAQD1AQAhngFAAPYBACGqAUAA9gEAIbEBAQAAAAGyAQEA9QEAIbMBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbYBAQD3AQAhtwFAAPgBACG4AQEA9wEAIQIAAAAJACAVAACPAwAgAgAAAI0DACAVAACOAwAgDpgBAACMAwAwmQEAAI0DABCaAQAAjAMAMJsBAQD1AQAhngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhsgEBAPUBACGzAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG2AQEA9wEAIbcBQAD4AQAhuAEBAPcBACEOmAEAAIwDADCZAQAAjQMAEJoBAACMAwAwmwEBAPUBACGeAUAA9gEAIaoBQAD2AQAhsQEBAPUBACGyAQEA9QEAIbMBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbYBAQD3AQAhtwFAAPgBACG4AQEA9wEAIQqeAUAAmQIAIaoBQACZAgAhsQEBAJgCACGyAQEAmAIAIbMBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbYBAQC1AgAhtwFAALYCACG4AQEAtQIAIQsHAAC4AgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhsgEBAJgCACGzAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG2AQEAtQIAIbcBQAC2AgAhuAEBALUCACELBwAAxgIAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbIBAQAAAAGzAQEAAAABtAEBAAAAAbUBAQAAAAG2AQEAAAABtwFAAAAAAbgBAQAAAAEUBAAAnQMAIAkAAJ4DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb4BAQAAAAG_AQEAAAABwAEBAAAAAcEBIAAAAAHCASAAAAABwwFAAAAAAcQBAQAAAAECAAAAAQAgHAAAnAMAIAMAAAABACAcAACcAwAgHQAAmwMAIAEVAACuAwAwGQEAAI8CACAEAACQAgAgCQAAkQIAIAwAAJICACANAACSAgAgDgAAkwIAIA8AAJQCACCYAQAAjQIAMJkBAAADABCaAQAAjQIAMJ4BQAD2AQAhqgFAAPYBACGxAQEAAAABtAEBAPUBACG1AQEA9QEAIbcBQAD4AQAhuAEBAPcBACG9AQEA9wEAIb4BAQD3AQAhvwEBAPcBACHAAQEA9QEAIcEBIACOAgAhwgEgAI4CACHDAUAA-AEAIcQBAQD3AQAhAgAAAAEAIBUAAJsDACACAAAAmQMAIBUAAJoDACASmAEAAJgDADCZAQAAmQMAEJoBAACYAwAwngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbcBQAD4AQAhuAEBAPcBACG9AQEA9wEAIb4BAQD3AQAhvwEBAPcBACHAAQEA9QEAIcEBIACOAgAhwgEgAI4CACHDAUAA-AEAIcQBAQD3AQAhEpgBAACYAwAwmQEAAJkDABCaAQAAmAMAMJ4BQAD2AQAhqgFAAPYBACGxAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG3AUAA-AEAIbgBAQD3AQAhvQEBAPcBACG-AQEA9wEAIb8BAQD3AQAhwAEBAPUBACHBASAAjgIAIcIBIACOAgAhwwFAAPgBACHEAQEA9wEAIQ6eAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhFAQAANICACAJAADTAgAgDAAA1AIAIA0AANUCACAOAADWAgAgDwAA1wIAIJ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG3AUAAtgIAIbgBAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEUBAAAnQMAIAkAAJ4DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb4BAQAAAAG_AQEAAAABwAEBAAAAAcEBIAAAAAHCASAAAAABwwFAAAAAAcQBAQAAAAEEHAAAkQMAMM0BAACSAwAwzwEAAJQDACDTAQAAlQMAMAQcAACFAwAwzQEAAIYDADDPAQAAiAMAINMBAACJAwAwBBwAAPwCADDNAQAA_QIAMM8BAAD_AgAg0wEAAPQCADAEHAAA8AIAMM0BAADxAgAwzwEAAPMCACDTAQAA9AIAMAQcAADkAgAwzQEAAOUCADDPAQAA5wIAINMBAADoAgAwBBwAANgCADDNAQAA2QIAMM8BAADbAgAg0wEAANwCADADHAAArAMAIM0BAACtAwAg0wEAAAEAIA4BAACkAwAgBAAApwMAIAkAAKgDACAMAACpAwAgDQAAqQMAIA4AAKoDACAPAACrAwAgtwEAALECACC4AQAAsQIAIL0BAACxAgAgvgEAALECACC_AQAAsQIAIMMBAACxAgAgxAEAALECACAFBQAApAMAIAcAAKYDACC2AQAAsQIAILcBAACxAgAguAEAALECACAAAAAAAAAVAQAAowMAIAkAAJ4DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb0BAQAAAAG-AQEAAAABvwEBAAAAAcABAQAAAAHBASAAAAABwgEgAAAAAcMBQAAAAAHEAQEAAAABAgAAAAEAIBwAAKwDACAOngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb4BAQAAAAG_AQEAAAABwAEBAAAAAcEBIAAAAAHCASAAAAABwwFAAAAAAcQBAQAAAAEKngFAAAAAAaoBQAAAAAGxAQEAAAABsgEBAAAAAbMBAQAAAAG0AQEAAAABtQEBAAAAAbYBAQAAAAG3AUAAAAABuAEBAAAAAQKsAQEAAAABrQECAAAAAQKrAQEAAAABrQECAAAAAQKcAQEAAAABqgFAAAAAAQOcAQEAAAABnQFAAAAAAZ4BQAAAAAEDAAAAAwAgHAAArAMAIB0AALYDACAXAAAAAwAgAQAA0QIAIAkAANMCACAMAADUAgAgDQAA1QIAIA4AANYCACAPAADXAgAgFQAAtgMAIJ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG3AUAAtgIAIbgBAQC1AgAhvQEBALUCACG-AQEAtQIAIb8BAQC1AgAhwAEBAJgCACHBASAA0AIAIcIBIADQAgAhwwFAALYCACHEAQEAtQIAIRUBAADRAgAgCQAA0wIAIAwAANQCACANAADVAgAgDgAA1gIAIA8AANcCACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAAowMAIAQAAJ0DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb0BAQAAAAG-AQEAAAABvwEBAAAAAcABAQAAAAHBASAAAAABwgEgAAAAAcMBQAAAAAHEAQEAAAABAgAAAAEAIBwAALcDACAEnAEBAAAAAZ4BQAAAAAGqAUAAAAABsAEAAACwAQIDAAAAAwAgHAAAtwMAIB0AALwDACAXAAAAAwAgAQAA0QIAIAQAANICACAMAADUAgAgDQAA1QIAIA4AANYCACAPAADXAgAgFQAAvAMAIJ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG3AUAAtgIAIbgBAQC1AgAhvQEBALUCACG-AQEAtQIAIb8BAQC1AgAhwAEBAJgCACHBASAA0AIAIcIBIADQAgAhwwFAALYCACHEAQEAtQIAIRUBAADRAgAgBAAA0gIAIAwAANQCACANAADVAgAgDgAA1gIAIA8AANcCACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEMBQAAxQIAIJsBAQAAAAGeAUAAAAABqgFAAAAAAbEBAQAAAAGyAQEAAAABswEBAAAAAbQBAQAAAAG1AQEAAAABtgEBAAAAAbcBQAAAAAG4AQEAAAABAgAAAAkAIBwAAL0DACADAAAABwAgHAAAvQMAIB0AAMEDACAOAAAABwAgBQAAtwIAIBUAAMEDACCbAQEAmAIAIZ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbIBAQCYAgAhswEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtgEBALUCACG3AUAAtgIAIbgBAQC1AgAhDAUAALcCACCbAQEAmAIAIZ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbIBAQCYAgAhswEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtgEBALUCACG3AUAAtgIAIbgBAQC1AgAhFQEAAKMDACAEAACdAwAgCQAAngMAIAwAAJ8DACAOAAChAwAgDwAAogMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADCAwAgFQEAAKMDACAEAACdAwAgCQAAngMAIA0AAKADACAOAAChAwAgDwAAogMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADEAwAgAwAAAAMAIBwAAMIDACAdAADIAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIAwAANQCACAOAADWAgAgDwAA1wIAIBUAAMgDACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDAAA1AIAIA4AANYCACAPAADXAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhAwAAAAMAIBwAAMQDACAdAADLAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIA0AANUCACAOAADWAgAgDwAA1wIAIBUAAMsDACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDQAA1QIAIA4AANYCACAPAADXAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhFQEAAKMDACAEAACdAwAgCQAAngMAIAwAAJ8DACANAACgAwAgDwAAogMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADMAwAgAwAAAAMAIBwAAMwDACAdAADQAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIAwAANQCACANAADVAgAgDwAA1wIAIBUAANADACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDAAA1AIAIA0AANUCACAPAADXAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhFQEAAKMDACAEAACdAwAgCQAAngMAIAwAAJ8DACANAACgAwAgDgAAoQMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADRAwAgAwAAAAMAIBwAANEDACAdAADVAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIAwAANQCACANAADVAgAgDgAA1gIAIBUAANUDACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDAAA1AIAIA0AANUCACAOAADWAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhCAEEAQQGAQgACAkKAgwTBQ0UBQ4YBg8cBwMFAAEHDgMIAAQBBgACAQcPAAIKAAELAAEBBQABAQUAAQYEHQAJHgAMHwANIAAOIQAPIgAAAQEsAQEBMgEDCAANIgAOIwAPAAAAAwgADSIADiMADwAAAAMIABUiABYjABcAAAADCAAVIgAWIwAXAAAAAwgAHSIAHiMAHwAAAAMIAB0iAB4jAB8BBQABAQUAAQMIACQiACUjACYAAAADCAAkIgAlIwAmAQYAAgEGAAIDCAArIgAsIwAtAAAAAwgAKyIALCMALQIKAAELAAECCgABCwABBQgAMiIANSMANnQAM3UANAAAAAAABQgAMiIANSMANnQAM3UANAEFAAEBBQABAwgAOyIAPCMAPQAAAAMIADsiADwjAD0BBQABAQUAAQMIAEIiAEMjAEQAAAADCABCIgBDIwBEEAIBESMBEiQBEyUBFCYBFigBFyoJGCsKGS4BGjAJGzELHjMBHzQBIDUJJDgMJTkQJjsRJzwRKD8RKUARKkERK0MRLEUJLUYSLkgRL0oJMEsTMUwRMk0RM04JNFEUNVIYNlQZN1UZOFgZOVkZOloZO1wZPF4JPV8aPmEZP2MJQGQbQWUZQmYZQ2cJRGocRWsgRmwCR20CSG4CSW8CSnACS3ICTHQJTXUhTncCT3kJUHoiUXsCUnwCU30JVIABI1WBASdWggEDV4MBA1iEAQNZhQEDWoYBA1uIAQNcigEJXYsBKF6NAQNfjwEJYJABKWGRAQNikgEDY5MBCWSWASpllwEuZpgBBWeZAQVomgEFaZsBBWqcAQVrngEFbKABCW2hAS9uowEFb6UBCXCmATBxpwEFcqgBBXOpAQl2rAExd60BN3iuAQZ5rwEGerABBnuxAQZ8sgEGfbQBBn62AQl_twE4gAG5AQaBAbsBCYIBvAE5gwG9AQaEAb4BBoUBvwEJhgHCATqHAcMBPogBxAEHiQHFAQeKAcYBB4sBxwEHjAHIAQeNAcoBB44BzAEJjwHNAT-QAc8BB5EB0QEJkgHSAUCTAdMBB5QB1AEHlQHVAQmWAdgBQZcB2QFF" } config.compilerWasm = { getRuntime: async () => require('./query_compiler_fast_bg.js'), diff --git a/packages/forums-prisma-client/index-browser.js b/packages/forums-prisma-client/index-browser.js index 2b3aa09..1995440 100644 --- a/packages/forums-prisma-client/index-browser.js +++ b/packages/forums-prisma-client/index-browser.js @@ -170,6 +170,14 @@ exports.Prisma.PostScalarFieldEnum = { deletedByMemberId: 'deletedByMemberId' }; +exports.Prisma.PostReactionScalarFieldEnum = { + postId: 'postId', + memberId: 'memberId', + reaction: 'reaction', + createdAt: 'createdAt', + updatedAt: 'updatedAt' +}; + exports.Prisma.TopicClosureScalarFieldEnum = { ancestorTopicId: 'ancestorTopicId', descendantTopicId: 'descendantTopicId', @@ -203,13 +211,17 @@ exports.Prisma.NullsOrder = { first: 'first', last: 'last' }; - +exports.PostReactionType = exports.$Enums.PostReactionType = { + THUMBS_UP: 'THUMBS_UP', + THUMBS_DOWN: 'THUMBS_DOWN' +}; exports.Prisma.ModelName = { Topic: 'Topic', MemberBan: 'MemberBan', IpBan: 'IpBan', Post: 'Post', + PostReaction: 'PostReaction', TopicClosure: 'TopicClosure', TopicWatch: 'TopicWatch', TopicReadState: 'TopicReadState' diff --git a/packages/forums-prisma-client/index.d.ts b/packages/forums-prisma-client/index.d.ts index b7d370e..7f5014b 100644 --- a/packages/forums-prisma-client/index.d.ts +++ b/packages/forums-prisma-client/index.d.ts @@ -33,6 +33,11 @@ export type IpBan = $Result.DefaultSelection * */ export type Post = $Result.DefaultSelection +/** + * Model PostReaction + * + */ +export type PostReaction = $Result.DefaultSelection /** * Model TopicClosure * @@ -49,6 +54,23 @@ export type TopicWatch = $Result.DefaultSelection */ export type TopicReadState = $Result.DefaultSelection +/** + * Enums + */ +export namespace $Enums { + export const PostReactionType: { + THUMBS_UP: 'THUMBS_UP', + THUMBS_DOWN: 'THUMBS_DOWN' +}; + +export type PostReactionType = (typeof PostReactionType)[keyof typeof PostReactionType] + +} + +export type PostReactionType = $Enums.PostReactionType + +export const PostReactionType: typeof $Enums.PostReactionType + /** * ## Prisma Client ʲˢ * @@ -210,6 +232,16 @@ export class PrismaClient< */ get post(): Prisma.PostDelegate; + /** + * `prisma.postReaction`: Exposes CRUD operations for the **PostReaction** model. + * Example usage: + * ```ts + * // Fetch zero or more PostReactions + * const postReactions = await prisma.postReaction.findMany() + * ``` + */ + get postReaction(): Prisma.PostReactionDelegate; + /** * `prisma.topicClosure`: Exposes CRUD operations for the **TopicClosure** model. * Example usage: @@ -690,6 +722,7 @@ export namespace Prisma { MemberBan: 'MemberBan', IpBan: 'IpBan', Post: 'Post', + PostReaction: 'PostReaction', TopicClosure: 'TopicClosure', TopicWatch: 'TopicWatch', TopicReadState: 'TopicReadState' @@ -708,7 +741,7 @@ export namespace Prisma { omit: GlobalOmitOptions } meta: { - modelProps: "topic" | "memberBan" | "ipBan" | "post" | "topicClosure" | "topicWatch" | "topicReadState" + modelProps: "topic" | "memberBan" | "ipBan" | "post" | "postReaction" | "topicClosure" | "topicWatch" | "topicReadState" txIsolationLevel: Prisma.TransactionIsolationLevel } model: { @@ -1008,6 +1041,80 @@ export namespace Prisma { } } } + PostReaction: { + payload: Prisma.$PostReactionPayload + fields: Prisma.PostReactionFieldRefs + operations: { + findUnique: { + args: Prisma.PostReactionFindUniqueArgs + result: $Utils.PayloadToResult | null + } + findUniqueOrThrow: { + args: Prisma.PostReactionFindUniqueOrThrowArgs + result: $Utils.PayloadToResult + } + findFirst: { + args: Prisma.PostReactionFindFirstArgs + result: $Utils.PayloadToResult | null + } + findFirstOrThrow: { + args: Prisma.PostReactionFindFirstOrThrowArgs + result: $Utils.PayloadToResult + } + findMany: { + args: Prisma.PostReactionFindManyArgs + result: $Utils.PayloadToResult[] + } + create: { + args: Prisma.PostReactionCreateArgs + result: $Utils.PayloadToResult + } + createMany: { + args: Prisma.PostReactionCreateManyArgs + result: BatchPayload + } + createManyAndReturn: { + args: Prisma.PostReactionCreateManyAndReturnArgs + result: $Utils.PayloadToResult[] + } + delete: { + args: Prisma.PostReactionDeleteArgs + result: $Utils.PayloadToResult + } + update: { + args: Prisma.PostReactionUpdateArgs + result: $Utils.PayloadToResult + } + deleteMany: { + args: Prisma.PostReactionDeleteManyArgs + result: BatchPayload + } + updateMany: { + args: Prisma.PostReactionUpdateManyArgs + result: BatchPayload + } + updateManyAndReturn: { + args: Prisma.PostReactionUpdateManyAndReturnArgs + result: $Utils.PayloadToResult[] + } + upsert: { + args: Prisma.PostReactionUpsertArgs + result: $Utils.PayloadToResult + } + aggregate: { + args: Prisma.PostReactionAggregateArgs + result: $Utils.Optional + } + groupBy: { + args: Prisma.PostReactionGroupByArgs + result: $Utils.Optional[] + } + count: { + args: Prisma.PostReactionCountArgs + result: $Utils.Optional | number + } + } + } TopicClosure: { payload: Prisma.$TopicClosurePayload fields: Prisma.TopicClosureFieldRefs @@ -1357,6 +1464,7 @@ export namespace Prisma { memberBan?: MemberBanOmit ipBan?: IpBanOmit post?: PostOmit + postReaction?: PostReactionOmit topicClosure?: TopicClosureOmit topicWatch?: TopicWatchOmit topicReadState?: TopicReadStateOmit @@ -1511,6 +1619,37 @@ export namespace Prisma { } + /** + * Count Type PostCountOutputType + */ + + export type PostCountOutputType = { + reactions: number + } + + export type PostCountOutputTypeSelect = { + reactions?: boolean | PostCountOutputTypeCountReactionsArgs + } + + // Custom InputTypes + /** + * PostCountOutputType without action + */ + export type PostCountOutputTypeDefaultArgs = { + /** + * Select specific fields to fetch from the PostCountOutputType + */ + select?: PostCountOutputTypeSelect | null + } + + /** + * PostCountOutputType without action + */ + export type PostCountOutputTypeCountReactionsArgs = { + where?: PostReactionWhereInput + } + + /** * Models */ @@ -5136,6 +5275,8 @@ export namespace Prisma { deletedAt?: boolean deletedByMemberId?: boolean topic?: boolean | TopicDefaultArgs + reactions?: boolean | Post$reactionsArgs + _count?: boolean | PostCountOutputTypeDefaultArgs }, ExtArgs["result"]["post"]> export type PostSelectCreateManyAndReturn = $Extensions.GetSelect<{ @@ -5185,6 +5326,8 @@ export namespace Prisma { export type PostOmit = $Extensions.GetOmit<"id" | "topicId" | "parentType" | "parentId" | "authorMemberId" | "authorHandle" | "content" | "createdAt" | "updatedAt" | "deletedAt" | "deletedByMemberId", ExtArgs["result"]["post"]> export type PostInclude = { topic?: boolean | TopicDefaultArgs + reactions?: boolean | Post$reactionsArgs + _count?: boolean | PostCountOutputTypeDefaultArgs } export type PostIncludeCreateManyAndReturn = { topic?: boolean | TopicDefaultArgs @@ -5197,6 +5340,7 @@ export namespace Prisma { name: "Post" objects: { topic: Prisma.$TopicPayload + reactions: Prisma.$PostReactionPayload[] } scalars: $Extensions.GetPayloadResult<{ id: string @@ -5605,6 +5749,7 @@ export namespace Prisma { export interface Prisma__PostClient extends Prisma.PrismaPromise { readonly [Symbol.toStringTag]: "PrismaPromise" topic = {}>(args?: Subset>): Prisma__TopicClient<$Result.GetResult, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> + reactions = {}>(args?: Subset>): Prisma.PrismaPromise<$Result.GetResult, T, "findMany", GlobalOmitOptions> | Null> /** * Attaches callbacks for the resolution and/or rejection of the Promise. * @param onfulfilled The callback to execute when the Promise is resolved. @@ -5964,103 +6109,1190 @@ export namespace Prisma { /** * The data used to update Posts. */ - data: XOR + data: XOR + /** + * Filter which Posts to update + */ + where?: PostWhereInput + /** + * Limit how many Posts to update. + */ + limit?: number + /** + * Choose, which related nodes to fetch as well + */ + include?: PostIncludeUpdateManyAndReturn | null + } + + /** + * Post upsert + */ + export type PostUpsertArgs = { + /** + * Select specific fields to fetch from the Post + */ + select?: PostSelect | null + /** + * Omit specific fields from the Post + */ + omit?: PostOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostInclude | null + /** + * The filter to search for the Post to update in case it exists. + */ + where: PostWhereUniqueInput + /** + * In case the Post found by the `where` argument doesn't exist, create a new Post with this data. + */ + create: XOR + /** + * In case the Post was found with the provided `where` argument, update it with this data. + */ + update: XOR + } + + /** + * Post delete + */ + export type PostDeleteArgs = { + /** + * Select specific fields to fetch from the Post + */ + select?: PostSelect | null + /** + * Omit specific fields from the Post + */ + omit?: PostOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostInclude | null + /** + * Filter which Post to delete. + */ + where: PostWhereUniqueInput + } + + /** + * Post deleteMany + */ + export type PostDeleteManyArgs = { + /** + * Filter which Posts to delete + */ + where?: PostWhereInput + /** + * Limit how many Posts to delete. + */ + limit?: number + } + + /** + * Post.reactions + */ + export type Post$reactionsArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + where?: PostReactionWhereInput + orderBy?: PostReactionOrderByWithRelationInput | PostReactionOrderByWithRelationInput[] + cursor?: PostReactionWhereUniqueInput + take?: number + skip?: number + distinct?: PostReactionScalarFieldEnum | PostReactionScalarFieldEnum[] + } + + /** + * Post without action + */ + export type PostDefaultArgs = { + /** + * Select specific fields to fetch from the Post + */ + select?: PostSelect | null + /** + * Omit specific fields from the Post + */ + omit?: PostOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostInclude | null + } + + + /** + * Model PostReaction + */ + + export type AggregatePostReaction = { + _count: PostReactionCountAggregateOutputType | null + _min: PostReactionMinAggregateOutputType | null + _max: PostReactionMaxAggregateOutputType | null + } + + export type PostReactionMinAggregateOutputType = { + postId: string | null + memberId: string | null + reaction: $Enums.PostReactionType | null + createdAt: Date | null + updatedAt: Date | null + } + + export type PostReactionMaxAggregateOutputType = { + postId: string | null + memberId: string | null + reaction: $Enums.PostReactionType | null + createdAt: Date | null + updatedAt: Date | null + } + + export type PostReactionCountAggregateOutputType = { + postId: number + memberId: number + reaction: number + createdAt: number + updatedAt: number + _all: number + } + + + export type PostReactionMinAggregateInputType = { + postId?: true + memberId?: true + reaction?: true + createdAt?: true + updatedAt?: true + } + + export type PostReactionMaxAggregateInputType = { + postId?: true + memberId?: true + reaction?: true + createdAt?: true + updatedAt?: true + } + + export type PostReactionCountAggregateInputType = { + postId?: true + memberId?: true + reaction?: true + createdAt?: true + updatedAt?: true + _all?: true + } + + export type PostReactionAggregateArgs = { + /** + * Filter which PostReaction to aggregate. + */ + where?: PostReactionWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of PostReactions to fetch. + */ + orderBy?: PostReactionOrderByWithRelationInput | PostReactionOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the start position + */ + cursor?: PostReactionWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` PostReactions from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` PostReactions. + */ + skip?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs} + * + * Count returned PostReactions + **/ + _count?: true | PostReactionCountAggregateInputType + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs} + * + * Select which fields to find the minimum value + **/ + _min?: PostReactionMinAggregateInputType + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs} + * + * Select which fields to find the maximum value + **/ + _max?: PostReactionMaxAggregateInputType + } + + export type GetPostReactionAggregateType = { + [P in keyof T & keyof AggregatePostReaction]: P extends '_count' | 'count' + ? T[P] extends true + ? number + : GetScalarType + : GetScalarType + } + + + + + export type PostReactionGroupByArgs = { + where?: PostReactionWhereInput + orderBy?: PostReactionOrderByWithAggregationInput | PostReactionOrderByWithAggregationInput[] + by: PostReactionScalarFieldEnum[] | PostReactionScalarFieldEnum + having?: PostReactionScalarWhereWithAggregatesInput + take?: number + skip?: number + _count?: PostReactionCountAggregateInputType | true + _min?: PostReactionMinAggregateInputType + _max?: PostReactionMaxAggregateInputType + } + + export type PostReactionGroupByOutputType = { + postId: string + memberId: string + reaction: $Enums.PostReactionType + createdAt: Date + updatedAt: Date + _count: PostReactionCountAggregateOutputType | null + _min: PostReactionMinAggregateOutputType | null + _max: PostReactionMaxAggregateOutputType | null + } + + type GetPostReactionGroupByPayload = Prisma.PrismaPromise< + Array< + PickEnumerable & + { + [P in ((keyof T) & (keyof PostReactionGroupByOutputType))]: P extends '_count' + ? T[P] extends boolean + ? number + : GetScalarType + : GetScalarType + } + > + > + + + export type PostReactionSelect = $Extensions.GetSelect<{ + postId?: boolean + memberId?: boolean + reaction?: boolean + createdAt?: boolean + updatedAt?: boolean + post?: boolean | PostDefaultArgs + }, ExtArgs["result"]["postReaction"]> + + export type PostReactionSelectCreateManyAndReturn = $Extensions.GetSelect<{ + postId?: boolean + memberId?: boolean + reaction?: boolean + createdAt?: boolean + updatedAt?: boolean + post?: boolean | PostDefaultArgs + }, ExtArgs["result"]["postReaction"]> + + export type PostReactionSelectUpdateManyAndReturn = $Extensions.GetSelect<{ + postId?: boolean + memberId?: boolean + reaction?: boolean + createdAt?: boolean + updatedAt?: boolean + post?: boolean | PostDefaultArgs + }, ExtArgs["result"]["postReaction"]> + + export type PostReactionSelectScalar = { + postId?: boolean + memberId?: boolean + reaction?: boolean + createdAt?: boolean + updatedAt?: boolean + } + + export type PostReactionOmit = $Extensions.GetOmit<"postId" | "memberId" | "reaction" | "createdAt" | "updatedAt", ExtArgs["result"]["postReaction"]> + export type PostReactionInclude = { + post?: boolean | PostDefaultArgs + } + export type PostReactionIncludeCreateManyAndReturn = { + post?: boolean | PostDefaultArgs + } + export type PostReactionIncludeUpdateManyAndReturn = { + post?: boolean | PostDefaultArgs + } + + export type $PostReactionPayload = { + name: "PostReaction" + objects: { + post: Prisma.$PostPayload + } + scalars: $Extensions.GetPayloadResult<{ + postId: string + memberId: string + reaction: $Enums.PostReactionType + createdAt: Date + updatedAt: Date + }, ExtArgs["result"]["postReaction"]> + composites: {} + } + + type PostReactionGetPayload = $Result.GetResult + + type PostReactionCountArgs = + Omit & { + select?: PostReactionCountAggregateInputType | true + } + + export interface PostReactionDelegate { + [K: symbol]: { types: Prisma.TypeMap['model']['PostReaction'], meta: { name: 'PostReaction' } } + /** + * Find zero or one PostReaction that matches the filter. + * @param {PostReactionFindUniqueArgs} args - Arguments to find a PostReaction + * @example + * // Get one PostReaction + * const postReaction = await prisma.postReaction.findUnique({ + * where: { + * // ... provide filter here + * } + * }) + */ + findUnique(args: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "findUnique", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + + /** + * Find one PostReaction that matches the filter or throw an error with `error.code='P2025'` + * if no matches were found. + * @param {PostReactionFindUniqueOrThrowArgs} args - Arguments to find a PostReaction + * @example + * // Get one PostReaction + * const postReaction = await prisma.postReaction.findUniqueOrThrow({ + * where: { + * // ... provide filter here + * } + * }) + */ + findUniqueOrThrow(args: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "findUniqueOrThrow", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Find the first PostReaction that matches the filter. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {PostReactionFindFirstArgs} args - Arguments to find a PostReaction + * @example + * // Get one PostReaction + * const postReaction = await prisma.postReaction.findFirst({ + * where: { + * // ... provide filter here + * } + * }) + */ + findFirst(args?: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "findFirst", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions> + + /** + * Find the first PostReaction that matches the filter or + * throw `PrismaKnownClientError` with `P2025` code if no matches were found. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {PostReactionFindFirstOrThrowArgs} args - Arguments to find a PostReaction + * @example + * // Get one PostReaction + * const postReaction = await prisma.postReaction.findFirstOrThrow({ + * where: { + * // ... provide filter here + * } + * }) + */ + findFirstOrThrow(args?: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "findFirstOrThrow", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Find zero or more PostReactions that matches the filter. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {PostReactionFindManyArgs} args - Arguments to filter and select certain fields only. + * @example + * // Get all PostReactions + * const postReactions = await prisma.postReaction.findMany() + * + * // Get first 10 PostReactions + * const postReactions = await prisma.postReaction.findMany({ take: 10 }) + * + * // Only select the `postId` + * const postReactionWithPostIdOnly = await prisma.postReaction.findMany({ select: { postId: true } }) + * + */ + findMany(args?: SelectSubset>): Prisma.PrismaPromise<$Result.GetResult, T, "findMany", GlobalOmitOptions>> + + /** + * Create a PostReaction. + * @param {PostReactionCreateArgs} args - Arguments to create a PostReaction. + * @example + * // Create one PostReaction + * const PostReaction = await prisma.postReaction.create({ + * data: { + * // ... data to create a PostReaction + * } + * }) + * + */ + create(args: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "create", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Create many PostReactions. + * @param {PostReactionCreateManyArgs} args - Arguments to create many PostReactions. + * @example + * // Create many PostReactions + * const postReaction = await prisma.postReaction.createMany({ + * data: [ + * // ... provide data here + * ] + * }) + * + */ + createMany(args?: SelectSubset>): Prisma.PrismaPromise + + /** + * Create many PostReactions and returns the data saved in the database. + * @param {PostReactionCreateManyAndReturnArgs} args - Arguments to create many PostReactions. + * @example + * // Create many PostReactions + * const postReaction = await prisma.postReaction.createManyAndReturn({ + * data: [ + * // ... provide data here + * ] + * }) + * + * // Create many PostReactions and only return the `postId` + * const postReactionWithPostIdOnly = await prisma.postReaction.createManyAndReturn({ + * select: { postId: true }, + * data: [ + * // ... provide data here + * ] + * }) + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * + */ + createManyAndReturn(args?: SelectSubset>): Prisma.PrismaPromise<$Result.GetResult, T, "createManyAndReturn", GlobalOmitOptions>> + + /** + * Delete a PostReaction. + * @param {PostReactionDeleteArgs} args - Arguments to delete one PostReaction. + * @example + * // Delete one PostReaction + * const PostReaction = await prisma.postReaction.delete({ + * where: { + * // ... filter to delete one PostReaction + * } + * }) + * + */ + delete(args: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "delete", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Update one PostReaction. + * @param {PostReactionUpdateArgs} args - Arguments to update one PostReaction. + * @example + * // Update one PostReaction + * const postReaction = await prisma.postReaction.update({ + * where: { + * // ... provide filter here + * }, + * data: { + * // ... provide data here + * } + * }) + * + */ + update(args: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "update", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + /** + * Delete zero or more PostReactions. + * @param {PostReactionDeleteManyArgs} args - Arguments to filter PostReactions to delete. + * @example + * // Delete a few PostReactions + * const { count } = await prisma.postReaction.deleteMany({ + * where: { + * // ... provide filter here + * } + * }) + * + */ + deleteMany(args?: SelectSubset>): Prisma.PrismaPromise + + /** + * Update zero or more PostReactions. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {PostReactionUpdateManyArgs} args - Arguments to update one or more rows. + * @example + * // Update many PostReactions + * const postReaction = await prisma.postReaction.updateMany({ + * where: { + * // ... provide filter here + * }, + * data: { + * // ... provide data here + * } + * }) + * + */ + updateMany(args: SelectSubset>): Prisma.PrismaPromise + + /** + * Update zero or more PostReactions and returns the data updated in the database. + * @param {PostReactionUpdateManyAndReturnArgs} args - Arguments to update many PostReactions. + * @example + * // Update many PostReactions + * const postReaction = await prisma.postReaction.updateManyAndReturn({ + * where: { + * // ... provide filter here + * }, + * data: [ + * // ... provide data here + * ] + * }) + * + * // Update zero or more PostReactions and only return the `postId` + * const postReactionWithPostIdOnly = await prisma.postReaction.updateManyAndReturn({ + * select: { postId: true }, + * where: { + * // ... provide filter here + * }, + * data: [ + * // ... provide data here + * ] + * }) + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * + */ + updateManyAndReturn(args: SelectSubset>): Prisma.PrismaPromise<$Result.GetResult, T, "updateManyAndReturn", GlobalOmitOptions>> + + /** + * Create or update one PostReaction. + * @param {PostReactionUpsertArgs} args - Arguments to update or create a PostReaction. + * @example + * // Update or create a PostReaction + * const postReaction = await prisma.postReaction.upsert({ + * create: { + * // ... data to create a PostReaction + * }, + * update: { + * // ... in case it already exists, update + * }, + * where: { + * // ... the filter for the PostReaction we want to update + * } + * }) + */ + upsert(args: SelectSubset>): Prisma__PostReactionClient<$Result.GetResult, T, "upsert", GlobalOmitOptions>, never, ExtArgs, GlobalOmitOptions> + + + /** + * Count the number of PostReactions. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {PostReactionCountArgs} args - Arguments to filter PostReactions to count. + * @example + * // Count the number of PostReactions + * const count = await prisma.postReaction.count({ + * where: { + * // ... the filter for the PostReactions we want to count + * } + * }) + **/ + count( + args?: Subset, + ): Prisma.PrismaPromise< + T extends $Utils.Record<'select', any> + ? T['select'] extends true + ? number + : GetScalarType + : number + > + + /** + * Allows you to perform aggregations operations on a PostReaction. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {PostReactionAggregateArgs} args - Select which aggregations you would like to apply and on what fields. + * @example + * // Ordered by age ascending + * // Where email contains prisma.io + * // Limited to the 10 users + * const aggregations = await prisma.user.aggregate({ + * _avg: { + * age: true, + * }, + * where: { + * email: { + * contains: "prisma.io", + * }, + * }, + * orderBy: { + * age: "asc", + * }, + * take: 10, + * }) + **/ + aggregate(args: Subset): Prisma.PrismaPromise> + + /** + * Group by PostReaction. + * Note, that providing `undefined` is treated as the value not being there. + * Read more here: https://pris.ly/d/null-undefined + * @param {PostReactionGroupByArgs} args - Group by arguments. + * @example + * // Group by city, order by createdAt, get count + * const result = await prisma.user.groupBy({ + * by: ['city', 'createdAt'], + * orderBy: { + * createdAt: true + * }, + * _count: { + * _all: true + * }, + * }) + * + **/ + groupBy< + T extends PostReactionGroupByArgs, + HasSelectOrTake extends Or< + Extends<'skip', Keys>, + Extends<'take', Keys> + >, + OrderByArg extends True extends HasSelectOrTake + ? { orderBy: PostReactionGroupByArgs['orderBy'] } + : { orderBy?: PostReactionGroupByArgs['orderBy'] }, + OrderFields extends ExcludeUnderscoreKeys>>, + ByFields extends MaybeTupleToUnion, + ByValid extends Has, + HavingFields extends GetHavingFields, + HavingValid extends Has, + ByEmpty extends T['by'] extends never[] ? True : False, + InputErrors extends ByEmpty extends True + ? `Error: "by" must not be empty.` + : HavingValid extends False + ? { + [P in HavingFields]: P extends ByFields + ? never + : P extends string + ? `Error: Field "${P}" used in "having" needs to be provided in "by".` + : [ + Error, + 'Field ', + P, + ` in "having" needs to be provided in "by"`, + ] + }[HavingFields] + : 'take' extends Keys + ? 'orderBy' extends Keys + ? ByValid extends True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : `Error: Field "${P}" in "orderBy" needs to be provided in "by"` + }[OrderFields] + : 'Error: If you provide "take", you also need to provide "orderBy"' + : 'skip' extends Keys + ? 'orderBy' extends Keys + ? ByValid extends True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : `Error: Field "${P}" in "orderBy" needs to be provided in "by"` + }[OrderFields] + : 'Error: If you provide "skip", you also need to provide "orderBy"' + : ByValid extends True + ? {} + : { + [P in OrderFields]: P extends ByFields + ? never + : `Error: Field "${P}" in "orderBy" needs to be provided in "by"` + }[OrderFields] + >(args: SubsetIntersection & InputErrors): {} extends InputErrors ? GetPostReactionGroupByPayload : Prisma.PrismaPromise + /** + * Fields of the PostReaction model + */ + readonly fields: PostReactionFieldRefs; + } + + /** + * The delegate class that acts as a "Promise-like" for PostReaction. + * Why is this prefixed with `Prisma__`? + * Because we want to prevent naming conflicts as mentioned in + * https://github.com/prisma/prisma-client-js/issues/707 + */ + export interface Prisma__PostReactionClient extends Prisma.PrismaPromise { + readonly [Symbol.toStringTag]: "PrismaPromise" + post = {}>(args?: Subset>): Prisma__PostClient<$Result.GetResult, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> + /** + * Attaches callbacks for the resolution and/or rejection of the Promise. + * @param onfulfilled The callback to execute when the Promise is resolved. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of which ever callback is executed. + */ + then(onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null): $Utils.JsPromise + /** + * Attaches a callback for only the rejection of the Promise. + * @param onrejected The callback to execute when the Promise is rejected. + * @returns A Promise for the completion of the callback. + */ + catch(onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null): $Utils.JsPromise + /** + * Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The + * resolved value cannot be modified from the callback. + * @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected). + * @returns A Promise for the completion of the callback. + */ + finally(onfinally?: (() => void) | undefined | null): $Utils.JsPromise + } + + + + + /** + * Fields of the PostReaction model + */ + interface PostReactionFieldRefs { + readonly postId: FieldRef<"PostReaction", 'String'> + readonly memberId: FieldRef<"PostReaction", 'String'> + readonly reaction: FieldRef<"PostReaction", 'PostReactionType'> + readonly createdAt: FieldRef<"PostReaction", 'DateTime'> + readonly updatedAt: FieldRef<"PostReaction", 'DateTime'> + } + + + // Custom InputTypes + /** + * PostReaction findUnique + */ + export type PostReactionFindUniqueArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + /** + * Filter, which PostReaction to fetch. + */ + where: PostReactionWhereUniqueInput + } + + /** + * PostReaction findUniqueOrThrow + */ + export type PostReactionFindUniqueOrThrowArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + /** + * Filter, which PostReaction to fetch. + */ + where: PostReactionWhereUniqueInput + } + + /** + * PostReaction findFirst + */ + export type PostReactionFindFirstArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + /** + * Filter, which PostReaction to fetch. + */ + where?: PostReactionWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of PostReactions to fetch. + */ + orderBy?: PostReactionOrderByWithRelationInput | PostReactionOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the position for searching for PostReactions. + */ + cursor?: PostReactionWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` PostReactions from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` PostReactions. + */ + skip?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs} + * + * Filter by unique combinations of PostReactions. + */ + distinct?: PostReactionScalarFieldEnum | PostReactionScalarFieldEnum[] + } + + /** + * PostReaction findFirstOrThrow + */ + export type PostReactionFindFirstOrThrowArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + /** + * Filter, which PostReaction to fetch. + */ + where?: PostReactionWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of PostReactions to fetch. + */ + orderBy?: PostReactionOrderByWithRelationInput | PostReactionOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the position for searching for PostReactions. + */ + cursor?: PostReactionWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` PostReactions from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` PostReactions. + */ + skip?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs} + * + * Filter by unique combinations of PostReactions. + */ + distinct?: PostReactionScalarFieldEnum | PostReactionScalarFieldEnum[] + } + + /** + * PostReaction findMany + */ + export type PostReactionFindManyArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + /** + * Filter, which PostReactions to fetch. + */ + where?: PostReactionWhereInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/sorting Sorting Docs} + * + * Determine the order of PostReactions to fetch. + */ + orderBy?: PostReactionOrderByWithRelationInput | PostReactionOrderByWithRelationInput[] + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination#cursor-based-pagination Cursor Docs} + * + * Sets the position for listing PostReactions. + */ + cursor?: PostReactionWhereUniqueInput + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Take `±n` PostReactions from the position of the cursor. + */ + take?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/pagination Pagination Docs} + * + * Skip the first `n` PostReactions. + */ + skip?: number + /** + * {@link https://www.prisma.io/docs/concepts/components/prisma-client/distinct Distinct Docs} + * + * Filter by unique combinations of PostReactions. + */ + distinct?: PostReactionScalarFieldEnum | PostReactionScalarFieldEnum[] + } + + /** + * PostReaction create + */ + export type PostReactionCreateArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + /** + * The data needed to create a PostReaction. + */ + data: XOR + } + + /** + * PostReaction createMany + */ + export type PostReactionCreateManyArgs = { + /** + * The data used to create many PostReactions. + */ + data: PostReactionCreateManyInput | PostReactionCreateManyInput[] + skipDuplicates?: boolean + } + + /** + * PostReaction createManyAndReturn + */ + export type PostReactionCreateManyAndReturnArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelectCreateManyAndReturn | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * The data used to create many PostReactions. + */ + data: PostReactionCreateManyInput | PostReactionCreateManyInput[] + skipDuplicates?: boolean + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionIncludeCreateManyAndReturn | null + } + + /** + * PostReaction update + */ + export type PostReactionUpdateArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelect | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * Choose, which related nodes to fetch as well + */ + include?: PostReactionInclude | null + /** + * The data needed to update a PostReaction. + */ + data: XOR + /** + * Choose, which PostReaction to update. + */ + where: PostReactionWhereUniqueInput + } + + /** + * PostReaction updateMany + */ + export type PostReactionUpdateManyArgs = { + /** + * The data used to update PostReactions. + */ + data: XOR + /** + * Filter which PostReactions to update + */ + where?: PostReactionWhereInput + /** + * Limit how many PostReactions to update. + */ + limit?: number + } + + /** + * PostReaction updateManyAndReturn + */ + export type PostReactionUpdateManyAndReturnArgs = { + /** + * Select specific fields to fetch from the PostReaction + */ + select?: PostReactionSelectUpdateManyAndReturn | null + /** + * Omit specific fields from the PostReaction + */ + omit?: PostReactionOmit | null + /** + * The data used to update PostReactions. + */ + data: XOR /** - * Filter which Posts to update + * Filter which PostReactions to update */ - where?: PostWhereInput + where?: PostReactionWhereInput /** - * Limit how many Posts to update. + * Limit how many PostReactions to update. */ limit?: number /** * Choose, which related nodes to fetch as well */ - include?: PostIncludeUpdateManyAndReturn | null + include?: PostReactionIncludeUpdateManyAndReturn | null } /** - * Post upsert + * PostReaction upsert */ - export type PostUpsertArgs = { + export type PostReactionUpsertArgs = { /** - * Select specific fields to fetch from the Post + * Select specific fields to fetch from the PostReaction */ - select?: PostSelect | null + select?: PostReactionSelect | null /** - * Omit specific fields from the Post + * Omit specific fields from the PostReaction */ - omit?: PostOmit | null + omit?: PostReactionOmit | null /** * Choose, which related nodes to fetch as well */ - include?: PostInclude | null + include?: PostReactionInclude | null /** - * The filter to search for the Post to update in case it exists. + * The filter to search for the PostReaction to update in case it exists. */ - where: PostWhereUniqueInput + where: PostReactionWhereUniqueInput /** - * In case the Post found by the `where` argument doesn't exist, create a new Post with this data. + * In case the PostReaction found by the `where` argument doesn't exist, create a new PostReaction with this data. */ - create: XOR + create: XOR /** - * In case the Post was found with the provided `where` argument, update it with this data. + * In case the PostReaction was found with the provided `where` argument, update it with this data. */ - update: XOR + update: XOR } /** - * Post delete + * PostReaction delete */ - export type PostDeleteArgs = { + export type PostReactionDeleteArgs = { /** - * Select specific fields to fetch from the Post + * Select specific fields to fetch from the PostReaction */ - select?: PostSelect | null + select?: PostReactionSelect | null /** - * Omit specific fields from the Post + * Omit specific fields from the PostReaction */ - omit?: PostOmit | null + omit?: PostReactionOmit | null /** * Choose, which related nodes to fetch as well */ - include?: PostInclude | null + include?: PostReactionInclude | null /** - * Filter which Post to delete. + * Filter which PostReaction to delete. */ - where: PostWhereUniqueInput + where: PostReactionWhereUniqueInput } /** - * Post deleteMany + * PostReaction deleteMany */ - export type PostDeleteManyArgs = { + export type PostReactionDeleteManyArgs = { /** - * Filter which Posts to delete + * Filter which PostReactions to delete */ - where?: PostWhereInput + where?: PostReactionWhereInput /** - * Limit how many Posts to delete. + * Limit how many PostReactions to delete. */ limit?: number } /** - * Post without action + * PostReaction without action */ - export type PostDefaultArgs = { + export type PostReactionDefaultArgs = { /** - * Select specific fields to fetch from the Post + * Select specific fields to fetch from the PostReaction */ - select?: PostSelect | null + select?: PostReactionSelect | null /** - * Omit specific fields from the Post + * Omit specific fields from the PostReaction */ - omit?: PostOmit | null + omit?: PostReactionOmit | null /** * Choose, which related nodes to fetch as well */ - include?: PostInclude | null + include?: PostReactionInclude | null } @@ -9306,6 +10538,17 @@ export namespace Prisma { export type PostScalarFieldEnum = (typeof PostScalarFieldEnum)[keyof typeof PostScalarFieldEnum] + export const PostReactionScalarFieldEnum: { + postId: 'postId', + memberId: 'memberId', + reaction: 'reaction', + createdAt: 'createdAt', + updatedAt: 'updatedAt' + }; + + export type PostReactionScalarFieldEnum = (typeof PostReactionScalarFieldEnum)[keyof typeof PostReactionScalarFieldEnum] + + export const TopicClosureScalarFieldEnum: { ancestorTopicId: 'ancestorTopicId', descendantTopicId: 'descendantTopicId', @@ -9398,6 +10641,20 @@ export namespace Prisma { + /** + * Reference to a field of type 'PostReactionType' + */ + export type EnumPostReactionTypeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'PostReactionType'> + + + + /** + * Reference to a field of type 'PostReactionType[]' + */ + export type ListEnumPostReactionTypeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'PostReactionType[]'> + + + /** * Reference to a field of type 'Int' */ @@ -9682,6 +10939,7 @@ export namespace Prisma { deletedAt?: DateTimeNullableFilter<"Post"> | Date | string | null deletedByMemberId?: StringNullableFilter<"Post"> | string | null topic?: XOR + reactions?: PostReactionListRelationFilter } export type PostOrderByWithRelationInput = { @@ -9697,6 +10955,7 @@ export namespace Prisma { deletedAt?: SortOrderInput | SortOrder deletedByMemberId?: SortOrderInput | SortOrder topic?: TopicOrderByWithRelationInput + reactions?: PostReactionOrderByRelationAggregateInput } export type PostWhereUniqueInput = Prisma.AtLeast<{ @@ -9715,6 +10974,7 @@ export namespace Prisma { deletedAt?: DateTimeNullableFilter<"Post"> | Date | string | null deletedByMemberId?: StringNullableFilter<"Post"> | string | null topic?: XOR + reactions?: PostReactionListRelationFilter }, "id"> export type PostOrderByWithAggregationInput = { @@ -9751,6 +11011,62 @@ export namespace Prisma { deletedByMemberId?: StringNullableWithAggregatesFilter<"Post"> | string | null } + export type PostReactionWhereInput = { + AND?: PostReactionWhereInput | PostReactionWhereInput[] + OR?: PostReactionWhereInput[] + NOT?: PostReactionWhereInput | PostReactionWhereInput[] + postId?: StringFilter<"PostReaction"> | string + memberId?: StringFilter<"PostReaction"> | string + reaction?: EnumPostReactionTypeFilter<"PostReaction"> | $Enums.PostReactionType + createdAt?: DateTimeFilter<"PostReaction"> | Date | string + updatedAt?: DateTimeFilter<"PostReaction"> | Date | string + post?: XOR + } + + export type PostReactionOrderByWithRelationInput = { + postId?: SortOrder + memberId?: SortOrder + reaction?: SortOrder + createdAt?: SortOrder + updatedAt?: SortOrder + post?: PostOrderByWithRelationInput + } + + export type PostReactionWhereUniqueInput = Prisma.AtLeast<{ + postId_memberId?: PostReactionPostIdMemberIdCompoundUniqueInput + AND?: PostReactionWhereInput | PostReactionWhereInput[] + OR?: PostReactionWhereInput[] + NOT?: PostReactionWhereInput | PostReactionWhereInput[] + postId?: StringFilter<"PostReaction"> | string + memberId?: StringFilter<"PostReaction"> | string + reaction?: EnumPostReactionTypeFilter<"PostReaction"> | $Enums.PostReactionType + createdAt?: DateTimeFilter<"PostReaction"> | Date | string + updatedAt?: DateTimeFilter<"PostReaction"> | Date | string + post?: XOR + }, "postId_memberId"> + + export type PostReactionOrderByWithAggregationInput = { + postId?: SortOrder + memberId?: SortOrder + reaction?: SortOrder + createdAt?: SortOrder + updatedAt?: SortOrder + _count?: PostReactionCountOrderByAggregateInput + _max?: PostReactionMaxOrderByAggregateInput + _min?: PostReactionMinOrderByAggregateInput + } + + export type PostReactionScalarWhereWithAggregatesInput = { + AND?: PostReactionScalarWhereWithAggregatesInput | PostReactionScalarWhereWithAggregatesInput[] + OR?: PostReactionScalarWhereWithAggregatesInput[] + NOT?: PostReactionScalarWhereWithAggregatesInput | PostReactionScalarWhereWithAggregatesInput[] + postId?: StringWithAggregatesFilter<"PostReaction"> | string + memberId?: StringWithAggregatesFilter<"PostReaction"> | string + reaction?: EnumPostReactionTypeWithAggregatesFilter<"PostReaction"> | $Enums.PostReactionType + createdAt?: DateTimeWithAggregatesFilter<"PostReaction"> | Date | string + updatedAt?: DateTimeWithAggregatesFilter<"PostReaction"> | Date | string + } + export type TopicClosureWhereInput = { AND?: TopicClosureWhereInput | TopicClosureWhereInput[] OR?: TopicClosureWhereInput[] @@ -10186,6 +11502,7 @@ export namespace Prisma { deletedAt?: Date | string | null deletedByMemberId?: string | null topic: TopicCreateNestedOneWithoutPostsInput + reactions?: PostReactionCreateNestedManyWithoutPostInput } export type PostUncheckedCreateInput = { @@ -10200,6 +11517,7 @@ export namespace Prisma { updatedAt?: Date | string deletedAt?: Date | string | null deletedByMemberId?: string | null + reactions?: PostReactionUncheckedCreateNestedManyWithoutPostInput } export type PostUpdateInput = { @@ -10214,6 +11532,7 @@ export namespace Prisma { deletedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null deletedByMemberId?: NullableStringFieldUpdateOperationsInput | string | null topic?: TopicUpdateOneRequiredWithoutPostsNestedInput + reactions?: PostReactionUpdateManyWithoutPostNestedInput } export type PostUncheckedUpdateInput = { @@ -10228,6 +11547,7 @@ export namespace Prisma { updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string deletedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null deletedByMemberId?: NullableStringFieldUpdateOperationsInput | string | null + reactions?: PostReactionUncheckedUpdateManyWithoutPostNestedInput } export type PostCreateManyInput = { @@ -10271,6 +11591,61 @@ export namespace Prisma { deletedByMemberId?: NullableStringFieldUpdateOperationsInput | string | null } + export type PostReactionCreateInput = { + memberId: string + reaction: $Enums.PostReactionType + createdAt?: Date | string + updatedAt?: Date | string + post: PostCreateNestedOneWithoutReactionsInput + } + + export type PostReactionUncheckedCreateInput = { + postId: string + memberId: string + reaction: $Enums.PostReactionType + createdAt?: Date | string + updatedAt?: Date | string + } + + export type PostReactionUpdateInput = { + memberId?: StringFieldUpdateOperationsInput | string + reaction?: EnumPostReactionTypeFieldUpdateOperationsInput | $Enums.PostReactionType + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + post?: PostUpdateOneRequiredWithoutReactionsNestedInput + } + + export type PostReactionUncheckedUpdateInput = { + postId?: StringFieldUpdateOperationsInput | string + memberId?: StringFieldUpdateOperationsInput | string + reaction?: EnumPostReactionTypeFieldUpdateOperationsInput | $Enums.PostReactionType + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + } + + export type PostReactionCreateManyInput = { + postId: string + memberId: string + reaction: $Enums.PostReactionType + createdAt?: Date | string + updatedAt?: Date | string + } + + export type PostReactionUpdateManyMutationInput = { + memberId?: StringFieldUpdateOperationsInput | string + reaction?: EnumPostReactionTypeFieldUpdateOperationsInput | $Enums.PostReactionType + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + } + + export type PostReactionUncheckedUpdateManyInput = { + postId?: StringFieldUpdateOperationsInput | string + memberId?: StringFieldUpdateOperationsInput | string + reaction?: EnumPostReactionTypeFieldUpdateOperationsInput | $Enums.PostReactionType + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + } + export type TopicClosureCreateInput = { depth: number ancestorTopic: TopicCreateNestedOneWithoutAncestorClosuresInput @@ -10702,6 +12077,16 @@ export namespace Prisma { isNot?: TopicWhereInput } + export type PostReactionListRelationFilter = { + every?: PostReactionWhereInput + some?: PostReactionWhereInput + none?: PostReactionWhereInput + } + + export type PostReactionOrderByRelationAggregateInput = { + _count?: SortOrder + } + export type PostCountOrderByAggregateInput = { id?: SortOrder topicId?: SortOrder @@ -10744,6 +12129,57 @@ export namespace Prisma { deletedByMemberId?: SortOrder } + export type EnumPostReactionTypeFilter<$PrismaModel = never> = { + equals?: $Enums.PostReactionType | EnumPostReactionTypeFieldRefInput<$PrismaModel> + in?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + not?: NestedEnumPostReactionTypeFilter<$PrismaModel> | $Enums.PostReactionType + } + + export type PostScalarRelationFilter = { + is?: PostWhereInput + isNot?: PostWhereInput + } + + export type PostReactionPostIdMemberIdCompoundUniqueInput = { + postId: string + memberId: string + } + + export type PostReactionCountOrderByAggregateInput = { + postId?: SortOrder + memberId?: SortOrder + reaction?: SortOrder + createdAt?: SortOrder + updatedAt?: SortOrder + } + + export type PostReactionMaxOrderByAggregateInput = { + postId?: SortOrder + memberId?: SortOrder + reaction?: SortOrder + createdAt?: SortOrder + updatedAt?: SortOrder + } + + export type PostReactionMinOrderByAggregateInput = { + postId?: SortOrder + memberId?: SortOrder + reaction?: SortOrder + createdAt?: SortOrder + updatedAt?: SortOrder + } + + export type EnumPostReactionTypeWithAggregatesFilter<$PrismaModel = never> = { + equals?: $Enums.PostReactionType | EnumPostReactionTypeFieldRefInput<$PrismaModel> + in?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + not?: NestedEnumPostReactionTypeWithAggregatesFilter<$PrismaModel> | $Enums.PostReactionType + _count?: NestedIntFilter<$PrismaModel> + _min?: NestedEnumPostReactionTypeFilter<$PrismaModel> + _max?: NestedEnumPostReactionTypeFilter<$PrismaModel> + } + export type IntFilter<$PrismaModel = never> = { equals?: number | IntFieldRefInput<$PrismaModel> in?: number[] | ListIntFieldRefInput<$PrismaModel> @@ -11145,6 +12581,20 @@ export namespace Prisma { connect?: TopicWhereUniqueInput } + export type PostReactionCreateNestedManyWithoutPostInput = { + create?: XOR | PostReactionCreateWithoutPostInput[] | PostReactionUncheckedCreateWithoutPostInput[] + connectOrCreate?: PostReactionCreateOrConnectWithoutPostInput | PostReactionCreateOrConnectWithoutPostInput[] + createMany?: PostReactionCreateManyPostInputEnvelope + connect?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + } + + export type PostReactionUncheckedCreateNestedManyWithoutPostInput = { + create?: XOR | PostReactionCreateWithoutPostInput[] | PostReactionUncheckedCreateWithoutPostInput[] + connectOrCreate?: PostReactionCreateOrConnectWithoutPostInput | PostReactionCreateOrConnectWithoutPostInput[] + createMany?: PostReactionCreateManyPostInputEnvelope + connect?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + } + export type TopicUpdateOneRequiredWithoutPostsNestedInput = { create?: XOR connectOrCreate?: TopicCreateOrConnectWithoutPostsInput @@ -11153,6 +12603,52 @@ export namespace Prisma { update?: XOR, TopicUncheckedUpdateWithoutPostsInput> } + export type PostReactionUpdateManyWithoutPostNestedInput = { + create?: XOR | PostReactionCreateWithoutPostInput[] | PostReactionUncheckedCreateWithoutPostInput[] + connectOrCreate?: PostReactionCreateOrConnectWithoutPostInput | PostReactionCreateOrConnectWithoutPostInput[] + upsert?: PostReactionUpsertWithWhereUniqueWithoutPostInput | PostReactionUpsertWithWhereUniqueWithoutPostInput[] + createMany?: PostReactionCreateManyPostInputEnvelope + set?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + disconnect?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + delete?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + connect?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + update?: PostReactionUpdateWithWhereUniqueWithoutPostInput | PostReactionUpdateWithWhereUniqueWithoutPostInput[] + updateMany?: PostReactionUpdateManyWithWhereWithoutPostInput | PostReactionUpdateManyWithWhereWithoutPostInput[] + deleteMany?: PostReactionScalarWhereInput | PostReactionScalarWhereInput[] + } + + export type PostReactionUncheckedUpdateManyWithoutPostNestedInput = { + create?: XOR | PostReactionCreateWithoutPostInput[] | PostReactionUncheckedCreateWithoutPostInput[] + connectOrCreate?: PostReactionCreateOrConnectWithoutPostInput | PostReactionCreateOrConnectWithoutPostInput[] + upsert?: PostReactionUpsertWithWhereUniqueWithoutPostInput | PostReactionUpsertWithWhereUniqueWithoutPostInput[] + createMany?: PostReactionCreateManyPostInputEnvelope + set?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + disconnect?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + delete?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + connect?: PostReactionWhereUniqueInput | PostReactionWhereUniqueInput[] + update?: PostReactionUpdateWithWhereUniqueWithoutPostInput | PostReactionUpdateWithWhereUniqueWithoutPostInput[] + updateMany?: PostReactionUpdateManyWithWhereWithoutPostInput | PostReactionUpdateManyWithWhereWithoutPostInput[] + deleteMany?: PostReactionScalarWhereInput | PostReactionScalarWhereInput[] + } + + export type PostCreateNestedOneWithoutReactionsInput = { + create?: XOR + connectOrCreate?: PostCreateOrConnectWithoutReactionsInput + connect?: PostWhereUniqueInput + } + + export type EnumPostReactionTypeFieldUpdateOperationsInput = { + set?: $Enums.PostReactionType + } + + export type PostUpdateOneRequiredWithoutReactionsNestedInput = { + create?: XOR + connectOrCreate?: PostCreateOrConnectWithoutReactionsInput + upsert?: PostUpsertWithoutReactionsInput + connect?: PostWhereUniqueInput + update?: XOR, PostUncheckedUpdateWithoutReactionsInput> + } + export type TopicCreateNestedOneWithoutAncestorClosuresInput = { create?: XOR connectOrCreate?: TopicCreateOrConnectWithoutAncestorClosuresInput @@ -11364,6 +12860,23 @@ export namespace Prisma { _max?: NestedDateTimeFilter<$PrismaModel> } + export type NestedEnumPostReactionTypeFilter<$PrismaModel = never> = { + equals?: $Enums.PostReactionType | EnumPostReactionTypeFieldRefInput<$PrismaModel> + in?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + not?: NestedEnumPostReactionTypeFilter<$PrismaModel> | $Enums.PostReactionType + } + + export type NestedEnumPostReactionTypeWithAggregatesFilter<$PrismaModel = never> = { + equals?: $Enums.PostReactionType | EnumPostReactionTypeFieldRefInput<$PrismaModel> + in?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + notIn?: $Enums.PostReactionType[] | ListEnumPostReactionTypeFieldRefInput<$PrismaModel> + not?: NestedEnumPostReactionTypeWithAggregatesFilter<$PrismaModel> | $Enums.PostReactionType + _count?: NestedIntFilter<$PrismaModel> + _min?: NestedEnumPostReactionTypeFilter<$PrismaModel> + _max?: NestedEnumPostReactionTypeFilter<$PrismaModel> + } + export type NestedIntWithAggregatesFilter<$PrismaModel = never> = { equals?: number | IntFieldRefInput<$PrismaModel> in?: number[] | ListIntFieldRefInput<$PrismaModel> @@ -11509,6 +13022,7 @@ export namespace Prisma { updatedAt?: Date | string deletedAt?: Date | string | null deletedByMemberId?: string | null + reactions?: PostReactionCreateNestedManyWithoutPostInput } export type PostUncheckedCreateWithoutTopicInput = { @@ -11522,6 +13036,7 @@ export namespace Prisma { updatedAt?: Date | string deletedAt?: Date | string | null deletedByMemberId?: string | null + reactions?: PostReactionUncheckedCreateNestedManyWithoutPostInput } export type PostCreateOrConnectWithoutTopicInput = { @@ -11886,6 +13401,30 @@ export namespace Prisma { create: XOR } + export type PostReactionCreateWithoutPostInput = { + memberId: string + reaction: $Enums.PostReactionType + createdAt?: Date | string + updatedAt?: Date | string + } + + export type PostReactionUncheckedCreateWithoutPostInput = { + memberId: string + reaction: $Enums.PostReactionType + createdAt?: Date | string + updatedAt?: Date | string + } + + export type PostReactionCreateOrConnectWithoutPostInput = { + where: PostReactionWhereUniqueInput + create: XOR + } + + export type PostReactionCreateManyPostInputEnvelope = { + data: PostReactionCreateManyPostInput | PostReactionCreateManyPostInput[] + skipDuplicates?: boolean + } + export type TopicUpsertWithoutPostsInput = { update: XOR create: XOR @@ -11943,6 +13482,105 @@ export namespace Prisma { readStates?: TopicReadStateUncheckedUpdateManyWithoutTopicNestedInput } + export type PostReactionUpsertWithWhereUniqueWithoutPostInput = { + where: PostReactionWhereUniqueInput + update: XOR + create: XOR + } + + export type PostReactionUpdateWithWhereUniqueWithoutPostInput = { + where: PostReactionWhereUniqueInput + data: XOR + } + + export type PostReactionUpdateManyWithWhereWithoutPostInput = { + where: PostReactionScalarWhereInput + data: XOR + } + + export type PostReactionScalarWhereInput = { + AND?: PostReactionScalarWhereInput | PostReactionScalarWhereInput[] + OR?: PostReactionScalarWhereInput[] + NOT?: PostReactionScalarWhereInput | PostReactionScalarWhereInput[] + postId?: StringFilter<"PostReaction"> | string + memberId?: StringFilter<"PostReaction"> | string + reaction?: EnumPostReactionTypeFilter<"PostReaction"> | $Enums.PostReactionType + createdAt?: DateTimeFilter<"PostReaction"> | Date | string + updatedAt?: DateTimeFilter<"PostReaction"> | Date | string + } + + export type PostCreateWithoutReactionsInput = { + id?: string + parentType: string + parentId: string + authorMemberId: string + authorHandle: string + content?: string | null + createdAt?: Date | string + updatedAt?: Date | string + deletedAt?: Date | string | null + deletedByMemberId?: string | null + topic: TopicCreateNestedOneWithoutPostsInput + } + + export type PostUncheckedCreateWithoutReactionsInput = { + id?: string + topicId: string + parentType: string + parentId: string + authorMemberId: string + authorHandle: string + content?: string | null + createdAt?: Date | string + updatedAt?: Date | string + deletedAt?: Date | string | null + deletedByMemberId?: string | null + } + + export type PostCreateOrConnectWithoutReactionsInput = { + where: PostWhereUniqueInput + create: XOR + } + + export type PostUpsertWithoutReactionsInput = { + update: XOR + create: XOR + where?: PostWhereInput + } + + export type PostUpdateToOneWithWhereWithoutReactionsInput = { + where?: PostWhereInput + data: XOR + } + + export type PostUpdateWithoutReactionsInput = { + id?: StringFieldUpdateOperationsInput | string + parentType?: StringFieldUpdateOperationsInput | string + parentId?: StringFieldUpdateOperationsInput | string + authorMemberId?: StringFieldUpdateOperationsInput | string + authorHandle?: StringFieldUpdateOperationsInput | string + content?: NullableStringFieldUpdateOperationsInput | string | null + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + deletedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null + deletedByMemberId?: NullableStringFieldUpdateOperationsInput | string | null + topic?: TopicUpdateOneRequiredWithoutPostsNestedInput + } + + export type PostUncheckedUpdateWithoutReactionsInput = { + id?: StringFieldUpdateOperationsInput | string + topicId?: StringFieldUpdateOperationsInput | string + parentType?: StringFieldUpdateOperationsInput | string + parentId?: StringFieldUpdateOperationsInput | string + authorMemberId?: StringFieldUpdateOperationsInput | string + authorHandle?: StringFieldUpdateOperationsInput | string + content?: NullableStringFieldUpdateOperationsInput | string | null + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + deletedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null + deletedByMemberId?: NullableStringFieldUpdateOperationsInput | string | null + } + export type TopicCreateWithoutAncestorClosuresInput = { id?: string challengeId?: string | null @@ -12500,6 +14138,7 @@ export namespace Prisma { updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string deletedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null deletedByMemberId?: NullableStringFieldUpdateOperationsInput | string | null + reactions?: PostReactionUpdateManyWithoutPostNestedInput } export type PostUncheckedUpdateWithoutTopicInput = { @@ -12513,6 +14152,7 @@ export namespace Prisma { updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string deletedAt?: NullableDateTimeFieldUpdateOperationsInput | Date | string | null deletedByMemberId?: NullableStringFieldUpdateOperationsInput | string | null + reactions?: PostReactionUncheckedUpdateManyWithoutPostNestedInput } export type PostUncheckedUpdateManyWithoutTopicInput = { @@ -12591,6 +14231,34 @@ export namespace Prisma { updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string } + export type PostReactionCreateManyPostInput = { + memberId: string + reaction: $Enums.PostReactionType + createdAt?: Date | string + updatedAt?: Date | string + } + + export type PostReactionUpdateWithoutPostInput = { + memberId?: StringFieldUpdateOperationsInput | string + reaction?: EnumPostReactionTypeFieldUpdateOperationsInput | $Enums.PostReactionType + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + } + + export type PostReactionUncheckedUpdateWithoutPostInput = { + memberId?: StringFieldUpdateOperationsInput | string + reaction?: EnumPostReactionTypeFieldUpdateOperationsInput | $Enums.PostReactionType + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + } + + export type PostReactionUncheckedUpdateManyWithoutPostInput = { + memberId?: StringFieldUpdateOperationsInput | string + reaction?: EnumPostReactionTypeFieldUpdateOperationsInput | $Enums.PostReactionType + createdAt?: DateTimeFieldUpdateOperationsInput | Date | string + updatedAt?: DateTimeFieldUpdateOperationsInput | Date | string + } + /** diff --git a/packages/forums-prisma-client/index.js b/packages/forums-prisma-client/index.js index 135464a..73cb996 100644 --- a/packages/forums-prisma-client/index.js +++ b/packages/forums-prisma-client/index.js @@ -143,6 +143,14 @@ exports.Prisma.PostScalarFieldEnum = { deletedByMemberId: 'deletedByMemberId' }; +exports.Prisma.PostReactionScalarFieldEnum = { + postId: 'postId', + memberId: 'memberId', + reaction: 'reaction', + createdAt: 'createdAt', + updatedAt: 'updatedAt' +}; + exports.Prisma.TopicClosureScalarFieldEnum = { ancestorTopicId: 'ancestorTopicId', descendantTopicId: 'descendantTopicId', @@ -176,13 +184,17 @@ exports.Prisma.NullsOrder = { first: 'first', last: 'last' }; - +exports.PostReactionType = exports.$Enums.PostReactionType = { + THUMBS_UP: 'THUMBS_UP', + THUMBS_DOWN: 'THUMBS_DOWN' +}; exports.Prisma.ModelName = { Topic: 'Topic', MemberBan: 'MemberBan', IpBan: 'IpBan', Post: 'Post', + PostReaction: 'PostReaction', TopicClosure: 'TopicClosure', TopicWatch: 'TopicWatch', TopicReadState: 'TopicReadState' @@ -195,14 +207,14 @@ const config = { "clientVersion": "7.9.0", "engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad", "activeProvider": "postgresql", - "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"./generated/client\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/forums-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n schemas = [\"forums\"]\n}\n\nmodel Topic {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n parentTopicId String? @db.VarChar(14)\n challengeId String? @db.VarChar(64)\n roleName String? @db.VarChar(128)\n title String @db.VarChar(255)\n isAnnouncement Boolean @default(false)\n locked Boolean @default(false)\n lockedAt DateTime?\n lockedByMemberId String? @db.VarChar(64)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n parentTopic Topic? @relation(\"TopicChildren\", fields: [parentTopicId], references: [id])\n childTopics Topic[] @relation(\"TopicChildren\")\n posts Post[]\n ancestorClosures TopicClosure[] @relation(\"TopicClosureAncestor\")\n descendantClosures TopicClosure[] @relation(\"TopicClosureDescendant\")\n watches TopicWatch[]\n readStates TopicReadState[]\n\n @@index([parentTopicId])\n @@index([challengeId])\n @@index([roleName])\n @@index([deletedAt])\n @@index([isAnnouncement, createdAt])\n @@schema(\"forums\")\n}\n\nmodel MemberBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([memberId])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel IpBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n ipAddress String @db.VarChar(45)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([ipAddress])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel Post {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n topicId String @db.VarChar(14)\n parentType String @db.VarChar(16)\n parentId String @db.VarChar(14)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n content String? @db.Text\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@index([topicId, createdAt])\n @@index([topicId, parentType, parentId])\n @@index([deletedAt])\n @@schema(\"forums\")\n}\n\nmodel TopicClosure {\n ancestorTopicId String @db.VarChar(14)\n descendantTopicId String @db.VarChar(14)\n depth Int\n\n ancestorTopic Topic @relation(\"TopicClosureAncestor\", fields: [ancestorTopicId], references: [id], onDelete: Cascade)\n descendantTopic Topic @relation(\"TopicClosureDescendant\", fields: [descendantTopicId], references: [id], onDelete: Cascade)\n\n @@id([ancestorTopicId, descendantTopicId])\n @@index([ancestorTopicId, depth])\n @@index([descendantTopicId, depth])\n @@schema(\"forums\")\n}\n\nmodel TopicWatch {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@schema(\"forums\")\n}\n\nmodel TopicReadState {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n lastReadAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@index([lastReadAt])\n @@schema(\"forums\")\n}\n" + "inlineSchema": "generator client {\n provider = \"prisma-client-js\"\n output = \"./generated/client\"\n}\n\ngenerator externalClient {\n provider = \"prisma-client-js\"\n output = \"../packages/forums-prisma-client\"\n binaryTargets = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n provider = \"postgresql\"\n schemas = [\"forums\"]\n}\n\nenum PostReactionType {\n THUMBS_UP\n THUMBS_DOWN\n\n @@schema(\"forums\")\n}\n\nmodel Topic {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n parentTopicId String? @db.VarChar(14)\n challengeId String? @db.VarChar(64)\n roleName String? @db.VarChar(128)\n title String @db.VarChar(255)\n isAnnouncement Boolean @default(false)\n locked Boolean @default(false)\n lockedAt DateTime?\n lockedByMemberId String? @db.VarChar(64)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n parentTopic Topic? @relation(\"TopicChildren\", fields: [parentTopicId], references: [id])\n childTopics Topic[] @relation(\"TopicChildren\")\n posts Post[]\n ancestorClosures TopicClosure[] @relation(\"TopicClosureAncestor\")\n descendantClosures TopicClosure[] @relation(\"TopicClosureDescendant\")\n watches TopicWatch[]\n readStates TopicReadState[]\n\n @@index([parentTopicId])\n @@index([challengeId])\n @@index([roleName])\n @@index([deletedAt])\n @@index([isAnnouncement, createdAt])\n @@schema(\"forums\")\n}\n\nmodel MemberBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([memberId])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel IpBan {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n ipAddress String @db.VarChar(45)\n createdAt DateTime @default(now())\n createdByMemberId String? @db.VarChar(64)\n removedAt DateTime?\n removedByMemberId String? @db.VarChar(64)\n\n @@index([ipAddress])\n @@index([removedAt])\n @@schema(\"forums\")\n}\n\nmodel Post {\n id String @id @default(dbgenerated(\"nanoid()\")) @db.VarChar(14)\n topicId String @db.VarChar(14)\n parentType String @db.VarChar(16)\n parentId String @db.VarChar(14)\n authorMemberId String @db.VarChar(64)\n authorHandle String @db.VarChar(128)\n content String? @db.Text\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n deletedAt DateTime?\n deletedByMemberId String? @db.VarChar(64)\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n reactions PostReaction[]\n\n @@index([topicId, createdAt])\n @@index([topicId, parentType, parentId])\n @@index([deletedAt])\n @@schema(\"forums\")\n}\n\nmodel PostReaction {\n postId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n reaction PostReactionType\n createdAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n\n post Post @relation(fields: [postId], references: [id], onDelete: Cascade)\n\n @@id([postId, memberId])\n @@index([memberId])\n @@index([postId, reaction])\n @@schema(\"forums\")\n}\n\nmodel TopicClosure {\n ancestorTopicId String @db.VarChar(14)\n descendantTopicId String @db.VarChar(14)\n depth Int\n\n ancestorTopic Topic @relation(\"TopicClosureAncestor\", fields: [ancestorTopicId], references: [id], onDelete: Cascade)\n descendantTopic Topic @relation(\"TopicClosureDescendant\", fields: [descendantTopicId], references: [id], onDelete: Cascade)\n\n @@id([ancestorTopicId, descendantTopicId])\n @@index([ancestorTopicId, depth])\n @@index([descendantTopicId, depth])\n @@schema(\"forums\")\n}\n\nmodel TopicWatch {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n createdAt DateTime @default(now())\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@schema(\"forums\")\n}\n\nmodel TopicReadState {\n topicId String @db.VarChar(14)\n memberId String @db.VarChar(64)\n lastReadAt DateTime @default(now())\n updatedAt DateTime @default(now()) @updatedAt\n\n topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade)\n\n @@id([topicId, memberId])\n @@index([memberId])\n @@index([lastReadAt])\n @@schema(\"forums\")\n}\n" } -config.runtimeDataModel = JSON.parse("{\"models\":{\"Topic\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isAnnouncement\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"locked\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lockedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"lockedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"childTopics\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"posts\",\"kind\":\"object\",\"type\":\"Post\",\"relationName\":\"PostToTopic\"},{\"name\":\"ancestorClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureDescendant\"},{\"name\":\"watches\",\"kind\":\"object\",\"type\":\"TopicWatch\",\"relationName\":\"TopicToTopicWatch\"},{\"name\":\"readStates\",\"kind\":\"object\",\"type\":\"TopicReadState\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null},\"MemberBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"IpBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Post\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"PostToTopic\"}],\"dbName\":null},\"TopicClosure\":{\"fields\":[{\"name\":\"ancestorTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"descendantTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"depth\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ancestorTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureDescendant\"}],\"dbName\":null},\"TopicWatch\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicWatch\"}],\"dbName\":null},\"TopicReadState\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastReadAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") +config.runtimeDataModel = JSON.parse("{\"models\":{\"Topic\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"challengeId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"roleName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"title\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"isAnnouncement\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"locked\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"lockedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"lockedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"childTopics\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicChildren\"},{\"name\":\"posts\",\"kind\":\"object\",\"type\":\"Post\",\"relationName\":\"PostToTopic\"},{\"name\":\"ancestorClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantClosures\",\"kind\":\"object\",\"type\":\"TopicClosure\",\"relationName\":\"TopicClosureDescendant\"},{\"name\":\"watches\",\"kind\":\"object\",\"type\":\"TopicWatch\",\"relationName\":\"TopicToTopicWatch\"},{\"name\":\"readStates\",\"kind\":\"object\",\"type\":\"TopicReadState\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null},\"MemberBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"IpBan\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ipAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"removedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"removedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Post\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"parentId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"authorHandle\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"content\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"deletedByMemberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"PostToTopic\"},{\"name\":\"reactions\",\"kind\":\"object\",\"type\":\"PostReaction\",\"relationName\":\"PostToPostReaction\"}],\"dbName\":null},\"PostReaction\":{\"fields\":[{\"name\":\"postId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"reaction\",\"kind\":\"enum\",\"type\":\"PostReactionType\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"post\",\"kind\":\"object\",\"type\":\"Post\",\"relationName\":\"PostToPostReaction\"}],\"dbName\":null},\"TopicClosure\":{\"fields\":[{\"name\":\"ancestorTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"descendantTopicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"depth\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"ancestorTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureAncestor\"},{\"name\":\"descendantTopic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicClosureDescendant\"}],\"dbName\":null},\"TopicWatch\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicWatch\"}],\"dbName\":null},\"TopicReadState\":{\"fields\":[{\"name\":\"topicId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"memberId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"lastReadAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"topic\",\"kind\":\"object\",\"type\":\"Topic\",\"relationName\":\"TopicToTopicReadState\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}") defineDmmfProperty(exports.Prisma, config.runtimeDataModel) config.parameterizationSchema = { - strings: JSON.parse("[\"where\",\"parentTopic\",\"orderBy\",\"cursor\",\"childTopics\",\"topic\",\"posts\",\"ancestorTopic\",\"descendantTopic\",\"ancestorClosures\",\"descendantClosures\",\"watches\",\"readStates\",\"_count\",\"Topic.findUnique\",\"Topic.findUniqueOrThrow\",\"Topic.findFirst\",\"Topic.findFirstOrThrow\",\"Topic.findMany\",\"data\",\"Topic.createOne\",\"Topic.createMany\",\"Topic.createManyAndReturn\",\"Topic.updateOne\",\"Topic.updateMany\",\"Topic.updateManyAndReturn\",\"create\",\"update\",\"Topic.upsertOne\",\"Topic.deleteOne\",\"Topic.deleteMany\",\"having\",\"_min\",\"_max\",\"Topic.groupBy\",\"Topic.aggregate\",\"MemberBan.findUnique\",\"MemberBan.findUniqueOrThrow\",\"MemberBan.findFirst\",\"MemberBan.findFirstOrThrow\",\"MemberBan.findMany\",\"MemberBan.createOne\",\"MemberBan.createMany\",\"MemberBan.createManyAndReturn\",\"MemberBan.updateOne\",\"MemberBan.updateMany\",\"MemberBan.updateManyAndReturn\",\"MemberBan.upsertOne\",\"MemberBan.deleteOne\",\"MemberBan.deleteMany\",\"MemberBan.groupBy\",\"MemberBan.aggregate\",\"IpBan.findUnique\",\"IpBan.findUniqueOrThrow\",\"IpBan.findFirst\",\"IpBan.findFirstOrThrow\",\"IpBan.findMany\",\"IpBan.createOne\",\"IpBan.createMany\",\"IpBan.createManyAndReturn\",\"IpBan.updateOne\",\"IpBan.updateMany\",\"IpBan.updateManyAndReturn\",\"IpBan.upsertOne\",\"IpBan.deleteOne\",\"IpBan.deleteMany\",\"IpBan.groupBy\",\"IpBan.aggregate\",\"Post.findUnique\",\"Post.findUniqueOrThrow\",\"Post.findFirst\",\"Post.findFirstOrThrow\",\"Post.findMany\",\"Post.createOne\",\"Post.createMany\",\"Post.createManyAndReturn\",\"Post.updateOne\",\"Post.updateMany\",\"Post.updateManyAndReturn\",\"Post.upsertOne\",\"Post.deleteOne\",\"Post.deleteMany\",\"Post.groupBy\",\"Post.aggregate\",\"TopicClosure.findUnique\",\"TopicClosure.findUniqueOrThrow\",\"TopicClosure.findFirst\",\"TopicClosure.findFirstOrThrow\",\"TopicClosure.findMany\",\"TopicClosure.createOne\",\"TopicClosure.createMany\",\"TopicClosure.createManyAndReturn\",\"TopicClosure.updateOne\",\"TopicClosure.updateMany\",\"TopicClosure.updateManyAndReturn\",\"TopicClosure.upsertOne\",\"TopicClosure.deleteOne\",\"TopicClosure.deleteMany\",\"_avg\",\"_sum\",\"TopicClosure.groupBy\",\"TopicClosure.aggregate\",\"TopicWatch.findUnique\",\"TopicWatch.findUniqueOrThrow\",\"TopicWatch.findFirst\",\"TopicWatch.findFirstOrThrow\",\"TopicWatch.findMany\",\"TopicWatch.createOne\",\"TopicWatch.createMany\",\"TopicWatch.createManyAndReturn\",\"TopicWatch.updateOne\",\"TopicWatch.updateMany\",\"TopicWatch.updateManyAndReturn\",\"TopicWatch.upsertOne\",\"TopicWatch.deleteOne\",\"TopicWatch.deleteMany\",\"TopicWatch.groupBy\",\"TopicWatch.aggregate\",\"TopicReadState.findUnique\",\"TopicReadState.findUniqueOrThrow\",\"TopicReadState.findFirst\",\"TopicReadState.findFirstOrThrow\",\"TopicReadState.findMany\",\"TopicReadState.createOne\",\"TopicReadState.createMany\",\"TopicReadState.createManyAndReturn\",\"TopicReadState.updateOne\",\"TopicReadState.updateMany\",\"TopicReadState.updateManyAndReturn\",\"TopicReadState.upsertOne\",\"TopicReadState.deleteOne\",\"TopicReadState.deleteMany\",\"TopicReadState.groupBy\",\"TopicReadState.aggregate\",\"AND\",\"OR\",\"NOT\",\"topicId\",\"memberId\",\"lastReadAt\",\"updatedAt\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"not\",\"contains\",\"startsWith\",\"endsWith\",\"createdAt\",\"ancestorTopicId\",\"descendantTopicId\",\"depth\",\"id\",\"parentType\",\"parentId\",\"authorMemberId\",\"authorHandle\",\"content\",\"deletedAt\",\"deletedByMemberId\",\"ipAddress\",\"createdByMemberId\",\"removedAt\",\"removedByMemberId\",\"parentTopicId\",\"challengeId\",\"roleName\",\"title\",\"isAnnouncement\",\"locked\",\"lockedAt\",\"lockedByMemberId\",\"topicId_memberId\",\"ancestorTopicId_descendantTopicId\",\"every\",\"some\",\"none\",\"is\",\"isNot\",\"connectOrCreate\",\"upsert\",\"createMany\",\"set\",\"disconnect\",\"delete\",\"connect\",\"updateMany\",\"deleteMany\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"), - graph: "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_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_AEAIIkBAQAAAAGKAQEAAAABmAFAAAAAAQETAACYAQAgA4kBAQAAAAGKAQEAAAABmAFAAAAAAQETAACaAQAwARMAAJoBADAEBQAA-wEAIIkBAQD0AQAhigEBAPQBACGYAUAA9QEAIQIAAAASACATAACdAQAgA4kBAQD0AQAhigEBAPQBACGYAUAA9QEAIQIAAAAQACATAACfAQAgAgAAABAAIBMAAJ8BACADAAAAEgAgGgAAmAEAIBsAAJ0BACABAAAAEgAgAQAAABAAIAMNAAD4AQAgIAAA-gEAICEAAPkBACAGhgEAAMcBADCHAQAApgEAEIgBAADHAQAwiQEBAMABACGKAQEAwAEAIZgBQADBAQAhAwAAABAAIAIAAKUBADAfAACmAQAgAwAAABAAIAIAABEAMAMAABIAIAEAAAAWACABAAAAFgAgAwAAABQAIAIAABUAMAMAABYAIAMAAAAUACACAAAVADADAAAWACADAAAAFAAgAgAAFQAwAwAAFgAgBQUAAPcBACCJAQEAAAABigEBAAAAAYsBQAAAAAGMAUAAAAABARMAAK4BACAEiQEBAAAAAYoBAQAAAAGLAUAAAAABjAFAAAAAAQETAACwAQAwARMAALABADAFBQAA9gEAIIkBAQD0AQAhigEBAPQBACGLAUAA9QEAIYwBQAD1AQAhAgAAABYAIBMAALMBACAEiQEBAPQBACGKAQEA9AEAIYsBQAD1AQAhjAFAAPUBACECAAAAFAAgEwAAtQEAIAIAAAAUACATAAC1AQAgAwAAABYAIBoAAK4BACAbAACzAQAgAQAAABYAIAEAAAAUACADDQAA8QEAICAAAPMBACAhAADyAQAgB4YBAAC_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_wIAIL0BAAABACADGgAA_QIAILcBAAD-AgAgvQEAAAEAIAAAAAAAAAAAAAG6ASAAAAABBxoAAPICACAbAAD7AgAgtwEAAPMCACC4AQAA-gIAILsBAAADACC8AQAAAwAgvQEAAAEAIAsaAADZAgAwGwAA3gIAMLcBAADaAgAwuAEAANsCADC5AQAA3AIAILoBAADdAgAwuwEAAN0CADC8AQAA3QIAML0BAADdAgAwvgEAAN8CADC_AQAA4AIAMAsaAADNAgAwGwAA0gIAMLcBAADOAgAwuAEAAM8CADC5AQAA0AIAILoBAADRAgAwuwEAANECADC8AQAA0QIAML0BAADRAgAwvgEAANMCADC_AQAA1AIAMAsaAADEAgAwGwAAyAIAMLcBAADFAgAwuAEAAMYCADC5AQAAxwIAILoBAAC8AgAwuwEAALwCADC8AQAAvAIAML0BAAC8AgAwvgEAAMkCADC_AQAAvwIAMAsaAAC4AgAwGwAAvQIAMLcBAAC5AgAwuAEAALoCADC5AQAAuwIAILoBAAC8AgAwuwEAALwCADC8AQAAvAIAML0BAAC8AgAwvgEAAL4CADC_AQAAvwIAMAsaAACsAgAwGwAAsQIAMLcBAACtAgAwuAEAAK4CADC5AQAArwIAILoBAACwAgAwuwEAALACADC8AQAAsAIAML0BAACwAgAwvgEAALICADC_AQAAswIAMAsaAACgAgAwGwAApQIAMLcBAAChAgAwuAEAAKICADC5AQAAowIAILoBAACkAgAwuwEAAKQCADC8AQAApAIAML0BAACkAgAwvgEAAKYCADC_AQAApwIAMAOKAQEAAAABiwFAAAAAAYwBQAAAAAECAAAAFgAgGgAAqwIAIAMAAAAWACAaAACrAgAgGwAAqgIAIAETAAD5AgAwCQUAAOIBACCGAQAA4QEAMIcBAAAUABCIAQAA4QEAMIkBAQDWAQAhigEBANYBACGLAUAA1wEAIYwBQADXAQAhsAEAAOABACACAAAAFgAgEwAAqgIAIAIAAACoAgAgEwAAqQIAIAeGAQAApwIAMIcBAACoAgAQiAEAAKcCADCJAQEA1gEAIYoBAQDWAQAhiwFAANcBACGMAUAA1wEAIQeGAQAApwIAMIcBAACoAgAQiAEAAKcCADCJAQEA1gEAIYoBAQDWAQAhiwFAANcBACGMAUAA1wEAIQOKAQEA9AEAIYsBQAD1AQAhjAFAAPUBACEDigEBAPQBACGLAUAA9QEAIYwBQAD1AQAhA4oBAQAAAAGLAUAAAAABjAFAAAAAAQKKAQEAAAABmAFAAAAAAQIAAAASACAaAAC3AgAgAwAAABIAIBoAALcCACAbAAC2AgAgARMAAPgCADAIBQAA4gEAIIYBAADkAQAwhwEAABAAEIgBAADkAQAwiQEBANYBACGKAQEA1gEAIZgBQADXAQAhsAEAAOMBACACAAAAEgAgEwAAtgIAIAIAAAC0AgAgEwAAtQIAIAaGAQAAswIAMIcBAAC0AgAQiAEAALMCADCJAQEA1gEAIYoBAQDWAQAhmAFAANcBACEGhgEAALMCADCHAQAAtAIAEIgBAACzAgAwiQEBANYBACGKAQEA1gEAIZgBQADXAQAhAooBAQD0AQAhmAFAAPUBACECigEBAPQBACGYAUAA9QEAIQKKAQEAAAABmAFAAAAAAQMHAACFAgAgmQEBAAAAAZsBAgAAAAECAAAADQAgGgAAwwIAIAMAAAANACAaAADDAgAgGwAAwgIAIAETAAD3AgAwCQcAAOIBACAIAADiAQAghgEAAOYBADCHAQAACwAQiAEAAOYBADCZAQEA1gEAIZoBAQDWAQAhmwECAOcBACGxAQAA5QEAIAIAAAANACATAADCAgAgAgAAAMACACATAADBAgAgBoYBAAC_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_AIAIIwBQAD1AQAhmAFAAPUBACGcAQEA9AEAIZ8BAQD0AQAhoAEBAPQBACGiAUAAjAIAIaMBAQCLAgAhqAEBAIsCACGpAQEAiwIAIaoBAQCLAgAhqwEBAPQBACGsASAAmAIAIa0BIACYAgAhrgFAAIwCACGvAQEAiwIAIRUBAACZAgAgBgAAmwIAIAkAAJwCACAKAACdAgAgCwAAngIAIAwAAJ8CACCMAUAA9QEAIZgBQAD1AQAhnAEBAPQBACGfAQEA9AEAIaABAQD0AQAhogFAAIwCACGjAQEAiwIAIagBAQCLAgAhqQEBAIsCACGqAQEAiwIAIasBAQD0AQAhrAEgAJgCACGtASAAmAIAIa4BQACMAgAhrwEBAIsCACEVAQAA6wIAIAQAAOUCACAJAADnAgAgCgAA6AIAIAsAAOkCACAMAADqAgAgjAFAAAAAAZgBQAAAAAGcAQEAAAABnwEBAAAAAaABAQAAAAGiAUAAAAABowEBAAAAAagBAQAAAAGpAQEAAAABqgEBAAAAAasBAQAAAAGsASAAAAABrQEgAAAAAa4BQAAAAAGvAQEAAAABAgAAAAEAIBoAAP0CACADAAAAAwAgGgAA_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-Xxk_YBdAYRdBYgdCZRpDZh5EZwJFaAJGaQJHagJIawJJbQJKbwdLcB9McgJNdAdOdSBPdgJQdwJReAdSeyFTfCVUfQNVfgNWfwNXgAEDWIEBA1mDAQNahQEHW4YBJlyIAQNdigEHXosBJ1-MAQNgjQEDYY4BB2SRAShlkgEuZpMBBGeUAQRolQEEaZYBBGqXAQRrmQEEbJsBB22cAS9ungEEb6ABB3ChATBxogEEcqMBBHOkAQd0pwExdagBNXapAQV3qgEFeKsBBXmsAQV6rQEFe68BBXyxAQd9sgE2frQBBX-2AQeAAbcBN4EBuAEFggG5AQWDAboBB4QBvQE4hQG-ATw" + strings: JSON.parse("[\"where\",\"parentTopic\",\"orderBy\",\"cursor\",\"childTopics\",\"topic\",\"post\",\"reactions\",\"_count\",\"posts\",\"ancestorTopic\",\"descendantTopic\",\"ancestorClosures\",\"descendantClosures\",\"watches\",\"readStates\",\"Topic.findUnique\",\"Topic.findUniqueOrThrow\",\"Topic.findFirst\",\"Topic.findFirstOrThrow\",\"Topic.findMany\",\"data\",\"Topic.createOne\",\"Topic.createMany\",\"Topic.createManyAndReturn\",\"Topic.updateOne\",\"Topic.updateMany\",\"Topic.updateManyAndReturn\",\"create\",\"update\",\"Topic.upsertOne\",\"Topic.deleteOne\",\"Topic.deleteMany\",\"having\",\"_min\",\"_max\",\"Topic.groupBy\",\"Topic.aggregate\",\"MemberBan.findUnique\",\"MemberBan.findUniqueOrThrow\",\"MemberBan.findFirst\",\"MemberBan.findFirstOrThrow\",\"MemberBan.findMany\",\"MemberBan.createOne\",\"MemberBan.createMany\",\"MemberBan.createManyAndReturn\",\"MemberBan.updateOne\",\"MemberBan.updateMany\",\"MemberBan.updateManyAndReturn\",\"MemberBan.upsertOne\",\"MemberBan.deleteOne\",\"MemberBan.deleteMany\",\"MemberBan.groupBy\",\"MemberBan.aggregate\",\"IpBan.findUnique\",\"IpBan.findUniqueOrThrow\",\"IpBan.findFirst\",\"IpBan.findFirstOrThrow\",\"IpBan.findMany\",\"IpBan.createOne\",\"IpBan.createMany\",\"IpBan.createManyAndReturn\",\"IpBan.updateOne\",\"IpBan.updateMany\",\"IpBan.updateManyAndReturn\",\"IpBan.upsertOne\",\"IpBan.deleteOne\",\"IpBan.deleteMany\",\"IpBan.groupBy\",\"IpBan.aggregate\",\"Post.findUnique\",\"Post.findUniqueOrThrow\",\"Post.findFirst\",\"Post.findFirstOrThrow\",\"Post.findMany\",\"Post.createOne\",\"Post.createMany\",\"Post.createManyAndReturn\",\"Post.updateOne\",\"Post.updateMany\",\"Post.updateManyAndReturn\",\"Post.upsertOne\",\"Post.deleteOne\",\"Post.deleteMany\",\"Post.groupBy\",\"Post.aggregate\",\"PostReaction.findUnique\",\"PostReaction.findUniqueOrThrow\",\"PostReaction.findFirst\",\"PostReaction.findFirstOrThrow\",\"PostReaction.findMany\",\"PostReaction.createOne\",\"PostReaction.createMany\",\"PostReaction.createManyAndReturn\",\"PostReaction.updateOne\",\"PostReaction.updateMany\",\"PostReaction.updateManyAndReturn\",\"PostReaction.upsertOne\",\"PostReaction.deleteOne\",\"PostReaction.deleteMany\",\"PostReaction.groupBy\",\"PostReaction.aggregate\",\"TopicClosure.findUnique\",\"TopicClosure.findUniqueOrThrow\",\"TopicClosure.findFirst\",\"TopicClosure.findFirstOrThrow\",\"TopicClosure.findMany\",\"TopicClosure.createOne\",\"TopicClosure.createMany\",\"TopicClosure.createManyAndReturn\",\"TopicClosure.updateOne\",\"TopicClosure.updateMany\",\"TopicClosure.updateManyAndReturn\",\"TopicClosure.upsertOne\",\"TopicClosure.deleteOne\",\"TopicClosure.deleteMany\",\"_avg\",\"_sum\",\"TopicClosure.groupBy\",\"TopicClosure.aggregate\",\"TopicWatch.findUnique\",\"TopicWatch.findUniqueOrThrow\",\"TopicWatch.findFirst\",\"TopicWatch.findFirstOrThrow\",\"TopicWatch.findMany\",\"TopicWatch.createOne\",\"TopicWatch.createMany\",\"TopicWatch.createManyAndReturn\",\"TopicWatch.updateOne\",\"TopicWatch.updateMany\",\"TopicWatch.updateManyAndReturn\",\"TopicWatch.upsertOne\",\"TopicWatch.deleteOne\",\"TopicWatch.deleteMany\",\"TopicWatch.groupBy\",\"TopicWatch.aggregate\",\"TopicReadState.findUnique\",\"TopicReadState.findUniqueOrThrow\",\"TopicReadState.findFirst\",\"TopicReadState.findFirstOrThrow\",\"TopicReadState.findMany\",\"TopicReadState.createOne\",\"TopicReadState.createMany\",\"TopicReadState.createManyAndReturn\",\"TopicReadState.updateOne\",\"TopicReadState.updateMany\",\"TopicReadState.updateManyAndReturn\",\"TopicReadState.upsertOne\",\"TopicReadState.deleteOne\",\"TopicReadState.deleteMany\",\"TopicReadState.groupBy\",\"TopicReadState.aggregate\",\"AND\",\"OR\",\"NOT\",\"topicId\",\"memberId\",\"lastReadAt\",\"updatedAt\",\"equals\",\"in\",\"notIn\",\"lt\",\"lte\",\"gt\",\"gte\",\"not\",\"contains\",\"startsWith\",\"endsWith\",\"createdAt\",\"ancestorTopicId\",\"descendantTopicId\",\"depth\",\"postId\",\"PostReactionType\",\"reaction\",\"id\",\"parentType\",\"parentId\",\"authorMemberId\",\"authorHandle\",\"content\",\"deletedAt\",\"deletedByMemberId\",\"ipAddress\",\"createdByMemberId\",\"removedAt\",\"removedByMemberId\",\"parentTopicId\",\"challengeId\",\"roleName\",\"title\",\"isAnnouncement\",\"locked\",\"lockedAt\",\"lockedByMemberId\",\"topicId_memberId\",\"ancestorTopicId_descendantTopicId\",\"postId_memberId\",\"every\",\"some\",\"none\",\"is\",\"isNot\",\"connectOrCreate\",\"upsert\",\"createMany\",\"set\",\"disconnect\",\"delete\",\"connect\",\"updateMany\",\"deleteMany\",\"increment\",\"decrement\",\"multiply\",\"divide\"]"), + graph: "1QNFgAEZAQAAjwIAIAQAAJACACAJAACRAgAgDAAAkgIAIA0AAJICACAOAACTAgAgDwAAlAIAIJgBAACNAgAwmQEAAAMAEJoBAACNAgAwngFAAPYBACGqAUAA9gEAIbEBAQAAAAG0AQEA9QEAIbUBAQD1AQAhtwFAAPgBACG4AQEA9wEAIb0BAQD3AQAhvgEBAPcBACG_AQEA9wEAIcABAQD1AQAhwQEgAI4CACHCASAAjgIAIcMBQAD4AQAhxAEBAPcBACEBAAAAAQAgGQEAAI8CACAEAACQAgAgCQAAkQIAIAwAAJICACANAACSAgAgDgAAkwIAIA8AAJQCACCYAQAAjQIAMJkBAAADABCaAQAAjQIAMJ4BQAD2AQAhqgFAAPYBACGxAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG3AUAA-AEAIbgBAQD3AQAhvQEBAPcBACG-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_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_ABCaAQAA6wEAMJsBAQDbAQAhngFAANwBACGqAUAA3AEAIbEBAQDbAQAhsgEBANsBACGzAQEA2wEAIbQBAQDbAQAhtQEBANsBACG2AQEA7AEAIbcBQADtAQAhuAEBAOwBACEDAAAABwAgAgAAfgAwIQAAfwAgAwAAAAcAIAIAAAgAMAMAAAkAIAEAAAANACABAAAADQAgAwAAAAsAIAIAAAwAMAMAAA0AIAMAAAALACACAAAMADADAAANACADAAAACwAgAgAADAAwAwAADQAgBgYAALACACCcAQEAAAABngFAAAAAAaoBQAAAAAGuAQEAAAABsAEAAACwAQIBFQAAhwEAIAWcAQEAAAABngFAAAAAAaoBQAAAAAGuAQEAAAABsAEAAACwAQIBFQAAiQEAMAEVAACJAQAwBgYAAK8CACCcAQEAmAIAIZ4BQACZAgAhqgFAAJkCACGuAQEAmAIAIbABAACuArABIgIAAAANACAVAACMAQAgBZwBAQCYAgAhngFAAJkCACGqAUAAmQIAIa4BAQCYAgAhsAEAAK4CsAEiAgAAAAsAIBUAAI4BACACAAAACwAgFQAAjgEAIAMAAAANACAcAACHAQAgHQAAjAEAIAEAAAANACABAAAACwAgAwgAAKsCACAiAACtAgAgIwAArAIAIAiYAQAA5wEAMJkBAACVAQAQmgEAAOcBADCcAQEA2wEAIZ4BQADcAQAhqgFAANwBACGuAQEA2wEAIbABAADoAbABIgMAAAALACACAACUAQAwIQAAlQEAIAMAAAALACACAAAMADADAAANACABAAAAEgAgAQAAABIAIAMAAAAQACACAAARADADAAASACADAAAAEAAgAgAAEQAwAwAAEgAgAwAAABAAIAIAABEAMAMAABIAIAUKAACpAgAgCwAAqgIAIKsBAQAAAAGsAQEAAAABrQECAAAAAQEVAACdAQAgA6sBAQAAAAGsAQEAAAABrQECAAAAAQEVAACfAQAwARUAAJ8BADAFCgAApwIAIAsAAKgCACCrAQEAmAIAIawBAQCYAgAhrQECAKYCACECAAAAEgAgFQAAogEAIAOrAQEAmAIAIawBAQCYAgAhrQECAKYCACECAAAAEAAgFQAApAEAIAIAAAAQACAVAACkAQAgAwAAABIAIBwAAJ0BACAdAACiAQAgAQAAABIAIAEAAAAQ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AAABoAEBAAAABaEBAQAAAAWiAQEAAAABowEBAAAAAaQBAQAAAAGlAQEAAAABpgEBAPIBACGnAQEAAAABqAEBAAAAAakBAQAAAAEJmAEAAPMBADCZAQAAaQAQmgEAAPMBADCqAUAA3AEAIbEBAQDbAQAhuQEBANsBACG6AQEA7AEAIbsBQADtAQAhvAEBAOwBACEJmAEAAPQBADCZAQAAVgAQmgEAAPQBADCqAUAA9gEAIbEBAQD1AQAhuQEBAPUBACG6AQEA9wEAIbsBQAD4AQAhvAEBAPcBACELnwEBAAAAAaABAQAAAAShAQEAAAAEogEBAAAAAaMBAQAAAAGkAQEAAAABpQEBAAAAAaYBAQDhAQAhpwEBAAAAAagBAQAAAAGpAQEAAAABCJ8BQAAAAAGgAUAAAAAEoQFAAAAABKIBQAAAAAGjAUAAAAABpAFAAAAAAaUBQAAAAAGmAUAA3wEAIQufAQEAAAABoAEBAAAABaEBAQAAAAWiAQEAAAABowEBAAAAAaQBAQAAAAGlAQEAAAABpgEBAPIBACGnAQEAAAABqAEBAAAAAakBAQAAAAEInwFAAAAAAaABQAAAAAWhAUAAAAAFogFAAAAAAaMBQAAAAAGkAUAAAAABpQFAAAAAAaYBQADwAQAhCZgBAAD5AQAwmQEAAFAAEJoBAAD5AQAwnAEBANsBACGqAUAA3AEAIbEBAQDbAQAhugEBAOwBACG7AUAA7QEAIbwBAQDsAQAhCZgBAAD6AQAwmQEAAD0AEJoBAAD6AQAwnAEBAPUBACGqAUAA9gEAIbEBAQD1AQAhugEBAPcBACG7AUAA-AEAIbwBAQD3AQAhEpgBAAD7AQAwmQEAADcAEJoBAAD7AQAwngFAANwBACGqAUAA3AEAIbEBAQDbAQAhtAEBANsBACG1AQEA2wEAIbcBQADtAQAhuAEBAOwBACG9AQEA7AEAIb4BAQDsAQAhvwEBAOwBACHAAQEA2wEAIcEBIAD8AQAhwgEgAPwBACHDAUAA7QEAIcQBAQDsAQAhBQgAAN4BACAiAAD-AQAgIwAA_gEAIJ8BIAAAAAGmASAA_QEAIQUIAADeAQAgIgAA_gEAICMAAP4BACCfASAAAAABpgEgAP0BACECnwEgAAAAAaYBIAD-AQAhApsBAQAAAAGcAQEAAAABCAUAAIECACCYAQAAgAIAMJkBAAAZABCaAQAAgAIAMJsBAQD1AQAhnAEBAPUBACGdAUAA9gEAIZ4BQAD2AQAhGwEAAI8CACAEAACQAgAgCQAAkQIAIAwAAJICACANAACSAgAgDgAAkwIAIA8AAJQCACCYAQAAjQIAMJkBAAADABCaAQAAjQIAMJ4BQAD2AQAhqgFAAPYBACGxAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG3AUAA-AEAIbgBAQD3AQAhvQEBAPcBACG-AQEA9wEAIb8BAQD3AQAhwAEBAPUBACHBASAAjgIAIcIBIACOAgAhwwFAAPgBACHEAQEA9wEAIcsBAAADACDMAQAAAwAgApsBAQAAAAGcAQEAAAABBwUAAIECACCYAQAAgwIAMJkBAAAVABCaAQAAgwIAMJsBAQD1AQAhnAEBAPUBACGqAUAA9gEAIQKrAQEAAAABrAEBAAAAAQgKAACBAgAgCwAAgQIAIJgBAACFAgAwmQEAABAAEJoBAACFAgAwqwEBAPUBACGsAQEA9QEAIa0BAgCGAgAhCJ8BAgAAAAGgAQIAAAAEoQECAAAABKIBAgAAAAGjAQIAAAABpAECAAAAAaUBAgAAAAGmAQIA3gEAIQKcAQEAAAABrgEBAAAAAQkGAACKAgAgmAEAAIgCADCZAQAACwAQmgEAAIgCADCcAQEA9QEAIZ4BQAD2AQAhqgFAAPYBACGuAQEA9QEAIbABAACJArABIgSfAQAAALABAqABAAAAsAEIoQEAAACwAQimAQAA6gGwASISBQAAgQIAIAcAAIwCACCYAQAAiwIAMJkBAAAHABCaAQAAiwIAMJsBAQD1AQAhngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhsgEBAPUBACGzAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG2AQEA9wEAIbcBQAD4AQAhuAEBAPcBACHLAQAABwAgzAEAAAcAIBAFAACBAgAgBwAAjAIAIJgBAACLAgAwmQEAAAcAEJoBAACLAgAwmwEBAPUBACGeAUAA9gEAIaoBQAD2AQAhsQEBAPUBACGyAQEA9QEAIbMBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbYBAQD3AQAhtwFAAPgBACG4AQEA9wEAIQPIAQAACwAgyQEAAAsAIMoBAAALACAZAQAAjwIAIAQAAJACACAJAACRAgAgDAAAkgIAIA0AAJICACAOAACTAgAgDwAAlAIAIJgBAACNAgAwmQEAAAMAEJoBAACNAgAwngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbcBQAD4AQAhuAEBAPcBACG9AQEA9wEAIb4BAQD3AQAhvwEBAPcBACHAAQEA9QEAIcEBIACOAgAhwgEgAI4CACHDAUAA-AEAIcQBAQD3AQAhAp8BIAAAAAGmASAA_gEAIRsBAACPAgAgBAAAkAIAIAkAAJECACAMAACSAgAgDQAAkgIAIA4AAJMCACAPAACUAgAgmAEAAI0CADCZAQAAAwAQmgEAAI0CADCeAUAA9gEAIaoBQAD2AQAhsQEBAPUBACG0AQEA9QEAIbUBAQD1AQAhtwFAAPgBACG4AQEA9wEAIb0BAQD3AQAhvgEBAPcBACG_AQEA9wEAIcABAQD1AQAhwQEgAI4CACHCASAAjgIAIcMBQAD4AQAhxAEBAPcBACHLAQAAAwAgzAEAAAMAIAPIAQAAAwAgyQEAAAMAIMoBAAADACADyAEAAAcAIMkBAAAHACDKAQAABwAgA8gBAAAQACDJAQAAEAAgygEAABAAIAPIAQAAFQAgyQEAABUAIMoBAAAVACADyAEAABkAIMkBAAAZACDKAQAAGQAgAAAAAdABAQAAAAEB0AFAAAAAAQUcAADRAwAgHQAA1AMAIM0BAADSAwAgzgEAANMDACDTAQAAAQAgAxwAANEDACDNAQAA0gMAINMBAAABACAAAAAFHAAAzAMAIB0AAM8DACDNAQAAzQMAIM4BAADOAwAg0wEAAAEAIAMcAADMAwAgzQEAAM0DACDTAQAAAQAgAAAAAAAF0AECAAAAAdYBAgAAAAHXAQIAAAAB2AECAAAAAdkBAgAAAAEFHAAAxAMAIB0AAMoDACDNAQAAxQMAIM4BAADJAwAg0wEAAAEAIAUcAADCAwAgHQAAxwMAIM0BAADDAwAgzgEAAMYDACDTAQAAAQAgAxwAAMQDACDNAQAAxQMAINMBAAABACADHAAAwgMAIM0BAADDAwAg0wEAAAEAIAAAAAHQAQAAALABAgUcAAC9AwAgHQAAwAMAIM0BAAC-AwAgzgEAAL8DACDTAQAACQAgAxwAAL0DACDNAQAAvgMAINMBAAAJACAAAAAAAdABAQAAAAEB0AFAAAAAAQUcAAC3AwAgHQAAuwMAIM0BAAC4AwAgzgEAALoDACDTAQAAAQAgCxwAALkCADAdAAC-AgAwzQEAALoCADDOAQAAuwIAMM8BAAC8AgAg0AEAAL0CADDRAQAAvQIAMNIBAAC9AgAw0wEAAL0CADDUAQAAvwIAMNUBAADAAgAwBJwBAQAAAAGeAUAAAAABqgFAAAAAAbABAAAAsAECAgAAAA0AIBwAAMQCACADAAAADQAgHAAAxAIAIB0AAMMCACABFQAAuQMAMAoGAACKAgAgmAEAAIgCADCZAQAACwAQmgEAAIgCADCcAQEA9QEAIZ4BQAD2AQAhqgFAAPYBACGuAQEA9QEAIbABAACJArABIscBAACHAgAgAgAAAA0AIBUAAMMCACACAAAAwQIAIBUAAMICACAImAEAAMACADCZAQAAwQIAEJoBAADAAgAwnAEBAPUBACGeAUAA9gEAIaoBQAD2AQAhrgEBAPUBACGwAQAAiQKwASIImAEAAMACADCZAQAAwQIAEJoBAADAAgAwnAEBAPUBACGeAUAA9gEAIaoBQAD2AQAhrgEBAPUBACGwAQAAiQKwASIEnAEBAJgCACGeAUAAmQIAIaoBQACZAgAhsAEAAK4CsAEiBJwBAQCYAgAhngFAAJkCACGqAUAAmQIAIbABAACuArABIgScAQEAAAABngFAAAAAAaoBQAAAAAGwAQAAALABAgMcAAC3AwAgzQEAALgDACDTAQAAAQAgBBwAALkCADDNAQAAugIAMM8BAAC8AgAg0wEAAL0CADAAAAAAAAAAAAAB0AEgAAAAAQccAACsAwAgHQAAtQMAIM0BAACtAwAgzgEAALQDACDRAQAAAwAg0gEAAAMAINMBAAABACALHAAAkQMAMB0AAJYDADDNAQAAkgMAMM4BAACTAwAwzwEAAJQDACDQAQAAlQMAMNEBAACVAwAw0gEAAJUDADDTAQAAlQMAMNQBAACXAwAw1QEAAJgDADALHAAAhQMAMB0AAIoDADDNAQAAhgMAMM4BAACHAwAwzwEAAIgDACDQAQAAiQMAMNEBAACJAwAw0gEAAIkDADDTAQAAiQMAMNQBAACLAwAw1QEAAIwDADALHAAA_AIAMB0AAIADADDNAQAA_QIAMM4BAAD-AgAwzwEAAP8CACDQAQAA9AIAMNEBAAD0AgAw0gEAAPQCADDTAQAA9AIAMNQBAACBAwAw1QEAAPcCADALHAAA8AIAMB0AAPUCADDNAQAA8QIAMM4BAADyAgAwzwEAAPMCACDQAQAA9AIAMNEBAAD0AgAw0gEAAPQCADDTAQAA9AIAMNQBAAD2AgAw1QEAAPcCADALHAAA5AIAMB0AAOkCADDNAQAA5QIAMM4BAADmAgAwzwEAAOcCACDQAQAA6AIAMNEBAADoAgAw0gEAAOgCADDTAQAA6AIAMNQBAADqAgAw1QEAAOsCADALHAAA2AIAMB0AAN0CADDNAQAA2QIAMM4BAADaAgAwzwEAANsCACDQAQAA3AIAMNEBAADcAgAw0gEAANwCADDTAQAA3AIAMNQBAADeAgAw1QEAAN8CADADnAEBAAAAAZ0BQAAAAAGeAUAAAAABAgAAABsAIBwAAOMCACADAAAAGwAgHAAA4wIAIB0AAOICACABFQAAswMAMAkFAACBAgAgmAEAAIACADCZAQAAGQAQmgEAAIACADCbAQEA9QEAIZwBAQD1AQAhnQFAAPYBACGeAUAA9gEAIcUBAAD_AQAgAgAAABsAIBUAAOICACACAAAA4AIAIBUAAOECACAHmAEAAN8CADCZAQAA4AIAEJoBAADfAgAwmwEBAPUBACGcAQEA9QEAIZ0BQAD2AQAhngFAAPYBACEHmAEAAN8CADCZAQAA4AIAEJoBAADfAgAwmwEBAPUBACGcAQEA9QEAIZ0BQAD2AQAhngFAAPYBACEDnAEBAJgCACGdAUAAmQIAIZ4BQACZAgAhA5wBAQCYAgAhnQFAAJkCACGeAUAAmQIAIQOcAQEAAAABnQFAAAAAAZ4BQAAAAAECnAEBAAAAAaoBQAAAAAECAAAAFwAgHAAA7wIAIAMAAAAXACAcAADvAgAgHQAA7gIAIAEVAACyAwAwCAUAAIECACCYAQAAgwIAMJkBAAAVABCaAQAAgwIAMJsBAQD1AQAhnAEBAPUBACGqAUAA9gEAIcUBAACCAgAgAgAAABcAIBUAAO4CACACAAAA7AIAIBUAAO0CACAGmAEAAOsCADCZAQAA7AIAEJoBAADrAgAwmwEBAPUBACGcAQEA9QEAIaoBQAD2AQAhBpgBAADrAgAwmQEAAOwCABCaAQAA6wIAMJsBAQD1AQAhnAEBAPUBACGqAUAA9gEAIQKcAQEAmAIAIaoBQACZAgAhApwBAQCYAgAhqgFAAJkCACECnAEBAAAAAaoBQAAAAAEDCgAAqQIAIKsBAQAAAAGtAQIAAAABAgAAABIAIBwAAPsCACADAAAAEgAgHAAA-wIAIB0AAPoCACABFQAAsQMAMAkKAACBAgAgCwAAgQIAIJgBAACFAgAwmQEAABAAEJoBAACFAgAwqwEBAPUBACGsAQEA9QEAIa0BAgCGAgAhxgEAAIQCACACAAAAEgAgFQAA-gIAIAIAAAD4AgAgFQAA-QIAIAaYAQAA9wIAMJkBAAD4AgAQmgEAAPcCADCrAQEA9QEAIawBAQD1AQAhrQECAIYCACEGmAEAAPcCADCZAQAA-AIAEJoBAAD3AgAwqwEBAPUBACGsAQEA9QEAIa0BAgCGAgAhAqsBAQCYAgAhrQECAKYCACEDCgAApwIAIKsBAQCYAgAhrQECAKYCACEDCgAAqQIAIKsBAQAAAAGtAQIAAAABAwsAAKoCACCsAQEAAAABrQECAAAAAQIAAAASACAcAACEAwAgAwAAABIAIBwAAIQDACAdAACDAwAgARUAALADADACAAAAEgAgFQAAgwMAIAIAAAD4AgAgFQAAggMAIAKsAQEAmAIAIa0BAgCmAgAhAwsAAKgCACCsAQEAmAIAIa0BAgCmAgAhAwsAAKoCACCsAQEAAAABrQECAAAAAQsHAADGAgAgngFAAAAAAaoBQAAAAAGxAQEAAAABsgEBAAAAAbMBAQAAAAG0AQEAAAABtQEBAAAAAbYBAQAAAAG3AUAAAAABuAEBAAAAAQIAAAAJACAcAACQAwAgAwAAAAkAIBwAAJADACAdAACPAwAgARUAAK8DADAQBQAAgQIAIAcAAIwCACCYAQAAiwIAMJkBAAAHABCaAQAAiwIAMJsBAQD1AQAhngFAAPYBACGqAUAA9gEAIbEBAQAAAAGyAQEA9QEAIbMBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbYBAQD3AQAhtwFAAPgBACG4AQEA9wEAIQIAAAAJACAVAACPAwAgAgAAAI0DACAVAACOAwAgDpgBAACMAwAwmQEAAI0DABCaAQAAjAMAMJsBAQD1AQAhngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhsgEBAPUBACGzAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG2AQEA9wEAIbcBQAD4AQAhuAEBAPcBACEOmAEAAIwDADCZAQAAjQMAEJoBAACMAwAwmwEBAPUBACGeAUAA9gEAIaoBQAD2AQAhsQEBAPUBACGyAQEA9QEAIbMBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbYBAQD3AQAhtwFAAPgBACG4AQEA9wEAIQqeAUAAmQIAIaoBQACZAgAhsQEBAJgCACGyAQEAmAIAIbMBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbYBAQC1AgAhtwFAALYCACG4AQEAtQIAIQsHAAC4AgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhsgEBAJgCACGzAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG2AQEAtQIAIbcBQAC2AgAhuAEBALUCACELBwAAxgIAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbIBAQAAAAGzAQEAAAABtAEBAAAAAbUBAQAAAAG2AQEAAAABtwFAAAAAAbgBAQAAAAEUBAAAnQMAIAkAAJ4DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb4BAQAAAAG_AQEAAAABwAEBAAAAAcEBIAAAAAHCASAAAAABwwFAAAAAAcQBAQAAAAECAAAAAQAgHAAAnAMAIAMAAAABACAcAACcAwAgHQAAmwMAIAEVAACuAwAwGQEAAI8CACAEAACQAgAgCQAAkQIAIAwAAJICACANAACSAgAgDgAAkwIAIA8AAJQCACCYAQAAjQIAMJkBAAADABCaAQAAjQIAMJ4BQAD2AQAhqgFAAPYBACGxAQEAAAABtAEBAPUBACG1AQEA9QEAIbcBQAD4AQAhuAEBAPcBACG9AQEA9wEAIb4BAQD3AQAhvwEBAPcBACHAAQEA9QEAIcEBIACOAgAhwgEgAI4CACHDAUAA-AEAIcQBAQD3AQAhAgAAAAEAIBUAAJsDACACAAAAmQMAIBUAAJoDACASmAEAAJgDADCZAQAAmQMAEJoBAACYAwAwngFAAPYBACGqAUAA9gEAIbEBAQD1AQAhtAEBAPUBACG1AQEA9QEAIbcBQAD4AQAhuAEBAPcBACG9AQEA9wEAIb4BAQD3AQAhvwEBAPcBACHAAQEA9QEAIcEBIACOAgAhwgEgAI4CACHDAUAA-AEAIcQBAQD3AQAhEpgBAACYAwAwmQEAAJkDABCaAQAAmAMAMJ4BQAD2AQAhqgFAAPYBACGxAQEA9QEAIbQBAQD1AQAhtQEBAPUBACG3AUAA-AEAIbgBAQD3AQAhvQEBAPcBACG-AQEA9wEAIb8BAQD3AQAhwAEBAPUBACHBASAAjgIAIcIBIACOAgAhwwFAAPgBACHEAQEA9wEAIQ6eAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhFAQAANICACAJAADTAgAgDAAA1AIAIA0AANUCACAOAADWAgAgDwAA1wIAIJ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG3AUAAtgIAIbgBAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEUBAAAnQMAIAkAAJ4DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb4BAQAAAAG_AQEAAAABwAEBAAAAAcEBIAAAAAHCASAAAAABwwFAAAAAAcQBAQAAAAEEHAAAkQMAMM0BAACSAwAwzwEAAJQDACDTAQAAlQMAMAQcAACFAwAwzQEAAIYDADDPAQAAiAMAINMBAACJAwAwBBwAAPwCADDNAQAA_QIAMM8BAAD_AgAg0wEAAPQCADAEHAAA8AIAMM0BAADxAgAwzwEAAPMCACDTAQAA9AIAMAQcAADkAgAwzQEAAOUCADDPAQAA5wIAINMBAADoAgAwBBwAANgCADDNAQAA2QIAMM8BAADbAgAg0wEAANwCADADHAAArAMAIM0BAACtAwAg0wEAAAEAIA4BAACkAwAgBAAApwMAIAkAAKgDACAMAACpAwAgDQAAqQMAIA4AAKoDACAPAACrAwAgtwEAALECACC4AQAAsQIAIL0BAACxAgAgvgEAALECACC_AQAAsQIAIMMBAACxAgAgxAEAALECACAFBQAApAMAIAcAAKYDACC2AQAAsQIAILcBAACxAgAguAEAALECACAAAAAAAAAVAQAAowMAIAkAAJ4DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb0BAQAAAAG-AQEAAAABvwEBAAAAAcABAQAAAAHBASAAAAABwgEgAAAAAcMBQAAAAAHEAQEAAAABAgAAAAEAIBwAAKwDACAOngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb4BAQAAAAG_AQEAAAABwAEBAAAAAcEBIAAAAAHCASAAAAABwwFAAAAAAcQBAQAAAAEKngFAAAAAAaoBQAAAAAGxAQEAAAABsgEBAAAAAbMBAQAAAAG0AQEAAAABtQEBAAAAAbYBAQAAAAG3AUAAAAABuAEBAAAAAQKsAQEAAAABrQECAAAAAQKrAQEAAAABrQECAAAAAQKcAQEAAAABqgFAAAAAAQOcAQEAAAABnQFAAAAAAZ4BQAAAAAEDAAAAAwAgHAAArAMAIB0AALYDACAXAAAAAwAgAQAA0QIAIAkAANMCACAMAADUAgAgDQAA1QIAIA4AANYCACAPAADXAgAgFQAAtgMAIJ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG3AUAAtgIAIbgBAQC1AgAhvQEBALUCACG-AQEAtQIAIb8BAQC1AgAhwAEBAJgCACHBASAA0AIAIcIBIADQAgAhwwFAALYCACHEAQEAtQIAIRUBAADRAgAgCQAA0wIAIAwAANQCACANAADVAgAgDgAA1gIAIA8AANcCACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAAowMAIAQAAJ0DACAMAACfAwAgDQAAoAMAIA4AAKEDACAPAACiAwAgngFAAAAAAaoBQAAAAAGxAQEAAAABtAEBAAAAAbUBAQAAAAG3AUAAAAABuAEBAAAAAb0BAQAAAAG-AQEAAAABvwEBAAAAAcABAQAAAAHBASAAAAABwgEgAAAAAcMBQAAAAAHEAQEAAAABAgAAAAEAIBwAALcDACAEnAEBAAAAAZ4BQAAAAAGqAUAAAAABsAEAAACwAQIDAAAAAwAgHAAAtwMAIB0AALwDACAXAAAAAwAgAQAA0QIAIAQAANICACAMAADUAgAgDQAA1QIAIA4AANYCACAPAADXAgAgFQAAvAMAIJ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbQBAQCYAgAhtQEBAJgCACG3AUAAtgIAIbgBAQC1AgAhvQEBALUCACG-AQEAtQIAIb8BAQC1AgAhwAEBAJgCACHBASAA0AIAIcIBIADQAgAhwwFAALYCACHEAQEAtQIAIRUBAADRAgAgBAAA0gIAIAwAANQCACANAADVAgAgDgAA1gIAIA8AANcCACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEMBQAAxQIAIJsBAQAAAAGeAUAAAAABqgFAAAAAAbEBAQAAAAGyAQEAAAABswEBAAAAAbQBAQAAAAG1AQEAAAABtgEBAAAAAbcBQAAAAAG4AQEAAAABAgAAAAkAIBwAAL0DACADAAAABwAgHAAAvQMAIB0AAMEDACAOAAAABwAgBQAAtwIAIBUAAMEDACCbAQEAmAIAIZ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbIBAQCYAgAhswEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtgEBALUCACG3AUAAtgIAIbgBAQC1AgAhDAUAALcCACCbAQEAmAIAIZ4BQACZAgAhqgFAAJkCACGxAQEAmAIAIbIBAQCYAgAhswEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtgEBALUCACG3AUAAtgIAIbgBAQC1AgAhFQEAAKMDACAEAACdAwAgCQAAngMAIAwAAJ8DACAOAAChAwAgDwAAogMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADCAwAgFQEAAKMDACAEAACdAwAgCQAAngMAIA0AAKADACAOAAChAwAgDwAAogMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADEAwAgAwAAAAMAIBwAAMIDACAdAADIAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIAwAANQCACAOAADWAgAgDwAA1wIAIBUAAMgDACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDAAA1AIAIA4AANYCACAPAADXAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhAwAAAAMAIBwAAMQDACAdAADLAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIA0AANUCACAOAADWAgAgDwAA1wIAIBUAAMsDACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDQAA1QIAIA4AANYCACAPAADXAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhFQEAAKMDACAEAACdAwAgCQAAngMAIAwAAJ8DACANAACgAwAgDwAAogMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADMAwAgAwAAAAMAIBwAAMwDACAdAADQAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIAwAANQCACANAADVAgAgDwAA1wIAIBUAANADACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDAAA1AIAIA0AANUCACAPAADXAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhFQEAAKMDACAEAACdAwAgCQAAngMAIAwAAJ8DACANAACgAwAgDgAAoQMAIJ4BQAAAAAGqAUAAAAABsQEBAAAAAbQBAQAAAAG1AQEAAAABtwFAAAAAAbgBAQAAAAG9AQEAAAABvgEBAAAAAb8BAQAAAAHAAQEAAAABwQEgAAAAAcIBIAAAAAHDAUAAAAABxAEBAAAAAQIAAAABACAcAADRAwAgAwAAAAMAIBwAANEDACAdAADVAwAgFwAAAAMAIAEAANECACAEAADSAgAgCQAA0wIAIAwAANQCACANAADVAgAgDgAA1gIAIBUAANUDACCeAUAAmQIAIaoBQACZAgAhsQEBAJgCACG0AQEAmAIAIbUBAQCYAgAhtwFAALYCACG4AQEAtQIAIb0BAQC1AgAhvgEBALUCACG_AQEAtQIAIcABAQCYAgAhwQEgANACACHCASAA0AIAIcMBQAC2AgAhxAEBALUCACEVAQAA0QIAIAQAANICACAJAADTAgAgDAAA1AIAIA0AANUCACAOAADWAgAgngFAAJkCACGqAUAAmQIAIbEBAQCYAgAhtAEBAJgCACG1AQEAmAIAIbcBQAC2AgAhuAEBALUCACG9AQEAtQIAIb4BAQC1AgAhvwEBALUCACHAAQEAmAIAIcEBIADQAgAhwgEgANACACHDAUAAtgIAIcQBAQC1AgAhCAEEAQQGAQgACAkKAgwTBQ0UBQ4YBg8cBwMFAAEHDgMIAAQBBgACAQcPAAIKAAELAAEBBQABAQUAAQYEHQAJHgAMHwANIAAOIQAPIgAAAQEsAQEBMgEDCAANIgAOIwAPAAAAAwgADSIADiMADwAAAAMIABUiABYjABcAAAADCAAVIgAWIwAXAAAAAwgAHSIAHiMAHwAAAAMIAB0iAB4jAB8BBQABAQUAAQMIACQiACUjACYAAAADCAAkIgAlIwAmAQYAAgEGAAIDCAArIgAsIwAtAAAAAwgAKyIALCMALQIKAAELAAECCgABCwABBQgAMiIANSMANnQAM3UANAAAAAAABQgAMiIANSMANnQAM3UANAEFAAEBBQABAwgAOyIAPCMAPQAAAAMIADsiADwjAD0BBQABAQUAAQMIAEIiAEMjAEQAAAADCABCIgBDIwBEEAIBESMBEiQBEyUBFCYBFigBFyoJGCsKGS4BGjAJGzELHjMBHzQBIDUJJDgMJTkQJjsRJzwRKD8RKUARKkERK0MRLEUJLUYSLkgRL0oJMEsTMUwRMk0RM04JNFEUNVIYNlQZN1UZOFgZOVkZOloZO1wZPF4JPV8aPmEZP2MJQGQbQWUZQmYZQ2cJRGocRWsgRmwCR20CSG4CSW8CSnACS3ICTHQJTXUhTncCT3kJUHoiUXsCUnwCU30JVIABI1WBASdWggEDV4MBA1iEAQNZhQEDWoYBA1uIAQNcigEJXYsBKF6NAQNfjwEJYJABKWGRAQNikgEDY5MBCWSWASpllwEuZpgBBWeZAQVomgEFaZsBBWqcAQVrngEFbKABCW2hAS9uowEFb6UBCXCmATBxpwEFcqgBBXOpAQl2rAExd60BN3iuAQZ5rwEGerABBnuxAQZ8sgEGfbQBBn62AQl_twE4gAG5AQaBAbsBCYIBvAE5gwG9AQaEAb4BBoUBvwEJhgHCATqHAcMBPogBxAEHiQHFAQeKAcYBB4sBxwEHjAHIAQeNAcoBB44BzAEJjwHNAT-QAc8BB5EB0QEJkgHSAUCTAdMBB5QB1AEHlQHVAQmWAdgBQZcB2QFF" } config.compilerWasm = { getRuntime: async () => require('./query_compiler_fast_bg.js'), diff --git a/packages/forums-prisma-client/package.json b/packages/forums-prisma-client/package.json index 25a23d1..8779070 100644 --- a/packages/forums-prisma-client/package.json +++ b/packages/forums-prisma-client/package.json @@ -1,5 +1,5 @@ { - "name": "prisma-client-9191bf60429fafe5f278fa5f503fb7388dd9fe11117f6413411a46164ded1602", + "name": "prisma-client-ef34779367716a040c952441ff72fcb57b8c12c5a26fab35714dccb03ead1789", "main": "index.js", "types": "index.d.ts", "browser": "default.js", diff --git a/packages/forums-prisma-client/schema.prisma b/packages/forums-prisma-client/schema.prisma index 835a324..b836603 100644 --- a/packages/forums-prisma-client/schema.prisma +++ b/packages/forums-prisma-client/schema.prisma @@ -14,6 +14,13 @@ datasource db { schemas = ["forums"] } +enum PostReactionType { + THUMBS_UP + THUMBS_DOWN + + @@schema("forums") +} + model Topic { id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) parentTopicId String? @db.VarChar(14) @@ -86,7 +93,8 @@ model Post { deletedAt DateTime? deletedByMemberId String? @db.VarChar(64) - topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade) + topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade) + reactions PostReaction[] @@index([topicId, createdAt]) @@index([topicId, parentType, parentId]) @@ -94,6 +102,21 @@ model Post { @@schema("forums") } +model PostReaction { + postId String @db.VarChar(14) + memberId String @db.VarChar(64) + reaction PostReactionType + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + + @@id([postId, memberId]) + @@index([memberId]) + @@index([postId, reaction]) + @@schema("forums") +} + model TopicClosure { ancestorTopicId String @db.VarChar(14) descendantTopicId String @db.VarChar(14) diff --git a/prisma/migrations/20260827120000_post_reactions/migration.sql b/prisma/migrations/20260827120000_post_reactions/migration.sql new file mode 100644 index 0000000..706fd37 --- /dev/null +++ b/prisma/migrations/20260827120000_post_reactions/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "forums"."PostReactionType" AS ENUM ('THUMBS_UP', 'THUMBS_DOWN'); + +-- CreateTable +CREATE TABLE "forums"."PostReaction" ( + "postId" VARCHAR(14) NOT NULL, + "memberId" VARCHAR(64) NOT NULL, + "reaction" "forums"."PostReactionType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PostReaction_pkey" PRIMARY KEY ("postId", "memberId") +); + +-- CreateIndex +CREATE INDEX "PostReaction_memberId_idx" ON "forums"."PostReaction"("memberId"); + +-- CreateIndex +CREATE INDEX "PostReaction_postId_reaction_idx" ON "forums"."PostReaction"("postId", "reaction"); + +-- AddForeignKey +ALTER TABLE "forums"."PostReaction" ADD CONSTRAINT "PostReaction_postId_fkey" FOREIGN KEY ("postId") REFERENCES "forums"."Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 835a324..b836603 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,13 @@ datasource db { schemas = ["forums"] } +enum PostReactionType { + THUMBS_UP + THUMBS_DOWN + + @@schema("forums") +} + model Topic { id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) parentTopicId String? @db.VarChar(14) @@ -86,7 +93,8 @@ model Post { deletedAt DateTime? deletedByMemberId String? @db.VarChar(64) - topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade) + topic Topic @relation(fields: [topicId], references: [id], onDelete: Cascade) + reactions PostReaction[] @@index([topicId, createdAt]) @@index([topicId, parentType, parentId]) @@ -94,6 +102,21 @@ model Post { @@schema("forums") } +model PostReaction { + postId String @db.VarChar(14) + memberId String @db.VarChar(64) + reaction PostReactionType + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + + @@id([postId, memberId]) + @@index([memberId]) + @@index([postId, reaction]) + @@schema("forums") +} + model TopicClosure { ancestorTopicId String @db.VarChar(14) descendantTopicId String @db.VarChar(14) diff --git a/src/forums/dto/forums-command-response.dto.ts b/src/forums/dto/forums-command-response.dto.ts index 7d9a17f..afc6082 100644 --- a/src/forums/dto/forums-command-response.dto.ts +++ b/src/forums/dto/forums-command-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PostReactionType } from '../../../prisma/generated/client'; /** * Topic response returned by command-side forum endpoints. @@ -184,3 +185,27 @@ export class TopicReadStateCommandResponseDto { @ApiProperty({ description: 'Read-state update timestamp.' }) updatedAt: Date; } + +/** + * Aggregate reaction state returned after a member sets or removes a reaction. + * + * Counts include all members' current reactions. `viewerReaction` is the + * authenticated member's resulting value and is null after removal. + */ +export class ForumPostReactionStateDto { + @ApiProperty({ description: 'Post whose reaction state changed.' }) + postId: string; + + @ApiPropertyOptional({ + description: 'Current member reaction after the command.', + enum: PostReactionType, + nullable: true, + }) + viewerReaction: PostReactionType | null; + + @ApiProperty({ description: 'Current number of thumbs-up reactions.' }) + thumbsUpCount: number; + + @ApiProperty({ description: 'Current number of thumbs-down reactions.' }) + thumbsDownCount: number; +} diff --git a/src/forums/dto/forums-command.dto.ts b/src/forums/dto/forums-command.dto.ts index 8708625..8ddd98f 100644 --- a/src/forums/dto/forums-command.dto.ts +++ b/src/forums/dto/forums-command.dto.ts @@ -1,12 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, + IsEnum, IsIn, IsOptional, IsString, MaxLength, MinLength, } from 'class-validator'; +import { PostReactionType } from '../../../prisma/generated/client'; /** * Request body for creating a topic with its starter post. @@ -170,3 +172,18 @@ export class UpdatePostDto { @MinLength(1) content: string; } + +/** + * Request body for setting the authenticated member's reaction on a post. + * + * A member has at most one reaction per post. Sending the other reaction type + * replaces the existing value; removing a reaction uses the DELETE endpoint. + */ +export class SetPostReactionDto { + @ApiProperty({ + description: 'Thumb reaction to add or replace for the current member.', + enum: PostReactionType, + }) + @IsEnum(PostReactionType) + reaction: PostReactionType; +} diff --git a/src/forums/dto/forums-read.dto.ts b/src/forums/dto/forums-read.dto.ts index c64409a..b236add 100644 --- a/src/forums/dto/forums-read.dto.ts +++ b/src/forums/dto/forums-read.dto.ts @@ -1,6 +1,7 @@ import { Type } from 'class-transformer'; import { IsInt, IsOptional, Min } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PostReactionType } from '../../../prisma/generated/client'; /** * Query parameters for paginated forum topic list reads. @@ -293,6 +294,23 @@ export class ForumsPostTreeNodeDto { @ApiProperty({ description: 'Whether the post is a deleted placeholder.' }) deleted: boolean; + @ApiProperty({ + description: 'Number of current thumbs-up reactions from all members.', + }) + thumbsUpCount: number; + + @ApiProperty({ + description: 'Number of current thumbs-down reactions from all members.', + }) + thumbsDownCount: number; + + @ApiPropertyOptional({ + description: "Authenticated member's current reaction on this post.", + enum: PostReactionType, + nullable: true, + }) + viewerReaction: PostReactionType | null; + @ApiProperty({ description: 'Nested replies ordered by newest visible subtree activity.', type: () => [ForumsPostTreeNodeDto], diff --git a/src/forums/forums-command.service.spec.ts b/src/forums/forums-command.service.spec.ts index f06d841..0b22530 100644 --- a/src/forums/forums-command.service.spec.ts +++ b/src/forums/forums-command.service.spec.ts @@ -1,6 +1,6 @@ import { Logger } from '@nestjs/common'; import { JwtUser } from '../auth/jwt.service'; -import { Post, Topic } from '../../prisma/generated/client'; +import { Post, PostReactionType, Topic } from '../../prisma/generated/client'; import { ForumsCommandService } from './forums-command.service'; const createdAt = new Date('2026-06-04T00:00:00.000Z'); @@ -84,6 +84,20 @@ function createCommandService(order: string[] = []) { findUnique: jest.fn().mockResolvedValue(post), update: jest.fn().mockResolvedValue(post), }, + postReaction: { + upsert: jest.fn().mockResolvedValue({ + postId: 'post-1', + memberId: '1', + reaction: PostReactionType.THUMBS_UP, + createdAt, + updatedAt: createdAt, + }), + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + count: jest.fn().mockImplementation( + (args: { where: { reaction: PostReactionType } }) => + args.where.reaction === PostReactionType.THUMBS_UP ? 2 : 1, + ), + }, topicClosure: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]), @@ -129,6 +143,7 @@ function createCommandService(order: string[] = []) { canUpdateTopic: { allowed: true }, }), decideForPost: jest.fn().mockResolvedValue({ + canView: { allowed: true }, canDeletePost: { allowed: true }, canUpdatePost: { allowed: true }, }), @@ -560,6 +575,88 @@ describe('ForumsCommandService notification boundary', () => { }); }); +describe('ForumsCommandService post reactions', () => { + it('upserts a member reaction and returns both shared counts', async () => { + const { moderationService, service, tx } = createCommandService(); + + await expect( + service.setPostReaction( + 'post-1', + { reaction: PostReactionType.THUMBS_DOWN }, + user, + '203.0.113.10', + ), + ).resolves.toEqual({ + postId: 'post-1', + viewerReaction: PostReactionType.THUMBS_DOWN, + thumbsUpCount: 2, + thumbsDownCount: 1, + }); + + expect(moderationService.decideForRequestActorBan).toHaveBeenCalledWith( + expect.objectContaining({ memberId: '1' }), + '203.0.113.10', + ); + expect(tx.postReaction.upsert).toHaveBeenCalledWith({ + where: { + postId_memberId: { postId: 'post-1', memberId: '1' }, + }, + create: { + postId: 'post-1', + memberId: '1', + reaction: PostReactionType.THUMBS_DOWN, + }, + update: { reaction: PostReactionType.THUMBS_DOWN }, + }); + expect(tx.postReaction.count).toHaveBeenCalledTimes(2); + expect(moderationService.decideForLockedTopicMutation).not.toHaveBeenCalled(); + }); + + it('removes a member reaction idempotently and returns a null viewer state', async () => { + const { service, tx } = createCommandService(); + tx.postReaction.deleteMany.mockResolvedValue({ count: 0 }); + tx.postReaction.count.mockResolvedValue(0); + + await expect( + service.removePostReaction('post-1', user), + ).resolves.toEqual({ + postId: 'post-1', + viewerReaction: null, + thumbsUpCount: 0, + thumbsDownCount: 0, + }); + expect(tx.postReaction.deleteMany).toHaveBeenCalledWith({ + where: { postId: 'post-1', memberId: '1' }, + }); + }); + + it('rejects reactions when post visibility is denied before persistence', async () => { + const { accessPolicyService, db, service, tx } = createCommandService(); + accessPolicyService.decideForPost.mockResolvedValue({ + canView: { allowed: false, reason: 'Insufficient forums access.' }, + }); + + await expect( + service.setPostReaction( + 'post-1', + { reaction: PostReactionType.THUMBS_UP }, + user, + ), + ).rejects.toThrow('Insufficient forums access.'); + expect(db.$transaction).not.toHaveBeenCalled(); + expect(tx.postReaction.upsert).not.toHaveBeenCalled(); + }); + + it('rejects machine callers because reactions belong to human members', async () => { + const { db, service } = createCommandService(); + + await expect( + service.removePostReaction('post-1', machineUser), + ).rejects.toThrow('Authenticated member token required.'); + expect(db.$transaction).not.toHaveBeenCalled(); + }); +}); + describe('ForumsCommandService member-targeted state commands', () => { const commandCases = [ { diff --git a/src/forums/forums-command.service.ts b/src/forums/forums-command.service.ts index e8d1249..3c972b9 100644 --- a/src/forums/forums-command.service.ts +++ b/src/forums/forums-command.service.ts @@ -10,6 +10,7 @@ import { JwtUser } from '../auth/jwt.service'; import { DbService } from '../db/db.service'; import { Post, + PostReactionType, Prisma, Topic, TopicReadState, @@ -19,11 +20,13 @@ import { CreatePostDto, CreateTopicDto, MemberTargetDto, + SetPostReactionDto, UpdatePostDto, UpdateTopicDto, } from './dto/forums-command.dto'; import { CreateTopicCommandResponseDto, + ForumPostReactionStateDto, ForumTopicCommandResponseDto, } from './dto/forums-command-response.dto'; import { @@ -113,7 +116,8 @@ export interface TopicWatchResult { } /** - * Command-side service for forum content, watch, and read-state mutations. + * Command-side service for forum content, reactions, watch, and read-state + * mutations. * * The service keeps transactional write shapes local while delegating * action-level authorization to the shared forums access policy and runtime @@ -680,6 +684,88 @@ export class ForumsCommandService { }); } + /** + * Adds or replaces the authenticated member's reaction on an active post. + * + * Reactions require post visibility and an unbanned human member, but remain + * available on locked topics because they do not change discussion content. + * + * @param postId Post id receiving the reaction. + * @param dto Requested thumbs-up or thumbs-down value. + * @param user Authenticated human member token payload. + * @param trustedClientIp Optional trusted client IP resolved at the HTTP boundary. + * @returns Resulting viewer reaction and aggregate post reaction counts. + * @throws UnauthorizedException when no authenticated human member is present. + * @throws ForbiddenException when runtime bans or inherited visibility deny access. + * @throws NotFoundException when the post or its active topic is missing or deleted. + */ + async setPostReaction( + postId: string, + dto: SetPostReactionDto, + user: JwtUser | undefined, + trustedClientIp?: string, + ): Promise { + const memberId = await this.authorizePostReaction( + postId, + user, + trustedClientIp, + ); + + return this.db.$transaction(async (tx) => { + await this.ensureMutablePost(tx, postId); + await tx.postReaction.upsert({ + where: { + postId_memberId: { postId, memberId }, + }, + create: { + postId, + memberId, + reaction: dto.reaction, + }, + update: { + reaction: dto.reaction, + }, + }); + + return this.buildPostReactionState(tx, postId, dto.reaction); + }); + } + + /** + * Removes the authenticated member's reaction from an active post. + * + * Removal is idempotent and remains available on locked topics. The response + * always reports a null viewer reaction plus current aggregate counts. + * + * @param postId Post id whose current-member reaction should be removed. + * @param user Authenticated human member token payload. + * @param trustedClientIp Optional trusted client IP resolved at the HTTP boundary. + * @returns Resulting null viewer reaction and aggregate post reaction counts. + * @throws UnauthorizedException when no authenticated human member is present. + * @throws ForbiddenException when runtime bans or inherited visibility deny access. + * @throws NotFoundException when the post or its active topic is missing or deleted. + */ + async removePostReaction( + postId: string, + user: JwtUser | undefined, + trustedClientIp?: string, + ): Promise { + const memberId = await this.authorizePostReaction( + postId, + user, + trustedClientIp, + ); + + return this.db.$transaction(async (tx) => { + await this.ensureMutablePost(tx, postId); + await tx.postReaction.deleteMany({ + where: { postId, memberId }, + }); + + return this.buildPostReactionState(tx, postId, null); + }); + } + /** * Adds an explicit watch for a member on a topic. * @@ -932,6 +1018,83 @@ export class ForumsCommandService { }; } + /** + * Authorizes a human member reaction against post visibility and runtime bans. + * + * @param postId Post id receiving a reaction command. + * @param user Authenticated token payload from request middleware. + * @param trustedClientIp Optional trusted client IP resolved at the HTTP boundary. + * @returns Authenticated member id used as the reaction owner. + * @throws UnauthorizedException when the caller is absent, M2M, or lacks a member id. + * @throws ForbiddenException when runtime bans or inherited visibility deny access. + * @throws NotFoundException when the post or owning topic is missing or deleted. + */ + private async authorizePostReaction( + postId: string, + user: JwtUser | undefined, + trustedClientIp?: string, + ): Promise { + const principal = this.requirePrincipal(user); + + if (principal.isMachine || !principal.memberId) { + throw new UnauthorizedException('Authenticated member token required.'); + } + + this.assertAllowed( + await this.moderationService.decideForRequestActorBan( + principal, + trustedClientIp, + ), + ); + const context = await this.topicContextService.loadPostContext( + postId, + principal, + ); + const postDecisions = await this.accessPolicyService.decideForPost( + principal, + context, + ); + + this.assertAllowed(postDecisions.canView); + + if (context.post.deletedAt) { + throw new NotFoundException('Post not found.'); + } + + return principal.memberId; + } + + /** + * Counts both post reaction types after a reaction command completes. + * + * @param tx Prisma transaction client containing the just-written state. + * @param postId Post id whose aggregate reactions should be counted. + * @param viewerReaction Current member reaction after the command. + * @returns Viewer state paired with current thumbs-up and thumbs-down counts. + * @throws Prisma errors when either aggregate count query fails. + */ + private async buildPostReactionState( + tx: Prisma.TransactionClient, + postId: string, + viewerReaction: PostReactionType | null, + ): Promise { + const [thumbsUpCount, thumbsDownCount] = await Promise.all([ + tx.postReaction.count({ + where: { postId, reaction: PostReactionType.THUMBS_UP }, + }), + tx.postReaction.count({ + where: { postId, reaction: PostReactionType.THUMBS_DOWN }, + }), + ]); + + return { + postId, + viewerReaction, + thumbsUpCount, + thumbsDownCount, + }; + } + /** * Builds the reusable forums principal required by policy checks. * diff --git a/src/forums/forums-notification-read.integration.spec.ts b/src/forums/forums-notification-read.integration.spec.ts index 390cd50..7d73714 100644 --- a/src/forums/forums-notification-read.integration.spec.ts +++ b/src/forums/forums-notification-read.integration.spec.ts @@ -2,7 +2,7 @@ import { INestApplication } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; -import { Post, Topic } from '../../prisma/generated/client'; +import { Post, PostReactionType, Topic } from '../../prisma/generated/client'; import { DbService } from '../db/db.service'; import { ChallengeAccessService } from './challenge-access.service'; import { EventBusService } from './event-bus.service'; @@ -43,9 +43,16 @@ interface IpBanSeed { removedAt: Date | null; } +interface PostReactionSeed { + postId: string; + memberId: string; + reaction: PostReactionType; +} + interface SeededForumsData { topics: Topic[]; posts: Post[]; + postReactions: PostReactionSeed[]; topicClosures: TopicClosureSeed[]; topicWatches: TopicWatchSeed[]; topicReadStates: TopicReadStateSeed[]; @@ -139,27 +146,47 @@ function createSeededForumsDb(seed: SeededForumsData) { } if (queryText.includes('SELECT\n p.id')) { + const viewerMemberId = queryValueToString(values[0]); + return seed.posts .filter((post) => post.topicId === topicId) .sort(compareCreatedAtThenId) - .map((post) => ({ - id: post.id, - topicId: post.topicId, - parentType: post.parentType, - parentId: post.parentId, - authorMemberId: post.authorMemberId, - authorHandle: post.authorHandle, - authorPostsCount: seed.posts.filter( - (candidate) => - candidate.topicId === post.topicId && - candidate.authorMemberId === post.authorMemberId && - !candidate.deletedAt, - ).length, - content: post.content, - createdAt: post.createdAt, - updatedAt: post.updatedAt, - deletedAt: post.deletedAt, - })); + .map((post) => { + const reactions = seed.postReactions.filter( + (reaction) => reaction.postId === post.id, + ); + + return { + id: post.id, + topicId: post.topicId, + parentType: post.parentType, + parentId: post.parentId, + authorMemberId: post.authorMemberId, + authorHandle: post.authorHandle, + authorPostsCount: seed.posts.filter( + (candidate) => + candidate.topicId === post.topicId && + candidate.authorMemberId === post.authorMemberId && + !candidate.deletedAt, + ).length, + content: post.content, + createdAt: post.createdAt, + updatedAt: post.updatedAt, + deletedAt: post.deletedAt, + thumbsUpCount: reactions.filter( + (reaction) => + reaction.reaction === PostReactionType.THUMBS_UP, + ).length, + thumbsDownCount: reactions.filter( + (reaction) => + reaction.reaction === PostReactionType.THUMBS_DOWN, + ).length, + viewerReaction: + reactions.find( + (reaction) => reaction.memberId === viewerMemberId, + )?.reaction ?? null, + }; + }); } if (queryText.includes('SELECT EXISTS')) { @@ -258,6 +285,53 @@ function createSeededForumsDb(seed: SeededForumsData) { findUnique: (args: { where: { id: string } }) => seed.posts.find((post) => post.id === args.where.id) ?? null, }, + postReaction: { + upsert: (args: { + where: { + postId_memberId: { postId: string; memberId: string }; + }; + create: PostReactionSeed; + update: { reaction: PostReactionType }; + }) => { + const existing = seed.postReactions.find( + (reaction) => + reaction.postId === args.where.postId_memberId.postId && + reaction.memberId === args.where.postId_memberId.memberId, + ); + + if (existing) { + existing.reaction = args.update.reaction; + return existing; + } + + seed.postReactions.push(args.create); + return args.create; + }, + deleteMany: (args: { + where: { postId: string; memberId: string }; + }) => { + const index = seed.postReactions.findIndex( + (reaction) => + reaction.postId === args.where.postId && + reaction.memberId === args.where.memberId, + ); + + if (index < 0) { + return { count: 0 }; + } + + seed.postReactions.splice(index, 1); + return { count: 1 }; + }, + count: (args: { + where: { postId: string; reaction: PostReactionType }; + }) => + seed.postReactions.filter( + (reaction) => + reaction.postId === args.where.postId && + reaction.reaction === args.where.reaction, + ).length, + }, topicClosure: { findMany: (args: { where?: { @@ -637,6 +711,7 @@ describe('forums notification/read integration', () => { }), ], posts: [], + postReactions: [], topicClosures: [ { ancestorTopicId: 'parent-1', @@ -914,4 +989,69 @@ describe('forums notification/read integration', () => { }), ); }); + + 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( + { + postId: 'post-1', + memberId: '1', + reaction: PostReactionType.THUMBS_UP, + }, + { + postId: 'post-1', + memberId: '2', + reaction: PostReactionType.THUMBS_DOWN, + }, + ); + + const memberOneDetail = await request(app.getHttpServer()) + .get('/topics/parent-1') + .set('x-member-id', '1') + .expect(200); + + expect(memberOneDetail.body.posts[0]).toEqual( + expect.objectContaining({ + thumbsUpCount: 1, + thumbsDownCount: 1, + viewerReaction: PostReactionType.THUMBS_UP, + }), + ); + + await request(app.getHttpServer()) + .put('/posts/post-1/reaction') + .set('x-member-id', '1') + .send({ reaction: PostReactionType.THUMBS_DOWN }) + .expect(200) + .expect({ + postId: 'post-1', + viewerReaction: PostReactionType.THUMBS_DOWN, + thumbsUpCount: 0, + thumbsDownCount: 2, + }); + + const memberTwoDetail = await request(app.getHttpServer()) + .get('/topics/parent-1') + .set('x-member-id', '2') + .expect(200); + + expect(memberTwoDetail.body.posts[0]).toEqual( + expect.objectContaining({ + thumbsUpCount: 0, + thumbsDownCount: 2, + viewerReaction: PostReactionType.THUMBS_DOWN, + }), + ); + + await request(app.getHttpServer()) + .delete('/posts/post-1/reaction') + .set('x-member-id', '1') + .expect(200) + .expect({ + postId: 'post-1', + viewerReaction: null, + thumbsUpCount: 0, + thumbsDownCount: 1, + }); + }); }); diff --git a/src/forums/forums-read-query.service.ts b/src/forums/forums-read-query.service.ts index 36571a1..4125b7f 100644 --- a/src/forums/forums-read-query.service.ts +++ b/src/forums/forums-read-query.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Prisma } from '../../prisma/generated/client'; +import { PostReactionType, Prisma } from '../../prisma/generated/client'; import { DbService } from '../db/db.service'; /** @@ -58,6 +58,9 @@ export interface ForumsPostTreeRow { createdAt: Date; updatedAt: Date; deletedAt: Date | null; + thumbsUpCount: number; + thumbsDownCount: number; + viewerReaction: PostReactionType | null; } /** @@ -220,12 +223,14 @@ export class ForumsReadQueryService { * Fetches all posts for a topic, including soft-deleted placeholders. * * @param topicId Topic id whose posts should be returned. + * @param memberId Authenticated member id used to resolve viewer reaction state. * @param client Optional transaction client used to bind this read to a snapshot. - * @returns Post rows ordered by creation time for deterministic tree assembly. + * @returns Post rows with aggregate and viewer reactions, ordered for deterministic tree assembly. * @throws Prisma errors when the raw query fails. */ findTopicPostRows( topicId: string, + memberId: string | null, client: ForumsReadQueryClient = this.db, ): Promise { return client.$queryRaw(Prisma.sql` @@ -244,8 +249,25 @@ export class ForumsReadQueryService { p.content, p."createdAt", p."updatedAt", - p."deletedAt" + p."deletedAt", + reaction_stats."thumbsUpCount", + reaction_stats."thumbsDownCount", + reaction_stats."viewerReaction" FROM "Post" p + LEFT JOIN LATERAL ( + SELECT + COUNT(*) FILTER ( + WHERE post_reaction.reaction::text = 'THUMBS_UP' + )::integer AS "thumbsUpCount", + COUNT(*) FILTER ( + WHERE post_reaction.reaction::text = 'THUMBS_DOWN' + )::integer AS "thumbsDownCount", + MAX(post_reaction.reaction::text) FILTER ( + WHERE post_reaction."memberId" = CAST(${memberId} AS text) + ) AS "viewerReaction" + FROM "PostReaction" post_reaction + WHERE post_reaction."postId" = p.id + ) reaction_stats ON true WHERE p."topicId" = ${topicId} ORDER BY p."createdAt" ASC, p.id ASC `); @@ -302,7 +324,7 @@ export class ForumsReadQueryService { return null; } - const postRows = await this.findTopicPostRows(topicId, tx); + const postRows = await this.findTopicPostRows(topicId, memberId, tx); return { summaryRow, postRows }; }, diff --git a/src/forums/forums-read.service.ts b/src/forums/forums-read.service.ts index a269f26..8daa091 100644 --- a/src/forums/forums-read.service.ts +++ b/src/forums/forums-read.service.ts @@ -5,6 +5,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { JwtUser } from '../auth/jwt.service'; +import { PostReactionType } from '../../prisma/generated/client'; import { ForumsPostTreeNodeDto, ForumsTopicDetailDto, @@ -48,6 +49,9 @@ interface ForumsPostTreeNodeInternal { createdAt: Date; updatedAt: Date; deleted: boolean; + thumbsUpCount: number; + thumbsDownCount: number; + viewerReaction: PostReactionType | null; replies: ForumsPostTreeNodeInternal[]; subtreeLatestActivityAt: Date | null; } @@ -610,6 +614,9 @@ export class ForumsReadService { createdAt: row.createdAt, updatedAt: row.updatedAt, deleted, + thumbsUpCount: Number(row.thumbsUpCount), + thumbsDownCount: Number(row.thumbsDownCount), + viewerReaction: row.viewerReaction, replies: [], subtreeLatestActivityAt: deleted ? null : row.createdAt, }; @@ -693,6 +700,9 @@ export class ForumsReadService { createdAt: node.createdAt, updatedAt: node.updatedAt, deleted: node.deleted, + thumbsUpCount: node.thumbsUpCount, + thumbsDownCount: node.thumbsDownCount, + viewerReaction: node.viewerReaction, replies: node.replies.map((reply) => this.toPostTreeDto(reply)), }; } diff --git a/src/forums/posts.controller.ts b/src/forums/posts.controller.ts index 4527320..595cd4b 100644 --- a/src/forums/posts.controller.ts +++ b/src/forums/posts.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Param, Patch } from '@nestjs/common'; +import { Body, Controller, Delete, Param, Patch, Put } from '@nestjs/common'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -22,17 +22,23 @@ import { FORUMS_SCOPE_DELETE_POST, FORUMS_SCOPE_UPDATE_POST, } from '../auth/scope-mappings'; -import { UpdatePostDto } from './dto/forums-command.dto'; -import { ForumPostCommandResponseDto } from './dto/forums-command-response.dto'; +import { + SetPostReactionDto, + UpdatePostDto, +} from './dto/forums-command.dto'; +import { + ForumPostCommandResponseDto, + ForumPostReactionStateDto, +} from './dto/forums-command-response.dto'; import { ForumsCommandService } from './forums-command.service'; /** * Controller for command-side post mutation routes. * - * The controller exposes only update and delete commands. Topic detail embeds - * post trees under `read:forums-topics`; `read:forums-posts` remains reserved - * for future post-specific read routes. Trusted client-IP context is forwarded - * as a string for runtime ban checks. + * The controller exposes update, delete, and human-member reaction commands. + * Topic detail embeds post trees under `read:forums-topics`; + * `read:forums-posts` remains reserved for future post-specific read routes. + * Trusted client-IP context is forwarded as a string for runtime ban checks. */ @ApiTags('Forums posts') @ApiBearerAuth() @@ -46,6 +52,97 @@ export class PostsController { */ constructor(private readonly commandService: ForumsCommandService) {} + /** + * Adds or switches the authenticated member's post reaction. + * + * @param postId Post id from the route. + * @param dto Thumbs-up or thumbs-down reaction value. + * @param user Authenticated human member token payload. + * @param trustedClientIp Optional trusted client IP resolved at the HTTP boundary. + * @returns The member's resulting reaction and shared aggregate counts. + * @throws UnauthorizedException when no authenticated human member is present. + * @throws ForbiddenException when an active ban or post visibility denies access. + * @throws BadRequestException when the reaction value is invalid. + * @throws NotFoundException when the post or active topic is missing or deleted. + */ + @Put(':postId/reaction') + @Roles(AUTHENTICATED_USER_ROLE) + @ApiOperation({ + summary: 'Set the current member forum post reaction', + description: + 'Human-member command route that adds a thumbs-up or thumbs-down reaction, or replaces the member\'s existing reaction with the requested value. Reactions require inherited post visibility and pass active member and trusted exact-IP ban checks. Locked topics remain reactable because reactions do not modify discussion content.', + }) + @ApiParam({ name: 'postId', description: 'Post id.' }) + @ApiResponse({ + status: 200, + description: 'Post reaction added or replaced.', + type: ForumPostReactionStateDto, + }) + @ApiBadRequestResponse({ description: 'Invalid reaction body.' }) + @ApiUnauthorizedResponse({ + description: 'Authenticated human member token required.', + }) + @ApiForbiddenResponse({ + description: 'Active forums ban or insufficient post visibility.', + }) + @ApiNotFoundResponse({ description: 'Post or active topic not found.' }) + setPostReaction( + @Param('postId') postId: string, + @Body() dto: SetPostReactionDto, + @CurrentUser() user?: JwtUser, + @ClientIp() trustedClientIp?: string, + ): Promise { + return this.commandService.setPostReaction( + postId, + dto, + user, + trustedClientIp, + ); + } + + /** + * Removes the authenticated member's current post reaction. + * + * @param postId Post id from the route. + * @param user Authenticated human member token payload. + * @param trustedClientIp Optional trusted client IP resolved at the HTTP boundary. + * @returns Null member reaction and the remaining shared aggregate counts. + * @throws UnauthorizedException when no authenticated human member is present. + * @throws ForbiddenException when an active ban or post visibility denies access. + * @throws NotFoundException when the post or active topic is missing or deleted. + */ + @Delete(':postId/reaction') + @Roles(AUTHENTICATED_USER_ROLE) + @ApiOperation({ + summary: 'Remove the current member forum post reaction', + description: + 'Human-member command route that idempotently removes the caller\'s reaction. It applies inherited post visibility plus active member and trusted exact-IP ban checks. Locked topics remain reactable because reactions do not modify discussion content.', + }) + @ApiParam({ name: 'postId', description: 'Post id.' }) + @ApiResponse({ + status: 200, + description: 'Post reaction removed or already absent.', + type: ForumPostReactionStateDto, + }) + @ApiUnauthorizedResponse({ + description: 'Authenticated human member token required.', + }) + @ApiForbiddenResponse({ + description: 'Active forums ban or insufficient post visibility.', + }) + @ApiNotFoundResponse({ description: 'Post or active topic not found.' }) + removePostReaction( + @Param('postId') postId: string, + @CurrentUser() user?: JwtUser, + @ClientIp() trustedClientIp?: string, + ): Promise { + return this.commandService.removePostReaction( + postId, + user, + trustedClientIp, + ); + } + /** * Updates an existing post. * diff --git a/src/main.ts b/src/main.ts index 3b7035c..3185704 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, watch, and read-state mutations with centralized forums authorization, runtime member/IP ban checks, locked-topic 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 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 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.', ) .setVersion('6.0') .addBearerAuth({ From 4e5bb30d9437a23f55de13604263856a4cda0894 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Thu, 3 Sep 2026 13:15:04 +1000 Subject: [PATCH 5/9] fix: apply forum migrations before startup --- Dockerfile | 9 +++++++-- README.md | 2 ++ appStartUp.sh | 8 ++++++++ package.json | 2 +- pnpm-lock.yaml | 6 +++--- 5 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 appStartUp.sh diff --git a/Dockerfile b/Dockerfile index ee15d77..ae90b71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,8 @@ RUN DATABASE_URL="postgresql://user:password@localhost:5432/forums" pnpm prisma: && pnpm lint \ && pnpm build \ && rm -rf node_modules \ - && pnpm install --prod --frozen-lockfile --ignore-scripts + && pnpm install --prod --frozen-lockfile --ignore-scripts \ + && pnpm rebuild @prisma/engines FROM node:${NODE_VERSION}-alpine AS production @@ -52,7 +53,11 @@ WORKDIR /usr/src/app COPY --from=build --chown=node:node /usr/src/app/dist ./dist COPY --from=build --chown=node:node /usr/src/app/node_modules ./node_modules COPY --from=build --chown=node:node /usr/src/app/prisma/generated ./prisma/generated +COPY --from=build --chown=node:node /usr/src/app/prisma/migrations ./prisma/migrations +COPY --from=build --chown=node:node /usr/src/app/prisma/schema.prisma ./prisma/schema.prisma +COPY --from=build --chown=node:node /usr/src/app/prisma.config.ts ./prisma.config.ts COPY --from=build --chown=node:node /usr/src/app/package.json ./package.json +COPY --from=build --chown=node:node --chmod=0555 /usr/src/app/appStartUp.sh ./appStartUp.sh COPY --from=external-clients --chown=node:node /clients/challenge-api-v6/packages/challenge-prisma-client /usr/src/challenge-api-v6/packages/challenge-prisma-client COPY --from=external-clients --chown=node:node /clients/identity-api-v6/packages/identity-prisma-client /usr/src/identity-api-v6/packages/identity-prisma-client COPY --from=external-clients --chown=node:node /clients/member-api-v6/packages/member-prisma-client /usr/src/member-api-v6/packages/member-prisma-client @@ -61,4 +66,4 @@ COPY --from=external-clients --chown=node:node /clients/resource-api-v6/packages USER node EXPOSE 3000 -CMD ["node", "dist/main.js"] +CMD ["./appStartUp.sh"] diff --git a/README.md b/README.md index eeddda6..ce151ab 100644 --- a/README.md +++ b/README.md @@ -230,4 +230,6 @@ interrupted, wipe the target forums dataset and rerun the full import. The forums Prisma schema defines `Topic`, `Post`, `PostReaction`, `TopicClosure`, `TopicWatch`, `TopicReadState`, `MemberBan`, and `IpBan` in the dedicated `forums` schema. `PostReaction` uses a composite post/member key so each member has at most one thumbs-up or thumbs-down value per post; deleting a post cascades its reactions. Topics store an explicit lock state plus nullable lock timestamp and lock actor member id. Ban rows keep active and removed audit metadata; the migration enforces one active row per member or exact IP value and validates IP bans as single IPv4/IPv6 host values. `pnpm prisma:generate` emits the local client at `prisma/generated/client` and the reusable exported client at `packages/forums-prisma-client`. +The production image includes the Prisma schema, migration history, configuration, and CLI. Its startup script runs `prisma migrate deploy` before starting NestJS, so a deployment cannot begin serving requests against an older forums schema. Migration failure stops the container instead of leaving feature-dependent reads or writes partially available. + Topic creation is transactional: it creates the topic, starter post, closure rows, and, for human authors, author watch and read-state rows together. M2M topic creation uses the system author and skips member watch/read-state side effects. Topic deletion is soft deletion, and post deletion preserves the post row while setting content to null for placeholder reads. Topic summary reads use side-effect-free raw queries for topic lock state, visible-post counts, nullable latest visible activity, and unread derivation, then apply centralized forums policy filtering before pagination. diff --git a/appStartUp.sh b/appStartUp.sh new file mode 100644 index 0000000..26f17b1 --- /dev/null +++ b/appStartUp.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +printf '%s\n' 'Applying forums database migrations.' +./node_modules/.bin/prisma migrate deploy + +printf '%s\n' 'Starting Forums API v6.' +exec node dist/main.js diff --git a/package.json b/package.json index 2bba3da..ec7528b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "express": "^5.1.0", "mysql2": "^3.14.3", "pg": "^8.16.3", + "prisma": "^7.9.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "tc-bus-api-wrapper": "github:topcoder-platform/tc-bus-api-wrapper", @@ -51,7 +52,6 @@ "globals": "^16.3.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^7.9.0", "supertest": "^7.0.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ddd803..0f7146b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: pg: specifier: ^8.16.3 version: 8.21.0 + prisma: + specifier: ^7.9.0 + version: 7.9.0(@types/react@19.2.17)(typescript@5.9.3) reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -133,9 +136,6 @@ importers: prettier: specifier: ^3.4.2 version: 3.8.3 - prisma: - specifier: ^7.9.0 - version: 7.9.0(@types/react@19.2.17)(typescript@5.9.3) supertest: specifier: ^7.0.0 version: 7.2.2(supports-color@8.1.1) From ba5c74a676ec2f0fd52bfc9331f825d5fbd8d7a1 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Fri, 4 Sep 2026 08:47:34 +1000 Subject: [PATCH 6/9] fix(PM-6169): correct forum command and deletion policy --- README.md | 2 +- .../forums-access-policy.service.spec.ts | 26 ++++++++++- src/forums/forums-access-policy.service.ts | 18 +++++++- src/forums/forums-command.service.spec.ts | 43 +++++++++++++++++++ src/forums/forums-command.service.ts | 11 +++-- .../forums-moderation.integration.spec.ts | 4 +- 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ce151ab..bd9f144 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Topic reads are exposed under `read:forums-topics`. `GET /v6/forums/topics` retu 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. +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. 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. diff --git a/src/forums/forums-access-policy.service.spec.ts b/src/forums/forums-access-policy.service.spec.ts index 155907a..ae6aeeb 100644 --- a/src/forums/forums-access-policy.service.spec.ts +++ b/src/forums/forums-access-policy.service.spec.ts @@ -233,7 +233,10 @@ describe('ForumsAccessPolicyService', () => { expect(topicDecisions.canView).toEqual({ allowed: true }); expect(topicDecisions.canUpdateTopic).toEqual({ allowed: true }); - expect(topicDecisions.canDeleteTopic).toEqual({ allowed: true }); + expect(topicDecisions.canDeleteTopic).toEqual({ + allowed: false, + reason: 'Only an administrator may delete a topic.', + }); expect(topicDecisions.canControlAnnouncement).toEqual({ allowed: true }); expect(postDecisions.canUpdatePost).toEqual({ allowed: true }); expect(postDecisions.canDeletePost).toEqual({ allowed: true }); @@ -319,9 +322,28 @@ describe('ForumsAccessPolicyService', () => { expect(createDecisions.canControlAnnouncement).toEqual({ allowed: true }); expect(topicDecisions.canView).toEqual({ allowed: true }); expect(topicDecisions.canUpdateTopic).toEqual({ allowed: true }); + expect(topicDecisions.canDeleteTopic.allowed).toBe(false); expect(topicDecisions.canControlAnnouncement).toEqual({ allowed: true }); }); + it('lets an author edit but not delete their own topic', async () => { + const { service } = createPolicyHarness(); + const decisions = await service.decideForTopic( + makePrincipal({ memberId: 'author-1' }), + makeTopicContext({ + effectiveChallengeId: null, + effectiveRoleName: null, + isTopicAuthor: true, + }), + ); + + expect(decisions.canUpdateTopic).toEqual({ allowed: true }); + expect(decisions.canDeleteTopic).toEqual({ + allowed: false, + reason: 'Only an administrator may delete a topic.', + }); + }); + it('allows general public child-topic creation', async () => { const { service } = createPolicyHarness(); const principal = makePrincipal(); @@ -490,6 +512,8 @@ describe('ForumsAccessPolicyService', () => { 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( challengeAccessService.getChallengeAccessFacts, ).not.toHaveBeenCalled(); diff --git a/src/forums/forums-access-policy.service.ts b/src/forums/forums-access-policy.service.ts index 88e430e..cdd4712 100644 --- a/src/forums/forums-access-policy.service.ts +++ b/src/forums/forums-access-policy.service.ts @@ -85,6 +85,7 @@ export class ForumsAccessPolicyService { : this.deny( 'Only the author or an elevated forums actor may modify it.', ); + const canDeleteTopic = this.evaluateTopicDeletion(evaluation); const canControlAnnouncement = this.evaluateAnnouncementControl(evaluation); return { @@ -93,7 +94,7 @@ export class ForumsAccessPolicyService { canCreateChildTopic: evaluation.visibility, canCreatePost: evaluation.visibility, canUpdateTopic: canMutateContent, - canDeleteTopic: canMutateContent, + canDeleteTopic, canAddWatch: evaluation.visibility, canRemoveWatch: evaluation.visibility, canMarkRead: evaluation.visibility, @@ -496,6 +497,21 @@ export class ForumsAccessPolicyService { : this.deny('Announcement control requires elevated forums access.'); } + /** + * Applies legacy-compatible topic deletion rules independently of editing. + * + * @param evaluation Restriction evaluation for the persisted topic. + * @returns Decision allowing administrators and scoped M2M callers, but not authors or challenge copilots. + * @throws Does not throw. + */ + private evaluateTopicDeletion( + evaluation: RestrictionEvaluation, + ): ForumsAccessDecision { + return evaluation.isElevated && !evaluation.isChallengeCopilot + ? this.allow() + : this.deny('Only an administrator may delete a topic.'); + } + /** * Applies the approved root-topic creation rules. * diff --git a/src/forums/forums-command.service.spec.ts b/src/forums/forums-command.service.spec.ts index 0b22530..22c21af 100644 --- a/src/forums/forums-command.service.spec.ts +++ b/src/forums/forums-command.service.spec.ts @@ -1,6 +1,7 @@ import { Logger } from '@nestjs/common'; import { JwtUser } from '../auth/jwt.service'; import { Post, PostReactionType, Topic } from '../../prisma/generated/client'; +import { CreatePostDto, UpdateTopicDto } from './dto/forums-command.dto'; import { ForumsCommandService } from './forums-command.service'; const createdAt = new Date('2026-06-04T00:00:00.000Z'); @@ -277,6 +278,22 @@ describe('ForumsCommandService notification boundary', () => { ); }); + it('treats transformed undefined post parent fields as omitted', async () => { + const { service, tx } = createCommandService(); + const dto = new CreatePostDto(); + dto.content = 'Persisted content'; + + await expect(service.createPost('topic-1', dto, user)).resolves.toEqual( + expect.objectContaining({ id: 'post-1' }), + ); + expect(tx.post.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + parentId: 'topic-1', + parentType: 'TOPIC', + }), + }); + }); + it('attempts starter-post notification for a child createTopic command', async () => { const order: string[] = []; const { notificationService, service, tx } = createCommandService(order); @@ -498,6 +515,32 @@ describe('ForumsCommandService notification boundary', () => { expect(tx.topic.update).not.toHaveBeenCalled(); }); + it('ignores transformed undefined announcement state during a title-only update', async () => { + const { accessPolicyService, service, tx } = createCommandService(); + accessPolicyService.decideForTopic.mockResolvedValue({ + canAddWatch: { allowed: true }, + canCreatePost: { allowed: true }, + canControlAnnouncement: { + allowed: false, + reason: 'Announcement control requires elevated forums access.', + }, + canDeleteTopic: { allowed: false }, + canMarkRead: { allowed: true }, + canRemoveWatch: { allowed: true }, + canUpdateTopic: { allowed: true }, + }); + const dto = new UpdateTopicDto(); + dto.title = 'Updated title'; + + await expect(service.updateTopic('topic-1', dto, user)).resolves.toEqual( + expect.objectContaining({ id: 'topic-1' }), + ); + expect(tx.topic.update).toHaveBeenCalledWith({ + data: { title: 'Updated title' }, + where: { id: 'topic-1' }, + }); + }); + it('rejects locked role updates before restriction or descendant checks', async () => { const { accessPolicyService, db, moderationService, service } = createCommandService(); diff --git a/src/forums/forums-command.service.ts b/src/forums/forums-command.service.ts index 3c972b9..fea1b48 100644 --- a/src/forums/forums-command.service.ts +++ b/src/forums/forums-command.service.ts @@ -1595,17 +1595,22 @@ export class ForumsCommandService { } /** - * Checks whether an object owns a property supplied by the request body. + * Checks whether an object owns a defined property supplied by the request body. + * Class-transformer DTO instances can own optional fields whose runtime value + * is `undefined`; those fields remain omitted for command semantics. * * @param value Object to inspect. * @param key Property key to check. - * @returns `true` when the property exists on the object. + * @returns `true` when the property exists and is not `undefined`. * @throws Does not throw. */ private hasOwn( value: T, key: K, ): value is T & Record { - return Object.prototype.hasOwnProperty.call(value, key); + return ( + Object.prototype.hasOwnProperty.call(value, key) && + (value as T & Record)[key] !== undefined + ); } } diff --git a/src/forums/forums-moderation.integration.spec.ts b/src/forums/forums-moderation.integration.spec.ts index fada418..f8d6dc0 100644 --- a/src/forums/forums-moderation.integration.spec.ts +++ b/src/forums/forums-moderation.integration.spec.ts @@ -195,7 +195,7 @@ function createModerationDb(seed: ModerationSeedData) { memberBanCounter += 1; seed.memberBans.push(ban); - return ban; + return Promise.resolve(ban); }, update: (args: { where: { id: string }; @@ -243,7 +243,7 @@ function createModerationDb(seed: ModerationSeedData) { ipBanCounter += 1; seed.ipBans.push(ban); - return ban; + return Promise.resolve(ban); }, update: (args: { where: { id: string }; From 0d28a94688e793e90f39b4709afcb3b313efda5e Mon Sep 17 00:00:00 2001 From: jmgasper Date: Sat, 5 Sep 2026 09:49:06 +1000 Subject: [PATCH 7/9] fix: address forum QA follow-ups --- README.md | 11 +- src/config/notifications.config.ts | 6 +- src/forums/dto/forums-read.dto.ts | 11 +- src/forums/event-bus.service.spec.ts | 69 +++++++++++ src/forums/event-bus.service.ts | 95 ++++++++++++++- .../forums-access-policy.service.spec.ts | 40 +++++- src/forums/forums-access-policy.service.ts | 20 ++- ...rums-notification-read.integration.spec.ts | 113 +++++++++++++++++ src/forums/forums-read.service.ts | 115 ++++++++++++------ src/forums/posts.controller.ts | 2 +- src/forums/resource-access.service.ts | 35 ++++++ src/forums/topics.controller.ts | 2 +- src/main.ts | 2 +- 13 files changed, 459 insertions(+), 62 deletions(-) create mode 100644 src/forums/event-bus.service.spec.ts 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({ From 5c3647ad94d1522c4b5f372843e74a1ad1cbd922 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Mon, 7 Sep 2026 16:31:37 +1000 Subject: [PATCH 8/9] chore: format forum QA changes Apply the repository ESLint and Prettier rules to the PM-6182 and PM-6183 API coverage before merge. --- src/forums/dto/forums-read.dto.ts | 3 ++- src/forums/event-bus.service.spec.ts | 3 +-- ...rums-notification-read.integration.spec.ts | 21 ++++++++----------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/forums/dto/forums-read.dto.ts b/src/forums/dto/forums-read.dto.ts index 982cc45..109fba4 100644 --- a/src/forums/dto/forums-read.dto.ts +++ b/src/forums/dto/forums-read.dto.ts @@ -318,7 +318,8 @@ export class ForumsPostTreeNodeDto { viewerReaction: PostReactionType | null; @ApiProperty({ - description: 'Nested replies ordered chronologically from oldest to newest.', + description: + 'Nested replies ordered chronologically from oldest to newest.', type: () => [ForumsPostTreeNodeDto], }) replies: ForumsPostTreeNodeDto[]; diff --git a/src/forums/event-bus.service.spec.ts b/src/forums/event-bus.service.spec.ts index f477d52..726167c 100644 --- a/src/forums/event-bus.service.spec.ts +++ b/src/forums/event-bus.service.spec.ts @@ -31,8 +31,7 @@ describe('resolveV6BusApiBase', () => { expect( resolveV6BusApiBase( configWith({ - 'notifications.topcoderApiUrlBase': - 'https://api.topcoder-dev.com/', + 'notifications.topcoderApiUrlBase': 'https://api.topcoder-dev.com/', }), ), ).toBe('https://api.topcoder-dev.com/v6'); diff --git a/src/forums/forums-notification-read.integration.spec.ts b/src/forums/forums-notification-read.integration.spec.ts index 59bc590..0c956ad 100644 --- a/src/forums/forums-notification-read.integration.spec.ts +++ b/src/forums/forums-notification-read.integration.spec.ts @@ -174,8 +174,7 @@ function createSeededForumsDb(seed: SeededForumsData) { updatedAt: post.updatedAt, deletedAt: post.deletedAt, thumbsUpCount: reactions.filter( - (reaction) => - reaction.reaction === PostReactionType.THUMBS_UP, + (reaction) => reaction.reaction === PostReactionType.THUMBS_UP, ).length, thumbsDownCount: reactions.filter( (reaction) => @@ -307,9 +306,7 @@ function createSeededForumsDb(seed: SeededForumsData) { seed.postReactions.push(args.create); return args.create; }, - deleteMany: (args: { - where: { postId: string; memberId: string }; - }) => { + deleteMany: (args: { where: { postId: string; memberId: string } }) => { const index = seed.postReactions.findIndex( (reaction) => reaction.postId === args.where.postId && @@ -914,9 +911,7 @@ describe('forums notification/read integration', () => { locked: false, lockedBy: null, lockedAt: null, - participants: [ - { handle: 'author', memberId: '1' }, - ], + participants: [{ handle: 'author', memberId: '1' }], participantsCount: 1, starterPostExcerpt: 'Restricted starter content', viewsCount: 1, @@ -1089,9 +1084,9 @@ describe('forums notification/read integration', () => { '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].replies.map((post: Post) => post.id)).toEqual( + ['reply-oldest', 'reply-newest'], + ); expect(response.body.posts[0]).toEqual( expect.objectContaining({ authorIsCopilot: true }), ); @@ -1104,7 +1099,9 @@ describe('forums notification/read integration', () => { }); it('shares post reaction counts while preserving each member viewer state', async () => { - seedData.posts.push(makePost({ topicId: 'parent-1', parentId: 'parent-1' })); + seedData.posts.push( + makePost({ topicId: 'parent-1', parentId: 'parent-1' }), + ); seedData.postReactions.push( { postId: 'post-1', From c1778d7acba7ad1daa59e3f8e7b7b70dbaf46b3d Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 8 Sep 2026 14:29:23 +1000 Subject: [PATCH 9/9] Get challenge title for forum notifications --- README.md | 38 ++++++ docs/email-templates/forum-notification.html | 67 +++++++++ .../forum-notification.sample.json | 9 ++ src/config/notifications.config.ts | 3 +- src/forums/challenge-api.service.spec.ts | 129 ++++++++++++++++++ src/forums/challenge-api.service.ts | 121 ++++++++++++++++ .../forums-watch-notification.service.spec.ts | 98 ++++++++++++- .../forums-watch-notification.service.ts | 47 ++++++- src/forums/forums.module.ts | 2 + src/types/tc-core-library-js.d.ts | 14 ++ 10 files changed, 521 insertions(+), 7 deletions(-) create mode 100644 docs/email-templates/forum-notification.html create mode 100644 docs/email-templates/forum-notification.sample.json create mode 100644 src/forums/challenge-api.service.spec.ts create mode 100644 src/forums/challenge-api.service.ts diff --git a/README.md b/README.md index d37b2e2..1410d93 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,42 @@ list and `SENDGRID_NOTIFICATION_TEMPLATE`. Missing template or missing recipient email skips notification delivery and is logged without rolling back the content write. +For challenge-scoped notifications, the publisher fetches the effective challenge +from `GET /v6/challenges/:challengeId` using the configured M2M credentials and +includes its `name` as `data.challengeTitle`. This lookup runs once per outgoing +notification, after recipient authorization, and uses a five-second HTTP timeout. +Lookup failures are logged and the email still publishes with `challengeId` but +without `challengeTitle`. + +The [forum notification HTML template](docs/email-templates/forum-notification.html) +adapts the Topcoder support email design for these notifications. Use the subject +`New forum post: {{topicTitle}}` in SendGrid and paste the HTML into the template's +code editor. The HTML `` does not configure the email subject. After +activating the template version, configure its ID as +`SENDGRID_NOTIFICATION_TEMPLATE`. + +Template variables come from the event payload's `data` object: + +| Field | Value | +| --- | --- | +| `challengeId` | Effective challenge ID; omitted when absent. The template hides this row when absent. | +| `challengeTitle` | Challenge API `name`; omitted for non-challenge topics or failed lookups. The template hides this row when absent. | +| `topicId` | Created content's topic ID. | +| `topicTitle` | Topic title. | +| `postContent` | Persisted post content, or an empty string when null. | +| `authorHandle` | Persisted post author's handle. | +| `createdAt` | Post creation timestamp in UTC ISO 8601 format. | + +Use the [sample template data](docs/email-templates/forum-notification.sample.json) +in SendGrid's preview editor; it contains only the `data` fields, without the +event envelope. Remove `challengeId` and `challengeTitle` to preview a non-challenge notification. +User content uses escaped double-brace substitutions and is displayed as text, +with line breaks preserved where the email client supports `white-space: pre-wrap`; +Markdown and HTML are not rendered. See SendGrid's +[Handlebars documentation](https://www.twilio.com/docs/sendgrid/for-developers/sending-email/using-handlebars) +for substitution and conditional syntax. The current payload has no discussion +URL, so the template currently displays the topic ID without a discussion link. + ## Environment ```bash @@ -78,6 +114,7 @@ SENDGRID_NOTIFICATION_TEMPLATE="sendgrid-template-id" BUSAPI_URL="https://api.topcoder-dev.com/v6" BUS_API_URL="https://api.topcoder-dev.com/v6/bus/events" TOPCODER_API_URL_BASE="https://api.topcoder-dev.com" +CHALLENGE_API_URL="https://api.topcoder-dev.com/v6/challenges" KAFKA_ERROR_TOPIC="common.error.reporting" AUTH0_URL="https://auth.topcoder-dev.com/" AUTH0_AUDIENCE="https://m2m.topcoder-dev.com/" @@ -97,6 +134,7 @@ PORT=3000 `VANILLA_DB_URL` is used only by the standalone Vanilla import CLI for legacy MySQL reads. The runtime HTTP service does not connect to Vanilla. `AUTH_SECRET` is required; the service fails during startup when it is omitted. `SENDGRID_NOTIFICATION_TEMPLATE` enables forum watch notification emails. When omitted, notification publishing is skipped and content writes still succeed. +`CHALLENGE_API_URL` optionally configures the challenges collection endpoint for notification titles. When omitted, it defaults to `${TOPCODER_API_URL_BASE}/v6/challenges`. The lookup uses the same Auth0 configuration and `M2M_CLIENT_ID` / `M2M_CLIENT_SECRET` as outbound bus publishing; the M2M client must have challenge read access. `BUSAPI_URL` configures the shared Bus API v6 base for `external.action.email`; the backwards-compatible `BUS_API_URL` alias may contain either that base or the complete `/v6/bus/events` endpoint. Both values are normalized to the `/v6` base because `tc-bus-api-wrapper` appends `/bus/events`, and conflicting aliases or legacy `/eventBus` and `/v5` values are rejected. When neither alias is set, the service derives the v6 base from `TOPCODER_API_URL_BASE`. `KAFKA_ERROR_TOPIC`, `AUTH0_URL`, `AUTH0_AUDIENCE`, `TOKEN_CACHE_TIME`, `M2M_CLIENT_ID`, `M2M_CLIENT_SECRET`, and `AUTH0_PROXY_SERVER_URL` are passed to the standard bus wrapper for outbound authenticated publishing. `TRUST_FORWARDED_CLIENT_IP=true` enables forwarded client-IP moderation using the first exact IPv4/IPv6 host from trusted forwarding headers. When disabled, or when the forwarded value is missing, malformed, CIDR, wildcard, or otherwise non-exact, no client IP is resolved and IP-ban enforcement is skipped for that request. Do not enable this unless the service is behind infrastructure that strips or controls inbound forwarding headers. diff --git a/docs/email-templates/forum-notification.html b/docs/email-templates/forum-notification.html new file mode 100644 index 0000000..2e3932b --- /dev/null +++ b/docs/email-templates/forum-notification.html @@ -0,0 +1,67 @@ +<!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}} + + + + +
+ {{authorHandle}} posted in {{topicTitle}} on Topcoder. +
+ + + + +
+ + + + + + + + + + + +
+ Topcoder +
+

New forum post

+

{{authorHandle}} posted in {{topicTitle}} on Topcoder.

+ +

Post details

+

Topic: {{topicTitle}}

+

Topic ID: {{topicId}}

+ {{#if challengeTitle}} +

Challenge: {{challengeTitle}}

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

Challenge ID: {{challengeId}}

+ {{/if}} +

Author: {{authorHandle}}

+

Posted at (UTC): {{createdAt}}

+ +

Post content

+ + + + +
{{postContent}}
+ +

You received this notification because you watch this topic or one of its parent topics.

+

Thanks,
Topcoder

+
+ Copyright © Topcoder, All rights reserved. +
+ +
+ + diff --git a/docs/email-templates/forum-notification.sample.json b/docs/email-templates/forum-notification.sample.json new file mode 100644 index 0000000..4b08282 --- /dev/null +++ b/docs/email-templates/forum-notification.sample.json @@ -0,0 +1,9 @@ +{ + "challengeId": "12345678-1234-4234-8234-123456789abc", + "challengeTitle": "Topcoder Forum Export Challenge", + "topicId": "87654321-4321-4321-8321-cba987654321", + "topicTitle": "Challenge questions and clarifications", + "postContent": "Hi everyone,\n\nThe requirements have been updated with an example for the export flow. Please review the discussion and share any questions.\n\nThanks!", + "authorHandle": "sampleCopilot", + "createdAt": "2026-09-08T04:30:00.000Z" +} diff --git a/src/config/notifications.config.ts b/src/config/notifications.config.ts index d8dff96..3d516fb 100644 --- a/src/config/notifications.config.ts +++ b/src/config/notifications.config.ts @@ -5,7 +5,7 @@ * notification publisher. Missing template configuration disables notification * publishing without affecting forum writes. * - * @returns SendGrid template, Bus API aliases/base, Kafka error topic, and Auth0 M2M values. + * @returns SendGrid template, Bus and Challenge API bases, Kafka error topic, and Auth0 M2M values. * @throws Does not throw; consumers decide whether optional values are required. */ export default () => ({ @@ -14,6 +14,7 @@ export default () => ({ busApiUrl: process.env.BUSAPI_URL, busApiUrlAlias: process.env.BUS_API_URL, topcoderApiUrlBase: process.env.TOPCODER_API_URL_BASE, + challengeApiUrl: process.env.CHALLENGE_API_URL, kafkaErrorTopic: process.env.KAFKA_ERROR_TOPIC, auth0Url: process.env.AUTH0_URL, auth0Audience: process.env.AUTH0_AUDIENCE, diff --git a/src/forums/challenge-api.service.spec.ts b/src/forums/challenge-api.service.spec.ts new file mode 100644 index 0000000..05e4d7d --- /dev/null +++ b/src/forums/challenge-api.service.spec.ts @@ -0,0 +1,129 @@ +import { ConfigService } from '@nestjs/config'; +import { auth } from 'tc-core-library-js'; +import { ChallengeApiService } from './challenge-api.service'; + +/** + * Creates a Challenge API adapter with test M2M and endpoint configuration. + * + * @param overrides Configuration replacements, including undefined for missing settings. + * @returns Service configured for mocked outbound requests. + * @throws Does not throw. + */ +function createService(overrides: Record = {}) { + const values = { + 'notifications.topcoderApiUrlBase': 'https://api.topcoder-dev.com/', + 'notifications.auth0Url': 'https://auth.example.com/oauth/token', + 'notifications.auth0Audience': 'https://m2m.example.com/', + 'notifications.tokenCacheTime': '86400', + 'notifications.auth0ProxyServerUrl': 'https://proxy.example.com/token', + 'notifications.m2mClientId': 'client-id', + 'notifications.m2mClientSecret': 'client-secret', + ...overrides, + }; + return new ChallengeApiService({ + get: jest.fn((key: string) => values[key]), + } as unknown as ConfigService); +} + +describe('ChallengeApiService', () => { + let fetchSpy: jest.SpiedFunction; + let m2mSpy: jest.SpiedFunction; + let getMachineToken: jest.Mock; + + beforeEach(() => { + getMachineToken = jest.fn().mockResolvedValue('machine-token'); + m2mSpy = jest.spyOn(auth, 'm2m').mockReturnValue({ getMachineToken }); + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(Response.json({ id: 'challenge-1', name: 'Challenge title' })); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('looks up the challenge name using the configured M2M credentials', async () => { + const service = createService(); + + expect(m2mSpy).not.toHaveBeenCalled(); + await expect(service.getChallengeTitle('challenge-1')).resolves.toBe( + 'Challenge title', + ); + expect(m2mSpy).toHaveBeenCalledWith({ + AUTH0_URL: 'https://auth.example.com/oauth/token', + AUTH0_AUDIENCE: 'https://m2m.example.com/', + TOKEN_CACHE_TIME: 86400, + AUTH0_PROXY_SERVER_URL: 'https://proxy.example.com/token', + }); + expect(getMachineToken).toHaveBeenCalledWith('client-id', 'client-secret'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.topcoder-dev.com/v6/challenges/challenge-1', + { + headers: { Authorization: 'Bearer machine-token' }, + signal: expect.any(AbortSignal), + }, + ); + }); + + it('uses an explicit collection URL and encodes the challenge id', async () => { + const service = createService({ + 'notifications.challengeApiUrl': ' http://localhost:4000/v6/challenges/ ', + }); + + await service.getChallengeTitle('challenge/id?query'); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:4000/v6/challenges/challenge%2Fid%3Fquery', + expect.any(Object), + ); + }); + + it.each([ + ['notifications.topcoderApiUrlBase', 'CHALLENGE_API_URL'], + ['notifications.m2mClientId', 'M2M_CLIENT_ID'], + ['notifications.m2mClientSecret', 'M2M_CLIENT_SECRET'], + ])('fails before outbound requests when %s is missing', async (key, message) => { + await expect( + createService({ [key]: undefined }).getChallengeTitle('challenge-1'), + ).rejects.toThrow(message); + expect(getMachineToken).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('propagates token failures without calling the Challenge API', async () => { + getMachineToken.mockRejectedValue(new Error('Token unavailable')); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toThrow( + 'Token unavailable', + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([403, 404, 500])('rejects HTTP %s responses', async (status) => { + fetchSpy.mockResolvedValue(new Response(null, { status })); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toThrow( + `Challenge API returned HTTP ${status}`, + ); + }); + + it.each([null, {}, { name: '' }, { name: ' ' }, { name: 123 }])( + 'rejects a response without a usable title: %p', + async (body) => { + fetchSpy.mockResolvedValue(Response.json(body)); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toThrow( + 'no usable challenge name', + ); + }, + ); + + it('propagates HTTP timeouts for best-effort notification handling', async () => { + const timeoutError = new DOMException('Request timed out', 'TimeoutError'); + fetchSpy.mockRejectedValue(timeoutError); + + await expect(createService().getChallengeTitle('challenge-1')).rejects.toBe( + timeoutError, + ); + }); +}); diff --git a/src/forums/challenge-api.service.ts b/src/forums/challenge-api.service.ts new file mode 100644 index 0000000..6296542 --- /dev/null +++ b/src/forums/challenge-api.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { auth } from 'tc-core-library-js'; + +const CHALLENGE_REQUEST_TIMEOUT_MS = 5_000; + +/** + * Looks up challenge titles for forum notifications through the Challenge API. + * The shared M2M library reuses cached tokens for the bus publisher's credentials; + * client initialization is lazy so missing outbound configuration does not block startup. + */ +@Injectable() +export class ChallengeApiService { + private m2mClient?: ReturnType; + + /** + * Creates the notification challenge lookup adapter. + * + * @param configService Notification API and Auth0 M2M configuration. + * @throws Does not throw; configuration is validated on lookup. + */ + constructor(private readonly configService: ConfigService) {} + + /** + * Fetches a challenge's name for the notification's `challengeTitle` field. + * + * @param challengeId Effective challenge id inherited by the notified topic. + * @returns Non-empty challenge name from the Challenge API. + * @throws Error for missing configuration, token failures, unsuccessful or invalid + * API responses, and HTTP requests exceeding five seconds. The publisher catches + * these errors and sends the notification without a challenge title. + */ + async getChallengeTitle(challengeId: string): Promise { + const apiBase = this.resolveApiBase(); + const clientId = this.configService.get( + 'notifications.m2mClientId', + ); + const clientSecret = this.configService.get( + 'notifications.m2mClientSecret', + ); + + if (!clientId || !clientSecret) { + throw new Error( + 'M2M_CLIENT_ID and M2M_CLIENT_SECRET must configure Challenge API access.', + ); + } + + if (!this.m2mClient) { + const tokenCacheTime = Number( + this.configService.get('notifications.tokenCacheTime'), + ); + this.m2mClient = auth.m2m({ + AUTH0_URL: this.configService.get('notifications.auth0Url'), + AUTH0_AUDIENCE: this.configService.get( + 'notifications.auth0Audience', + ), + TOKEN_CACHE_TIME: Number.isFinite(tokenCacheTime) + ? tokenCacheTime + : undefined, + AUTH0_PROXY_SERVER_URL: this.configService.get( + 'notifications.auth0ProxyServerUrl', + ), + }); + } + + const token = await this.m2mClient.getMachineToken(clientId, clientSecret); + const response = await fetch( + `${apiBase}/${encodeURIComponent(challengeId)}`, + { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(CHALLENGE_REQUEST_TIMEOUT_MS), + }, + ); + + if (!response.ok) { + throw new Error(`Challenge API returned HTTP ${response.status}.`); + } + + const challenge: unknown = await response.json(); + + if ( + !challenge || + typeof challenge !== 'object' || + !('name' in challenge) || + typeof challenge.name !== 'string' || + !challenge.name.trim() + ) { + throw new Error('Challenge API returned no usable challenge name.'); + } + + return challenge.name; + } + + /** + * Resolves the challenges collection URL from explicit or shared API settings. + * + * @returns API base without trailing slashes, ready for a challenge id suffix. + * @throws Error when neither CHALLENGE_API_URL nor TOPCODER_API_URL_BASE is set. + */ + private resolveApiBase(): string { + const configured = this.configService + .get('notifications.challengeApiUrl') + ?.trim(); + + if (configured) { + return configured.replace(/\/+$/, ''); + } + + const sharedBase = this.configService + .get('notifications.topcoderApiUrlBase') + ?.trim(); + + if (!sharedBase) { + throw new Error( + 'CHALLENGE_API_URL or TOPCODER_API_URL_BASE must configure the Challenge API.', + ); + } + + return `${sharedBase.replace(/\/+$/, '')}/v6/challenges`; + } +} diff --git a/src/forums/forums-watch-notification.service.spec.ts b/src/forums/forums-watch-notification.service.spec.ts index 69e1513..7affc0f 100644 --- a/src/forums/forums-watch-notification.service.spec.ts +++ b/src/forums/forums-watch-notification.service.spec.ts @@ -100,6 +100,9 @@ function createService(templateId: string | null = 'template-id') { const eventBusService = { postEvent: jest.fn().mockResolvedValue(undefined), }; + const challengeApiService = { + getChallengeTitle: jest.fn().mockResolvedValue('Challenge title'), + }; const configService = { get: jest.fn((key: string) => key === 'notifications.sendgridNotificationTemplate' @@ -115,10 +118,12 @@ function createService(templateId: string | null = 'template-id') { moderationService as any, eventBusService as any, configService as any, + challengeApiService as any, ); return { accessPolicyService, + challengeApiService, configService, db, eventBusService, @@ -135,7 +140,7 @@ describe('ForumsWatchNotificationService', () => { }); it('dedupes topic and ancestor watches into one recipient email event', async () => { - const { db, eventBusService, service } = createService(); + const { challengeApiService, db, eventBusService, service } = createService(); db.topicWatch.findMany.mockResolvedValue([ { memberId: '2' }, { memberId: '2' }, @@ -161,6 +166,10 @@ describe('ForumsWatchNotificationService', () => { select: { memberId: true }, }); expect(eventBusService.postEvent).toHaveBeenCalledTimes(1); + expect(challengeApiService.getChallengeTitle).not.toHaveBeenCalled(); + expect(eventBusService.postEvent.mock.calls[0][1].data).not.toHaveProperty( + 'challengeTitle', + ); expect(eventBusService.postEvent).toHaveBeenCalledWith( 'external.action.email', expect.objectContaining({ @@ -197,7 +206,8 @@ describe('ForumsWatchNotificationService', () => { }); it('filters watched members who cannot view a restricted child topic', async () => { - const { accessPolicyService, eventBusService, service } = createService(); + const { accessPolicyService, challengeApiService, eventBusService, service } = + createService(); accessPolicyService.decideForRestrictionVisibility.mockResolvedValue({ allowed: false, reason: 'Required forum role is missing.', @@ -226,6 +236,7 @@ describe('ForumsWatchNotificationService', () => { }, ); expect(eventBusService.postEvent).not.toHaveBeenCalled(); + expect(challengeApiService.getChallengeTitle).not.toHaveBeenCalled(); }); it('filters banned watched members out of recipient email events', async () => { @@ -427,6 +438,7 @@ describe('ForumsWatchNotificationService', () => { }); expect(missingTemplate.db.topicClosure.findMany).not.toHaveBeenCalled(); + expect(missingTemplate.challengeApiService.getChallengeTitle).not.toHaveBeenCalled(); expect(missingTemplate.eventBusService.postEvent).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining( @@ -466,4 +478,86 @@ describe('ForumsWatchNotificationService', () => { }), ); }); + + it.each(['createPost', 'createTopic'] as const)( + 'includes the inherited challenge title once for all %s recipients', + async (operationName) => { + const { + challengeApiService, + db, + eventBusService, + memberDirectoryService, + service, + } = createService(); + db.topicWatch.findMany.mockResolvedValue([ + { memberId: '2' }, + { memberId: '3' }, + ]); + memberDirectoryService.getMembersByIds.mockResolvedValue([ + { memberId: '2', email: 'two@example.com', handle: 'two' }, + { memberId: '3', email: 'three@example.com', handle: 'three' }, + ]); + + await service.publishPostNotification({ + topic: makeTopic({ parentTopicId: 'root-1', challengeId: null }), + post: makePost(), + restrictions: { + challengeId: 'challenge-1', + roleName: null, + hasRestrictionConflict: false, + }, + operationName, + }); + + expect(challengeApiService.getChallengeTitle).toHaveBeenCalledTimes(1); + expect(challengeApiService.getChallengeTitle).toHaveBeenCalledWith( + 'challenge-1', + ); + expect(eventBusService.postEvent).toHaveBeenCalledWith( + 'external.action.email', + { + data: { + challengeId: 'challenge-1', + challengeTitle: 'Challenge title', + topicId: 'topic-1', + topicTitle: 'Topic title', + postContent: 'Persisted post content', + authorHandle: 'author', + createdAt: createdAt.toISOString(), + }, + recipients: ['two@example.com', 'three@example.com'], + sendgrid_template_id: 'template-id', + version: 'v3', + }, + ); + }, + ); + + it('still publishes without a title when the challenge lookup fails', async () => { + const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const { challengeApiService, eventBusService, service } = createService(); + challengeApiService.getChallengeTitle.mockRejectedValue( + new Error('Challenge API unavailable'), + ); + + const result = await service.publishPostNotification({ + topic: makeTopic(), + post: makePost(), + restrictions: { + challengeId: 'challenge-1', + roleName: null, + hasRestrictionConflict: false, + }, + operationName: 'createPost', + }); + + expect(result).toEqual({ attemptedRecipientCount: 1, published: true }); + expect(eventBusService.postEvent).toHaveBeenCalledTimes(1); + const payload = eventBusService.postEvent.mock.calls[0][1]; + expect(payload.data.challengeId).toBe('challenge-1'); + expect(payload.data).not.toHaveProperty('challengeTitle'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('challenge challenge-1 title lookup failed'), + ); + }); }); diff --git a/src/forums/forums-watch-notification.service.ts b/src/forums/forums-watch-notification.service.ts index c65c3e3..10e6518 100644 --- a/src/forums/forums-watch-notification.service.ts +++ b/src/forums/forums-watch-notification.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Post, Topic } from '../../prisma/generated/client'; import { DbService } from '../db/db.service'; +import { ChallengeApiService } from './challenge-api.service'; import { EventBusService } from './event-bus.service'; import { ForumsAccessPolicyService } from './forums-access-policy.service'; import { @@ -72,9 +73,9 @@ export class ForumsNotificationPublishError extends Error { * The service resolves explicit watches on the created post's topic and all * ancestors, dedupes by member id, excludes the persisted author member id, * filters active member bans and remaining recipients through the shared forums - * access policy, and publishes one SendGrid email event for the final recipient - * list. IP-ban checks are intentionally excluded because notification delivery - * is not bound to a trusted request client IP. + * access policy, resolves the challenge title when applicable, and publishes one + * SendGrid email event for the final recipient list. IP-ban checks are intentionally + * excluded because notification delivery is not bound to a trusted request client IP. */ @Injectable() export class ForumsWatchNotificationService { @@ -90,6 +91,7 @@ export class ForumsWatchNotificationService { * @param moderationService Shared runtime member-ban gate. * @param eventBusService Local event-bus adapter. * @param configService Nest configuration service containing notification settings. + * @param challengeApiService M2M-authenticated lookup for challenge titles. * @throws Does not throw directly; dependencies are resolved by Nest. */ constructor( @@ -100,6 +102,7 @@ export class ForumsWatchNotificationService { private readonly moderationService: ForumsModerationService, private readonly eventBusService: EventBusService, private readonly configService: ConfigService, + private readonly challengeApiService: ChallengeApiService, ) {} /** @@ -157,7 +160,13 @@ export class ForumsWatchNotificationService { return { attemptedRecipientCount: 0, published: false }; } - const payload = this.buildEmailPayload(params, templateId, recipientEmails); + const challengeTitle = await this.resolveChallengeTitle(params); + const payload = this.buildEmailPayload( + params, + templateId, + recipientEmails, + challengeTitle, + ); try { await this.eventBusService.postEvent(EMAIL_EVENT_TOPIC, payload); @@ -364,12 +373,40 @@ export class ForumsWatchNotificationService { return results; } + /** + * Looks up the effective challenge title once per outgoing notification. + * + * @param params Current notification parameters with inherited challenge context. + * @returns Challenge title, or `undefined` for non-challenge topics or lookup failures. + * @throws Does not throw; lookup failures are logged so email delivery continues. + */ + private async resolveChallengeTitle( + params: PublishForumsPostNotificationParams, + ): Promise { + const challengeId = params.restrictions.challengeId; + + if (!challengeId) { + return undefined; + } + + try { + return await this.challengeApiService.getChallengeTitle(challengeId); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + this.logger.warn( + `${params.operationName} notification for topic ${params.topic.id}, post ${params.post.id}: challenge ${challengeId} title lookup failed: ${message}`, + ); + return undefined; + } + } + /** * Builds the SendGrid event payload from persisted topic and post rows. * * @param params Current notification publish parameters. * @param templateId Configured SendGrid template id. * @param recipients Final recipient email list. + * @param challengeTitle Challenge API name when the effective challenge resolves. * @returns Event-bus email payload. * @throws Does not throw. */ @@ -377,12 +414,14 @@ export class ForumsWatchNotificationService { params: PublishForumsPostNotificationParams, templateId: string, recipients: string[], + challengeTitle: string | undefined, ): ForumsWatchNotificationEmailPayload { return { data: { ...(params.restrictions.challengeId ? { challengeId: params.restrictions.challengeId } : {}), + ...(challengeTitle ? { challengeTitle } : {}), topicId: params.topic.id, topicTitle: params.topic.title, postContent: params.post.content ?? '', diff --git a/src/forums/forums.module.ts b/src/forums/forums.module.ts index 701543f..be92ae0 100644 --- a/src/forums/forums.module.ts +++ b/src/forums/forums.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { DbModule } from '../db/db.module'; import { ChallengeAccessService } from './challenge-access.service'; +import { ChallengeApiService } from './challenge-api.service'; import { EventBusService } from './event-bus.service'; import { ForumsAccessPolicyService } from './forums-access-policy.service'; import { ForumsCommandService } from './forums-command.service'; @@ -31,6 +32,7 @@ import { TopicsController } from './topics.controller'; controllers: [TopicsController, PostsController, ModerationController], providers: [ ChallengeAccessService, + ChallengeApiService, EventBusService, ForumsAccessPolicyService, ForumsCommandService, diff --git a/src/types/tc-core-library-js.d.ts b/src/types/tc-core-library-js.d.ts index 3caa5e7..81e7816 100644 --- a/src/types/tc-core-library-js.d.ts +++ b/src/types/tc-core-library-js.d.ts @@ -1,4 +1,18 @@ declare module 'tc-core-library-js' { + export const auth: { + m2m: (config: { + AUTH0_URL?: string; + AUTH0_AUDIENCE?: string; + TOKEN_CACHE_TIME?: number; + AUTH0_PROXY_SERVER_URL?: string; + }) => { + getMachineToken: ( + clientId: string, + clientSecret: string, + ) => Promise; + }; + }; + export const middleware: { jwtAuthenticator: (config: { AUTH_SECRET?: string;