diff --git a/packages/backend/src/routes/forums.ts b/packages/backend/src/routes/forums.ts new file mode 100644 index 000000000..ff0ffe00d --- /dev/null +++ b/packages/backend/src/routes/forums.ts @@ -0,0 +1,58 @@ +import type { Express, Request, Response } from 'express' +import { z } from 'zod' +import { getPrismaClient } from '@lucky/shared/utils' +import { apiLimiter } from '../middleware/rateLimit' +import { asyncHandler } from '../middleware/asyncHandler' + +const threadResponseSchema = z.object({ + threadId: z.string(), + slug: z.string(), + title: z.string(), + archived: z.boolean(), + url: z.string(), +}) + +export type ForumThreadResponse = z.infer + +export function setupForumsRoutes(app: Express): void { + // Public: resolve a forum-content slug to its Discord thread for a guild. + // Returns 404 when no thread is mapped (guia has no Discord thread yet). + // Used by the web app to render per-guia "Ver discussão no Discord" CTAs. + app.get( + '/api/guilds/:guildId/threads/:slug', + apiLimiter, + asyncHandler(async (req: Request, res: Response) => { + const { guildId, slug } = req.params as { + guildId: string + slug: string + } + + const prisma = getPrismaClient() + const thread = await prisma.guildForumThread.findUnique({ + where: { guildId_slug: { guildId, slug } }, + select: { + threadId: true, + slug: true, + title: true, + archived: true, + }, + }) + + if (!thread) { + res.status(404).json({ error: 'Thread not found' }) + return + } + + const response = threadResponseSchema.parse({ + ...thread, + url: `https://discord.com/channels/${guildId}/${thread.threadId}`, + }) + + res.set( + 'Cache-Control', + 'public, max-age=60, s-maxage=60, stale-while-revalidate=300', + ) + res.json(response) + }), + ) +} diff --git a/packages/backend/src/routes/index.ts b/packages/backend/src/routes/index.ts index 7f18f5c8f..b46ad6aab 100644 --- a/packages/backend/src/routes/index.ts +++ b/packages/backend/src/routes/index.ts @@ -29,6 +29,7 @@ import { errorHandler } from '../middleware/errorHandler' import { setupHealthRoutes } from './health' import { setupMetricsRoute } from './metrics' import { setupStatsRoutes } from './stats' +import { setupForumsRoutes } from './forums' import { setupInviteRoute } from './invite' import { setupSupportRoutes } from './support' import { setupSecurityRoutes } from './security' @@ -93,6 +94,7 @@ export function setupRoutes(app: Express): void { setupHealthRoutes(app) setupMetricsRoute(app) setupStatsRoutes(app) + setupForumsRoutes(app) setupInternalNotifyRoutes(app) setupWebhookPublicRoutes(app) // Public, unauthenticated CSP report sink — registered before the shared diff --git a/packages/backend/tests/unit/routes/forums.test.ts b/packages/backend/tests/unit/routes/forums.test.ts new file mode 100644 index 000000000..fd72e784c --- /dev/null +++ b/packages/backend/tests/unit/routes/forums.test.ts @@ -0,0 +1,116 @@ +import { describe, test, expect, jest, beforeEach } from '@jest/globals' +import express from 'express' +import request from 'supertest' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockFindUnique = jest.fn() + +jest.mock('@lucky/shared/utils', () => { + const actual = jest.requireActual('@lucky/shared/utils') as object + return { + ...actual, + getPrismaClient: jest.fn(() => ({ + guildForumThread: { + findUnique: mockFindUnique, + }, + })), + } +}) + +jest.mock('../../../src/middleware/rateLimit', () => ({ + apiLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), +})) + +import { setupForumsRoutes } from '../../../src/routes/forums' + +function buildApp() { + const app = express() + setupForumsRoutes(app) + return app +} + +const GUILD_ID = '895505900016631839' +const SLUG = 'linkedin-de-um-curriculo-parado-para-uma-landing-page-de-aut' + +describe('GET /api/guilds/:guildId/threads/:slug', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('returns 200 with thread data when found', async () => { + mockFindUnique.mockResolvedValueOnce({ + threadId: '123456789', + slug: SLUG, + title: 'LinkedIn: de um currículo parado para uma landing page de aut.', + archived: false, + }) + + const res = await request(buildApp()).get( + `/api/guilds/${GUILD_ID}/threads/${SLUG}`, + ) + + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ + threadId: '123456789', + slug: SLUG, + title: 'LinkedIn: de um currículo parado para uma landing page de aut.', + archived: false, + url: `https://discord.com/channels/${GUILD_ID}/123456789`, + }) + }) + + test('returns 404 when thread not found', async () => { + mockFindUnique.mockResolvedValueOnce(null) + + const res = await request(buildApp()).get( + `/api/guilds/${GUILD_ID}/threads/nonexistent-slug`, + ) + + expect(res.status).toBe(404) + expect(res.body).toEqual({ error: 'Thread not found' }) + }) + + test('queries by guildId and slug composite key', async () => { + mockFindUnique.mockResolvedValueOnce(null) + + await request(buildApp()).get(`/api/guilds/${GUILD_ID}/threads/${SLUG}`) + + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { guildId_slug: { guildId: GUILD_ID, slug: SLUG } }, + select: expect.any(Object), + }) + }) + + test('sets Cache-Control header on hit', async () => { + mockFindUnique.mockResolvedValueOnce({ + threadId: '999', + slug: SLUG, + title: 'Title', + archived: false, + }) + + const res = await request(buildApp()).get( + `/api/guilds/${GUILD_ID}/threads/${SLUG}`, + ) + + expect(res.headers['cache-control']).toContain('public') + expect(res.headers['cache-control']).toContain('max-age=60') + }) + + test('constructs correct Discord thread URL', async () => { + mockFindUnique.mockResolvedValueOnce({ + threadId: 'THREAD_XYZ', + slug: SLUG, + title: 'Any Title', + archived: true, + }) + + const res = await request(buildApp()).get( + `/api/guilds/${GUILD_ID}/threads/${SLUG}`, + ) + + expect(res.body.url).toBe( + `https://discord.com/channels/${GUILD_ID}/THREAD_XYZ`, + ) + }) +}) diff --git a/packages/bot/src/handlers/eventHandler.ts b/packages/bot/src/handlers/eventHandler.ts index 31d7ddae8..edbd8398b 100644 --- a/packages/bot/src/handlers/eventHandler.ts +++ b/packages/bot/src/handlers/eventHandler.ts @@ -38,6 +38,7 @@ import { guildJoinsTotal, guildLeavesTotal, } from '../utils/monitoring/prometheus' +import { handleForumThreadCreate } from './forumThreadHandler' function handleClientReady(client: Client): void { client.once('clientReady', () => { @@ -373,4 +374,5 @@ export default function handleEvents(client: Client) { handleGuildCreate(client) handleGuildDelete(client) handleChannelDelete(client) + handleForumThreadCreate(client) } diff --git a/packages/bot/src/handlers/forumThreadHandler.spec.ts b/packages/bot/src/handlers/forumThreadHandler.spec.ts new file mode 100644 index 000000000..47e766123 --- /dev/null +++ b/packages/bot/src/handlers/forumThreadHandler.spec.ts @@ -0,0 +1,177 @@ +// Mocks FIRST — before any imports +const mockUpsert = jest.fn() +const mockGetPrismaClient = jest.fn(() => ({ + guildForumThread: { upsert: mockUpsert }, +})) +const mockErrorLog = jest.fn() +const mockInfoLog = jest.fn() + +// The bot jest config maps @lucky/shared/utils/database/prismaClient to a manual +// mock. Override that with our own factory so getPrismaClient returns a stub client. +jest.mock('@lucky/shared/utils/database/prismaClient', () => ({ + getPrismaClient: mockGetPrismaClient, + disconnectPrisma: jest.fn(), +})) + +jest.mock('@lucky/shared/utils', () => ({ + getPrismaClient: mockGetPrismaClient, + errorLog: mockErrorLog, + infoLog: mockInfoLog, + debugLog: jest.fn(), +})) + +import { describe, expect, it, beforeEach } from '@jest/globals' +import { ChannelType, Events } from 'discord.js' +import { + extractOfficialSlug, + processForumThread, + handleForumThreadCreate, +} from './forumThreadHandler' + +// ---------- extractOfficialSlug unit tests ---------- + +describe('extractOfficialSlug', () => { + it('extracts slug from a well-formed marker', () => { + expect( + extractOfficialSlug( + 'Intro\n\nbody', + ), + ).toBe('my-guide-slug') + }) + + it('tolerates extra whitespace inside the comment', () => { + expect(extractOfficialSlug('')).toBe( + 'slug-abc', + ) + }) + + it('returns null when no marker is present', () => { + expect(extractOfficialSlug('No marker here')).toBeNull() + }) + + it('returns null for wrong version prefix', () => { + expect(extractOfficialSlug('')).toBeNull() + }) + + it('returns null for empty content', () => { + expect(extractOfficialSlug('')).toBeNull() + }) +}) + +// ---------- processForumThread tests ---------- + +describe('processForumThread', () => { + function makeThread( + overrides: Partial<{ + guildId: string | null + id: string + name: string + parentType: ChannelType + starterContent: string + starterThrows: boolean + }> = {}, + ) { + const opts = { + guildId: '895505900016631839', + id: 'THREAD_123', + name: 'Guide Thread', + parentType: ChannelType.GuildForum, + starterContent: ' some body', + starterThrows: false, + ...overrides, + } + return { + guildId: opts.guildId, + id: opts.id, + name: opts.name, + parent: { type: opts.parentType }, + fetchStarterMessage: jest.fn(async () => + opts.starterThrows + ? Promise.reject(new Error('forbidden')) + : { content: opts.starterContent }, + ), + } + } + + beforeEach(() => { + jest.clearAllMocks() + mockUpsert.mockResolvedValue({}) + mockGetPrismaClient.mockReturnValue({ + guildForumThread: { upsert: mockUpsert }, + }) + }) + + it('upserts a GuildForumThread record when marker is found', async () => { + await processForumThread(makeThread() as never) + + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + guildId_slug: { + guildId: '895505900016631839', + slug: 'my-guide', + }, + }, + create: expect.objectContaining({ + guildId: '895505900016631839', + threadId: 'THREAD_123', + slug: 'my-guide', + title: 'Guide Thread', + archived: false, + }), + }), + ) + expect(mockInfoLog).toHaveBeenCalledWith( + expect.objectContaining({ message: 'forum thread mapped' }), + ) + }) + + it('does nothing when parent is not a forum channel', async () => { + await processForumThread( + makeThread({ parentType: ChannelType.GuildText }) as never, + ) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('does nothing when thread has no guildId', async () => { + await processForumThread(makeThread({ guildId: null }) as never) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('does nothing when starter message has no marker', async () => { + await processForumThread( + makeThread({ starterContent: 'Just a regular post' }) as never, + ) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('does nothing when fetchStarterMessage throws (e.g. missing permissions)', async () => { + await processForumThread(makeThread({ starterThrows: true }) as never) + expect(mockUpsert).not.toHaveBeenCalled() + }) + + it('logs an error and does not throw when upsert fails', async () => { + mockUpsert.mockRejectedValue(new Error('db down')) + await expect( + processForumThread(makeThread() as never), + ).resolves.toBeUndefined() + expect(mockErrorLog).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'failed to upsert forum thread mapping', + }), + ) + }) +}) + +// ---------- handleForumThreadCreate wiring test ---------- + +describe('handleForumThreadCreate', () => { + it('registers a ThreadCreate listener on the client', () => { + const onMock = jest.fn() + handleForumThreadCreate({ on: onMock } as never) + expect(onMock).toHaveBeenCalledWith( + Events.ThreadCreate, + expect.any(Function), + ) + }) +}) diff --git a/packages/bot/src/handlers/forumThreadHandler.ts b/packages/bot/src/handlers/forumThreadHandler.ts new file mode 100644 index 000000000..1008f4776 --- /dev/null +++ b/packages/bot/src/handlers/forumThreadHandler.ts @@ -0,0 +1,75 @@ +import { + Events, + ChannelType, + type AnyThreadChannel, + type Client, +} from 'discord.js' +import { getPrismaClient, errorLog, infoLog } from '@lucky/shared/utils' + +// Extracts the canonical slug from an "official" forum thread marker. +// Format: where slug matches web forum-content keys. +const OFFICIAL_MARKER_RE = // + +export function extractOfficialSlug(content: string): string | null { + const match = OFFICIAL_MARKER_RE.exec(content) + return match?.[1] ?? null +} + +export async function processForumThread( + thread: AnyThreadChannel, +): Promise { + if (!thread.guildId || thread.parent?.type !== ChannelType.GuildForum) + return + + let content: string + try { + const starter = await thread.fetchStarterMessage() + content = starter?.content ?? '' + } catch { + return + } + + const slug = extractOfficialSlug(content) + if (!slug) return + + const prisma = getPrismaClient() + try { + await prisma.guildForumThread.upsert({ + where: { guildId_slug: { guildId: thread.guildId, slug } }, + create: { + guildId: thread.guildId, + threadId: thread.id, + slug, + title: thread.name, + archived: false, + }, + update: { + threadId: thread.id, + title: thread.name, + archived: false, + }, + }) + infoLog({ + message: 'forum thread mapped', + data: { guildId: thread.guildId, threadId: thread.id, slug }, + }) + } catch (error) { + errorLog({ + message: 'failed to upsert forum thread mapping', + data: { guildId: thread.guildId, threadId: thread.id, slug }, + error, + }) + } +} + +export function handleForumThreadCreate(client: Client): void { + client.on(Events.ThreadCreate, (thread, newlyCreated) => { + if (!newlyCreated) return + processForumThread(thread).catch((error) => { + errorLog({ + message: 'unhandled error in forumThreadHandler:', + error, + }) + }) + }) +} diff --git a/prisma/migrations/20260621000000_add_guild_forum_threads/migration.sql b/prisma/migrations/20260621000000_add_guild_forum_threads/migration.sql new file mode 100644 index 000000000..b952c8ecb --- /dev/null +++ b/prisma/migrations/20260621000000_add_guild_forum_threads/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "guild_forum_threads" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "threadId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "archived" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "guild_forum_threads_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "guild_forum_threads_guildId_idx" ON "guild_forum_threads"("guildId"); + +-- CreateIndex +CREATE UNIQUE INDEX "guild_forum_threads_guildId_slug_key" ON "guild_forum_threads"("guildId", "slug"); + +-- AddForeignKey +ALTER TABLE "guild_forum_threads" ADD CONSTRAINT "guild_forum_threads_guildId_fkey" FOREIGN KEY ("guildId") REFERENCES "guilds"("discordId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aad52a800..d20107e25 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,6 +95,7 @@ model Guild { memberBirthdays MemberBirthday[] subscription GuildSubscription? reactionRoleMessages ReactionRoleMessage[] + forumThreads GuildForumThread[] @@map("guilds") } @@ -1058,3 +1059,22 @@ model Session { @@index([expiresAt], map: "idx_sessions_expires_at") @@map("sessions") } + +// Forum thread ↔ web-slug mapping. Populated by the bot listener when a +// forum thread tagged `` is created or updated. +// Used by the web app to render per-guia "Ver discussão no Discord" CTAs. +model GuildForumThread { + id String @id @default(cuid()) + guildId String + guild Guild @relation(fields: [guildId], references: [discordId], onDelete: Cascade) + threadId String + slug String + title String + archived Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([guildId, slug]) + @@index([guildId]) + @@map("guild_forum_threads") +} diff --git a/release-please-config.json b/release-please-config.json index a4077994e..f830ba20f 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -3,6 +3,7 @@ "release-type": "node", "include-component-in-tag": false, "separate-pull-requests": false, + "pull-request-title-pattern": "chore: release ${version}", "packages": { ".": { "package-name": "lucky-bot",