feat: Add comment threads to challenges (#2026)

* feat: Add comment threads to challenges

Wire up the full comment/discussion system for challenges:
- Add Thread <-> Challenge relation in Prisma schema with migration
- Create ChallengeDiscussion component with title, sort filter, hidden comments support
- Add comment count to challenge list items, detail page, and active events
- Support challenge entity type in comment connector, hidden toggle, and hidden comments modal
- Add challenge comment notifications (new-challenge-comment) with proper thread URL mapping
- Show comment count badge on ChallengeCard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Smol fixes

* Applies code review feedback

* fix: Use correct owner field when hiding challenge comments

Challenge uses `createdById` not `userId` for its owner field.
The dynamic Prisma select in toggleHideCommentHandler was always
selecting `userId`, causing a validation error for challenge threads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Manuel Emilio Urena
2026-02-12 10:27:14 -04:00
committed by GitHub
parent 9bddb5d25e
commit f251a5b378
12 changed files with 263 additions and 21 deletions
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Thread" ADD COLUMN "challengeId" INTEGER;
-- CreateIndex (unique constraint)
CREATE UNIQUE INDEX "Thread_challengeId_key" ON "Thread"("challengeId");
-- CreateIndex (hash index for lookups)
CREATE INDEX "Thread_challengeId_idx" ON "Thread" USING HASH ("challengeId");
-- AddForeignKey
ALTER TABLE "Thread" ADD CONSTRAINT "Thread_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+4
View File
@@ -2112,6 +2112,8 @@ model Thread {
bountyEntry BountyEntry? @relation(fields: [bountyEntryId], references: [id], onDelete: SetNull)
clubPostId Int? @unique
clubPost ClubPost? @relation(fields: [clubPostId], references: [id], onDelete: SetNull)
challengeId Int? @unique
challenge Challenge? @relation(fields: [challengeId], references: [id], onDelete: SetNull)
metadata Json @default("{}") // unused
commentCount Int @default(0)
@@ -2126,6 +2128,7 @@ model Thread {
@@index([imageId], type: Hash)
@@index([articleId], type: Hash)
@@index([rootThreadId], type: Hash)
@@index([challengeId], type: Hash)
}
model QuestionReaction {
@@ -3791,6 +3794,7 @@ model Challenge {
// Relations
winners ChallengeWinner[]
threads Thread[]
// Event grouping
eventId Int?
+42 -16
View File
@@ -1,6 +1,12 @@
import type { BadgeProps } from '@mantine/core';
import { Badge, Group, Text } from '@mantine/core';
import { IconClockHour4, IconPhoto, IconTrophy, IconSparkles } from '@tabler/icons-react';
import {
IconClockHour4,
IconMessageCircle2,
IconPhoto,
IconTrophy,
IconSparkles,
} from '@tabler/icons-react';
import cardClasses from '~/components/Cards/Cards.module.css';
import { CurrencyBadge } from '~/components/Currency/CurrencyBadge';
import { IconBadge } from '~/components/IconBadge/IconBadge';
@@ -82,6 +88,7 @@ export function ChallengeCard({ data }: Props) {
source,
prizePool,
entryCount,
commentCount,
createdBy,
} = data;
@@ -166,21 +173,40 @@ export function ChallengeCard({ data }: Props) {
className={cardClasses.chip}
style={darkBgStyle}
/>
<IconBadge
icon={<IconPhoto size={14} />}
color="gray.0"
p={0}
px={8}
size="lg"
variant="transparent"
className={cardClasses.chip}
style={darkBgStyle}
radius="xl"
>
<Text fw="bold" size="xs">
{abbreviateNumber(entryCount)}
</Text>
</IconBadge>
<Group gap={4}>
{commentCount > 0 && (
<IconBadge
icon={<IconMessageCircle2 size={14} />}
color="gray.0"
p={0}
px={8}
size="lg"
variant="transparent"
className={cardClasses.chip}
style={darkBgStyle}
radius="xl"
>
<Text fw="bold" size="xs">
{abbreviateNumber(commentCount)}
</Text>
</IconBadge>
)}
<IconBadge
icon={<IconPhoto size={14} />}
color="gray.0"
p={0}
px={8}
size="lg"
variant="transparent"
className={cardClasses.chip}
style={darkBgStyle}
radius="xl"
>
<Text fw="bold" size="xs">
{abbreviateNumber(entryCount)}
</Text>
</IconBadge>
</Group>
</div>
</div>
}
@@ -0,0 +1,116 @@
import { Stack, Group, Text, Loader, Center, Divider, Title, Button } from '@mantine/core';
import { Comment } from '~/components/CommentsV2/Comment/Comment';
import { RootThreadProvider } from '~/components/CommentsV2/CommentsProvider';
import { CreateComment } from '~/components/CommentsV2/Comment/CreateComment';
import { IconMessageCancel } from '@tabler/icons-react';
import { SortFilter } from '~/components/Filters';
import type { ThreadSort } from '~/server/common/enums';
import { ReturnToRootThread } from '~/components/CommentsV2/ReturnToRootThread';
import classes from '~/components/CommentsV2/Comment/Comment.module.css';
import { dialogStore } from '~/components/Dialog/dialogStore';
import HiddenCommentsModal from '~/components/CommentsV2/HiddenCommentsModal';
type Props = {
challengeId: number;
userId?: number;
};
export function ChallengeDiscussion({ challengeId, userId }: Props) {
return (
<RootThreadProvider
entityType="challenge"
entityId={challengeId}
limit={10}
badges={userId ? [{ userId, label: 'op', color: 'violet' }] : []}
>
{({
data,
created,
isLoading,
isFetching,
isFetchingNextPage,
showMore,
hiddenCount,
toggleShowMore,
sort,
setSort,
activeComment,
}) => (
<Stack mt="xl" gap="xl">
<Stack gap={0}>
<Group justify="space-between">
<Group gap="md">
<Title order={2} id="comments">
Discussion
</Title>
{hiddenCount > 0 && !isLoading && (
<Button
variant="subtle"
onClick={() =>
dialogStore.trigger({
component: HiddenCommentsModal,
props: { entityId: challengeId, entityType: 'challenge', userId },
})
}
size="compact-xs"
>
<Group gap={4} justify="center">
<IconMessageCancel size={16} />
<Text inherit inline>
{`See ${hiddenCount} more hidden ${
hiddenCount > 1 ? 'comments' : 'comment'
}`}
</Text>
</Group>
</Button>
)}
</Group>
<SortFilter type="threads" value={sort} onChange={(v) => setSort(v as ThreadSort)} />
</Group>
<ReturnToRootThread />
</Stack>
{isLoading || isFetching ? (
<Center mt="xl">
<Loader type="bars" />
</Center>
) : (
<>
{activeComment && (
<Stack gap="xl">
<Divider />
<Text size="sm" c="dimmed">
Viewing thread for
</Text>
<Comment comment={activeComment} viewOnly />
</Stack>
)}
<Stack gap="xl" className={activeComment ? classes.rootCommentReplyInset : undefined}>
<CreateComment />
<Stack className="relative" gap="xl">
{data?.map((comment) => (
<Comment key={comment.id} comment={comment} />
))}
</Stack>
{showMore && (
<Center>
<Button
onClick={toggleShowMore}
loading={isFetchingNextPage}
variant="subtle"
size="md"
>
Load More Comments
</Button>
</Center>
)}
{created.map((comment) => (
<Comment key={comment.id} comment={comment} />
))}
</Stack>
</>
)}
</Stack>
)}
</RootThreadProvider>
);
}
@@ -14,6 +14,7 @@ type CommentEntityType =
| 'article'
| 'bounty'
| 'bountyEntry'
| 'challenge'
| 'comment'
| 'image';
@@ -81,6 +81,7 @@ import { MasonryContainer } from '~/components/MasonryColumns/MasonryContainer';
import { constants as appConstants } from '~/server/common/constants';
import { ImageSort } from '~/server/common/enums';
import { CustomMarkdown } from '~/components/Markdown/CustomMarkdown';
import { ChallengeDiscussion } from '~/components/Challenge/ChallengeDiscussion';
/** Open the generation panel for a challenge's model versions. */
function openChallengeGenerator(modelVersionIds: number[]) {
@@ -409,6 +410,11 @@ function ChallengeDetailsPage({ id }: InferGetServerSidePropsType<typeof getServ
{/* Winners Section (for completed challenges) */}
{isCompleted && challenge.winners.length > 0 && <ChallengeWinners challenge={challenge} />}
{/* Discussion Section */}
<Container size="xl" id="comments" py={32}>
<ChallengeDiscussion challengeId={challenge.id} userId={challenge.createdBy?.id} />
</Container>
{/* Entries Section */}
<ChallengeEntries challenge={challenge} />
</SensitiveShield>
@@ -76,6 +76,8 @@ export const upsertCommentV2Handler = async ({
? 'BountyEntry'
: input.entityType === 'clubPost'
? 'ClubPost'
: input.entityType === 'challenge'
? 'Challenge'
: null;
if (type === 'Post' || type === 'Article') {
@@ -119,7 +121,7 @@ export const upsertCommentV2Handler = async ({
const result = await upsertComment({ ...input, userId: ctx.user.id });
if (!input.id) {
if (type && type !== 'ClubPost' && type !== 'Article') {
if (type && type !== 'ClubPost' && type !== 'Article' && type !== 'Challenge') {
await ctx.track.comment({
type,
nsfw: result.nsfw,
@@ -224,19 +226,20 @@ export const toggleHideCommentHandler = async ({
const { id, entityType } = input;
try {
const ownerField = entityType === 'challenge' ? 'createdById' : 'userId';
const comment = await dbRead.commentV2.findFirst({
where: { id },
select: {
hidden: true,
userId: true,
thread: { select: { [entityType]: { select: { userId: true } } } },
thread: { select: { [entityType]: { select: { [ownerField]: true } } } },
},
});
if (!comment) throw throwNotFoundError(`No comment with id ${input.id}`);
if (
!isModerator &&
// Nasty hack to get around the fact that the thread is not typed
(comment.thread[entityType] as any)?.userId !== userId
(comment.thread[entityType] as any)?.[ownerField] !== userId
)
throw throwAuthorizationError();
@@ -19,6 +19,7 @@ export const threadUrlMap = ({ threadType, threadParentId, ...details }: any) =>
review: `/reviews/${threadParentId}?${queryString}`,
bounty: `/bounties/${threadParentId}?${queryString}#comments`,
bountyEntry: `/bounties/entries/${threadParentId}?${queryString}#comments`,
challenge: `/challenges/${threadParentId}?${queryString}#comments`,
// question: `/questions/${threadParentId}?highlight=${details.commentId}#comments`,
// answer: `/questions/${threadParentId}?highlight=${details.commentId}#answer-`,
}[threadType as string] as string;
@@ -164,7 +165,8 @@ export const commentNotifications = createNotificationProcessor({
root."reviewId",
root."articleId",
root."bountyId",
root."bountyEntryId"
root."bountyEntryId",
root."challengeId"
),
'threadType', CASE
WHEN root."imageId" IS NOT NULL THEN 'image'
@@ -176,6 +178,7 @@ export const commentNotifications = createNotificationProcessor({
WHEN root."articleId" IS NOT NULL THEN 'article'
WHEN root."bountyId" IS NOT NULL THEN 'bounty'
WHEN root."bountyEntryId" IS NOT NULL THEN 'bountyEntry'
WHEN root."challengeId" IS NOT NULL THEN 'challenge'
ELSE 'comment'
END,
'commentParentId', t."commentId",
@@ -253,6 +256,7 @@ export const commentNotifications = createNotificationProcessor({
root."articleId",
root."bountyId",
root."bountyEntryId",
root."challengeId",
t."imageId",
t."modelId",
t."postId",
@@ -261,7 +265,8 @@ export const commentNotifications = createNotificationProcessor({
t."reviewId",
t."articleId",
t."bountyId",
t."bountyEntryId"
t."bountyEntryId",
t."challengeId"
),
'threadType', CASE
WHEN COALESCE(root."imageId", t."imageId") IS NOT NULL THEN 'image'
@@ -273,6 +278,7 @@ export const commentNotifications = createNotificationProcessor({
WHEN COALESCE(root."articleId", t."articleId") IS NOT NULL THEN 'article'
WHEN COALESCE(root."bountyId", t."bountyId") IS NOT NULL THEN 'bounty'
WHEN COALESCE(root."bountyEntryId", t."bountyEntryId") IS NOT NULL THEN 'bountyEntry'
WHEN COALESCE(root."challengeId", t."challengeId") IS NOT NULL THEN 'challenge'
ELSE 'comment'
END,
'commentParentId', COALESCE(
@@ -285,6 +291,7 @@ export const commentNotifications = createNotificationProcessor({
t."articleId",
t."bountyId",
t."bountyEntryId",
t."challengeId",
t."commentId"
),
'commentParentType', CASE
@@ -297,6 +304,7 @@ export const commentNotifications = createNotificationProcessor({
WHEN t."articleId" IS NOT NULL THEN 'article'
WHEN t."bountyId" IS NOT NULL THEN 'bounty'
WHEN t."bountyEntryId" IS NOT NULL THEN 'bountyEntry'
WHEN t."challengeId" IS NOT NULL THEN 'challenge'
ELSE 'comment'
END,
'username', u.username
@@ -513,4 +521,40 @@ export const commentNotifications = createNotificationProcessor({
NOT EXISTS (SELECT 1 FROM "UserNotificationSettings" WHERE "userId" = "ownerId" AND type = 'new-bounty-comment');
`,
},
'new-challenge-comment': {
displayName: 'New comments on your challenges',
category: NotificationCategory.Comment,
prepareMessage: ({ details }) => ({
message: `${details.username} commented on your challenge: "${details.challengeTitle}"`,
url: `/challenges/${details.challengeId}?highlight=${details.commentId}#comments`,
}),
prepareQuery: ({ lastSent }) => `
WITH new_challenge_comment AS (
SELECT DISTINCT
ch."createdById" "ownerId",
JSONB_BUILD_OBJECT(
'version', 2,
'challengeId', ch.id,
'challengeTitle', ch.title,
'commentId', c.id,
'username', u.username
) as "details"
FROM "CommentV2" c
JOIN "User" u ON c."userId" = u.id
JOIN "Thread" t ON t.id = c."threadId" AND t."challengeId" IS NOT NULL
JOIN "Challenge" ch ON ch.id = t."challengeId"
WHERE ch."createdById" > 0
AND c."createdAt" > '${lastSent}'
AND c."userId" != ch."createdById"
)
SELECT
concat('new-comment-challenge:owner:v2:', details->>'commentId') "key",
"ownerId" "userId",
'new-challenge-comment' "type",
details
FROM new_challenge_comment
WHERE
NOT EXISTS (SELECT 1 FROM "UserNotificationSettings" WHERE "userId" = "ownerId" AND type = 'new-challenge-comment');
`,
},
});
+2
View File
@@ -47,6 +47,7 @@ export type ChallengeListItem = {
source: ChallengeSource;
prizePool: number;
entryCount: number;
commentCount: number;
modelVersionIds: number[];
collectionId: number | null;
createdBy: {
@@ -117,6 +118,7 @@ export type ChallengeDetail = {
prizePool: number;
operationBudget: number;
entryCount: number;
commentCount: number;
createdBy: {
id: number;
username: string | null;
+2
View File
@@ -18,6 +18,7 @@ export const commentConnectorSchema = z.object({
'bounty',
'bountyEntry',
'clubPost',
'challenge',
]),
hidden: z.boolean().nullish(),
parentThreadId: z.number().optional(),
@@ -54,6 +55,7 @@ export const toggleHideCommentSchema = z.object({
'bounty',
'bountyEntry',
'clubPost',
'challenge',
]),
});
+24
View File
@@ -293,6 +293,7 @@ export async function getInfiniteChallenges(input: GetInfiniteChallengesInput) {
source: ChallengeSource;
prizePool: number;
entryCount: bigint;
commentCount: bigint;
modelVersionIds: number[] | null;
modelId: number | null;
modelName: string | null;
@@ -319,6 +320,7 @@ export async function getInfiniteChallenges(input: GetInfiniteChallengesInput) {
c.source,
c."prizePool",
(SELECT COUNT(*) FROM "CollectionItem" WHERE "collectionId" = c."collectionId" AND status = 'ACCEPTED') as "entryCount",
COALESCE((SELECT t."commentCount" FROM "Thread" t WHERE t."challengeId" = c.id), 0) as "commentCount",
c."modelVersionIds",
(SELECT mv."modelId" FROM "ModelVersion" mv WHERE mv.id = c."modelVersionIds"[1] LIMIT 1) as "modelId",
(SELECT m.name FROM "ModelVersion" mv JOIN "Model" m ON m.id = mv."modelId" WHERE mv.id = c."modelVersionIds"[1] LIMIT 1) as "modelName",
@@ -384,6 +386,7 @@ export async function getInfiniteChallenges(input: GetInfiniteChallengesInput) {
prizePool: item.prizePool,
collectionId: item.collectionId,
entryCount: Number(item.entryCount),
commentCount: Number(item.commentCount),
coverImage: coverImage
? {
id: coverImage.id,
@@ -452,6 +455,13 @@ export async function getChallengeDetail(
entryCount = Number(countResult.count);
}
// Get comment count from the challenge's thread
const commentThread = await dbRead.thread.findUnique({
where: { challengeId: id },
select: { commentCount: true },
});
const commentCount = commentThread?.commentCount ?? 0;
// Get creator info with profile picture and cosmetics
const [creator] = await dbRead.$queryRaw<
[{ id: number; username: string | null; image: string | null; deletedAt: Date | null }]
@@ -627,6 +637,7 @@ export async function getChallengeDetail(
prizePool: challenge.prizePool,
operationBudget: challenge.operationBudget,
entryCount,
commentCount,
createdBy: {
...displayUser,
profilePicture: displayProfilePics[displayUserId] ?? null,
@@ -1476,6 +1487,18 @@ export async function getActiveEvents(): Promise<ChallengeEventListItem[]> {
for (const row of counts) entryCounts.set(row.collectionId, Number(row.count));
}
// Get comment counts for all challenges
const allChallengeIds = allChallenges.map((c) => c.id);
const commentCounts = new Map<number, number>();
if (allChallengeIds.length > 0) {
const counts = await dbRead.$queryRaw<
Array<{ challengeId: number; commentCount: number }>
>`SELECT "challengeId", "commentCount" FROM "Thread" WHERE "challengeId" IN (${Prisma.join(
allChallengeIds
)})`;
for (const row of counts) commentCounts.set(row.challengeId, row.commentCount);
}
return events.map((event) => ({
id: event.id,
title: event.title,
@@ -1500,6 +1523,7 @@ export async function getActiveEvents(): Promise<ChallengeEventListItem[]> {
prizePool: c.prizePool,
collectionId: c.collectionId,
entryCount: c.collectionId ? entryCounts.get(c.collectionId) ?? 0 : 0,
commentCount: commentCounts.get(c.id) ?? 0,
coverImage: coverImage
? {
id: coverImage.id,
+3
View File
@@ -1658,6 +1658,8 @@ export interface Thread {
bountyEntry?: BountyEntry | null;
clubPostId: number | null;
clubPost?: ClubPost | null;
challengeId: number | null;
challenge?: Challenge | null;
metadata: JsonValue;
commentCount: number;
comments?: CommentV2[];
@@ -2835,6 +2837,7 @@ export interface Challenge {
createdAt: Date;
updatedAt: Date;
winners?: ChallengeWinner[];
threads?: Thread[];
eventId: number | null;
event?: ChallengeEvent | null;
}