import { createServerFn } from "@tanstack/react-start"; import { resolveMentions } from "~/server/mentions"; import type { MentionMap } from "~/server/mentions"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, batch, first, run, stmt } from "~/server/db"; import { randomId } from "~/server/crypto"; import { assertBelowCap, limitWrite, MAX_PAGE } from "~/server/limits"; import { hasPermission } from "~/server/repos"; import { createDiscussion, loadDiscussion, repoCategories, requireDiscussionAccess } from "~/server/discussions"; import { PLANNING_CAPS } from "~/server/planning"; import { reactionSummaries } from "~/server/reactions"; import { renderMarkdown } from "~/server/markdown"; import { accountOrGhost, accountsById } from "~/server/people"; import { emitNotification, publicUrl, recordActivity } from "~/server/events/emit"; import { badRequest, forbidden, notFoundError } from "~/server/errors"; import { markdownBody, repoRef, title } from "~/lib/validation"; import type { RepoRow } from "~/lib/types"; const PAGE_SIZE = 25; const THREAD_LIMIT = 500; const discussionRef = repoRef.extend({ number: z.number().int().positive() }); function md(repo: RepoRow, body: string, mentions: MentionMap): string { return renderMarkdown( body, { owner: repo.owner_name, repo: repo.name, ref: repo.default_branch, basePath: "" }, mentions, ); } function discussionUrl(repo: RepoRow, number: number): string { return publicUrl(`/${repo.owner_name}/${repo.name}/discussions/${number}`); } export const listDiscussions = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ category: z.string().max(64).optional(), q: z.string().max(200).optional(), page: z.number().int().min(1).max(MAX_PAGE).default(1), }), ) .handler(async ({ data, context }) => { const { repo, permission } = await requireDiscussionAccess(data, context.viewer, "read", false); const categories = await repoCategories(repo.id); const category = categories.find((item) => item.name.toLowerCase() === data.category?.toLowerCase()) ?? null; const search = data.q?.trim() ? `%${data.q.trim().replace(/[%_]/g, "")}%` : null; const rows = await all<{ number: number; title: string; author_id: string; category_id: string; answer_id: string | null; upvotes: number; comments_count: number; updated_at: number; }>( `SELECT number, title, author_id, category_id, answer_id, upvotes, comments_count, updated_at FROM discussions WHERE repo_id = ?1 AND (?2 IS NULL OR category_id = ?2) AND (?3 IS NULL OR title LIKE ?3) ORDER BY updated_at DESC LIMIT ?4 OFFSET ?5`, repo.id, category?.id ?? null, search, PAGE_SIZE + 1, (data.page - 1) * PAGE_SIZE, ); const page = rows.slice(0, PAGE_SIZE); const people = await accountsById(page.map((row) => row.author_id)); const byId = new Map(categories.map((item) => [item.id, item])); return { categories: categories.map((item) => ({ id: item.id, name: item.name, emoji: item.emoji, description: item.description, answerable: item.answerable === 1, })), activeCategory: category?.name ?? null, hasMore: rows.length > PAGE_SIZE, canMaintain: hasPermission(permission, "write"), items: page.map((row) => ({ number: row.number, title: row.title, author: accountOrGhost(people, row.author_id).name, category: byId.get(row.category_id)?.name ?? "", emoji: byId.get(row.category_id)?.emoji ?? "", answered: row.answer_id !== null, upvotes: row.upvotes, commentsCount: row.comments_count, updatedAt: row.updated_at, })), }; }); export const getDiscussion = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(discussionRef) .handler(async ({ data, context }) => { const { repo, permission } = await requireDiscussionAccess(data, context.viewer, "read", false); const discussion = await loadDiscussion(repo.id, data.number); if (!discussion) throw notFoundError("Discussion not found"); const viewerId = context.viewer?.id ?? null; const [category, comments, votes] = await Promise.all([ first<{ name: string; emoji: string; answerable: number }>( "SELECT name, emoji, answerable FROM discussion_categories WHERE id = ?", discussion.category_id, ), all<{ id: string; parent_id: string | null; author_id: string; body: string; upvotes: number; created_at: number; updated_at: number; }>( "SELECT id, parent_id, author_id, body, upvotes, created_at, updated_at FROM discussion_comments WHERE discussion_id = ? ORDER BY created_at LIMIT ?", discussion.id, THREAD_LIMIT, ), all<{ subject_id: string }>( `SELECT v.subject_id FROM discussion_votes v WHERE v.user_id = ?1 AND (v.subject_id = ?2 OR v.subject_id IN (SELECT id FROM discussion_comments WHERE discussion_id = ?2 AND parent_id IS NULL))`, viewerId ?? "", discussion.id, ), ]); const [people, discussionReactions, commentReactions, mentions] = await Promise.all([ accountsById([discussion.author_id, ...comments.map((comment) => comment.author_id)]), reactionSummaries("discussion", [discussion.id], viewerId), reactionSummaries( "discussion_comment", comments.map((comment) => comment.id), viewerId, ), resolveMentions([discussion.body, ...comments.map((comment) => comment.body)]), ]); const voted = new Set(votes.map((vote) => vote.subject_id)); const maintainer = hasPermission(permission, "write"); const toComment = (comment: (typeof comments)[number]) => ({ id: comment.id, author: accountOrGhost(people, comment.author_id).name, body: comment.body, html: md(repo, comment.body, mentions), upvotes: comment.upvotes, viewerVoted: voted.has(comment.id), createdAt: comment.created_at, edited: comment.updated_at > comment.created_at, canEdit: Boolean(viewerId && (viewerId === comment.author_id || maintainer)), reactions: commentReactions.get(comment.id) ?? [], }); const threads = comments .filter((comment) => comment.parent_id === null) .map((comment) => ({ ...toComment(comment), isAnswer: discussion.answer_id === comment.id, replies: comments.filter((reply) => reply.parent_id === comment.id).map(toComment), })); return { discussion: { id: discussion.id, number: discussion.number, title: discussion.title, body: discussion.body, html: md(repo, discussion.body, mentions), author: accountOrGhost(people, discussion.author_id).name, category: category?.name ?? "", emoji: category?.emoji ?? "", answerable: category?.answerable === 1, answerId: discussion.answer_id, upvotes: discussion.upvotes, viewerVoted: voted.has(discussion.id), locked: discussion.locked === 1, createdAt: discussion.created_at, reactions: discussionReactions.get(discussion.id) ?? [], }, threads, canAnswer: Boolean(viewerId && (viewerId === discussion.author_id || maintainer)), canComment: Boolean(viewerId) && (discussion.locked === 0 || maintainer), canMaintain: maintainer, }; }); export const startDiscussion = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ categoryId: z.string().max(64), title, body: markdownBody })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission } = await requireDiscussionAccess(data, viewer, "read", true); const category = await first<{ id: string; name: string }>( "SELECT id, name FROM discussion_categories WHERE id = ? AND repo_id = ?", data.categoryId, repo.id, ); if (!category) throw notFoundError("Category not found"); if (category.name === "Announcements" && !hasPermission(permission, "write")) throw forbidden("Only maintainers can post announcements"); const created = await createDiscussion({ repoId: repo.id, categoryId: category.id, authorId: viewer.id, title: data.title, body: data.body, }); await recordActivity(viewer.id, repo.id, "discussion_opened", { number: created.number, title: data.title }); return { number: created.number }; }); export const replyToDiscussion = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(discussionRef.extend({ parentId: z.string().max(64).nullable(), body: markdownBody.min(1) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission } = await requireDiscussionAccess(data, viewer, "read", true); const discussion = await loadDiscussion(repo.id, data.number); if (!discussion) throw notFoundError("Discussion not found"); if (discussion.locked === 1 && !hasPermission(permission, "write")) throw forbidden("This discussion is locked"); assertBelowCap(discussion.comments_count, PLANNING_CAPS.discussionCommentsPerThread, "comments per discussion"); if (data.parentId) { const parent = await first<{ parent_id: string | null }>( "SELECT parent_id FROM discussion_comments WHERE id = ? AND discussion_id = ?", data.parentId, discussion.id, ); if (!parent) throw notFoundError("Comment not found"); if (parent.parent_id !== null) throw badRequest("Replies can only be one level deep"); } const now = Date.now(); await batch([ stmt( `INSERT INTO discussion_comments (id, discussion_id, parent_id, author_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, randomId(), discussion.id, data.parentId, viewer.id, data.body, now, now, ), stmt( "UPDATE discussions SET comments_count = comments_count + 1, updated_at = ? WHERE id = ?", now, discussion.id, ), ]); await emitNotification({ repoId: repo.id, issueId: null, actorId: viewer.id, reason: "comment", title: `${repo.owner_name}/${repo.name} discussion #${discussion.number}: ${discussion.title}`, url: discussionUrl(repo, discussion.number), mentionText: data.body, directRecipients: discussion.author_id === viewer.id ? [] : [discussion.author_id], }); return { ok: true }; }); export const editDiscussionComment = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ commentId: z.string().max(64), body: markdownBody.min(1).nullable() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const { repo, permission } = await requireDiscussionAccess(data, viewer, "read", true); await limitWrite(viewer); const comment = await first<{ id: string; author_id: string; discussion_id: string; locked: number }>( `SELECT c.id, c.author_id, c.discussion_id, d.locked FROM discussion_comments c JOIN discussions d ON d.id = c.discussion_id WHERE c.id = ? AND d.repo_id = ?`, data.commentId, repo.id, ); if (!comment) throw notFoundError("Comment not found"); if (comment.author_id !== viewer.id && !hasPermission(permission, "write")) throw forbidden(); if (comment.locked === 1 && !hasPermission(permission, "write")) throw forbidden("This discussion is locked"); if (data.body !== null) { await run( "UPDATE discussion_comments SET body = ?, updated_at = ? WHERE id = ?", data.body, Date.now(), comment.id, ); return { ok: true }; } const removed = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM discussion_comments WHERE id = ?1 OR parent_id = ?1", comment.id, ); const subjects = "SELECT id FROM discussion_comments WHERE id = ?1 OR parent_id = ?1"; await batch([ stmt( `DELETE FROM reactions WHERE subject_type = 'discussion_comment' AND subject_id IN (${subjects})`, comment.id, ), stmt(`DELETE FROM discussion_votes WHERE subject_id IN (${subjects})`, comment.id), stmt("DELETE FROM discussion_comments WHERE id = ?", comment.id), stmt("UPDATE discussions SET answer_id = NULL WHERE answer_id = ?", comment.id), stmt( "UPDATE discussions SET comments_count = MAX(comments_count - ?, 0) WHERE id = ?", removed?.count ?? 1, comment.discussion_id, ), ]); return { ok: true }; }); export const markDiscussionAnswer = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(discussionRef.extend({ commentId: z.string().max(64).nullable() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo, permission } = await requireDiscussionAccess(data, viewer, "read", true); const discussion = await loadDiscussion(repo.id, data.number); if (!discussion) throw notFoundError("Discussion not found"); if (discussion.author_id !== viewer.id && !hasPermission(permission, "write")) throw forbidden(); const category = await first<{ answerable: number }>( "SELECT answerable FROM discussion_categories WHERE id = ?", discussion.category_id, ); if (category?.answerable !== 1) throw badRequest("This category does not accept answers"); if (data.commentId) { const comment = await first( "SELECT 1 FROM discussion_comments WHERE id = ? AND discussion_id = ? AND parent_id IS NULL", data.commentId, discussion.id, ); if (!comment) throw notFoundError("Only top-level comments can be answers"); } await run("UPDATE discussions SET answer_id = ? WHERE id = ?", data.commentId, discussion.id); return { ok: true }; }); export const toggleDiscussionVote = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(discussionRef.extend({ subjectId: z.string().max(64) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireDiscussionAccess(data, viewer, "read", true); const discussion = await loadDiscussion(repo.id, data.number); if (!discussion) throw notFoundError("Discussion not found"); const isDiscussion = data.subjectId === discussion.id; if (!isDiscussion) { const comment = await first( "SELECT 1 FROM discussion_comments WHERE id = ? AND discussion_id = ? AND parent_id IS NULL", data.subjectId, discussion.id, ); if (!comment) throw notFoundError("Nothing to vote on"); } const table = isDiscussion ? "discussions" : "discussion_comments"; const existing = await first( "SELECT 1 FROM discussion_votes WHERE subject_id = ? AND user_id = ?", data.subjectId, viewer.id, ); await batch( existing ? [ stmt("DELETE FROM discussion_votes WHERE subject_id = ? AND user_id = ?", data.subjectId, viewer.id), stmt(`UPDATE ${table} SET upvotes = MAX(upvotes - 1, 0) WHERE id = ?`, data.subjectId), ] : [ stmt( "INSERT INTO discussion_votes (subject_id, user_id, created_at) VALUES (?, ?, ?)", data.subjectId, viewer.id, Date.now(), ), stmt(`UPDATE ${table} SET upvotes = upvotes + 1 WHERE id = ?`, data.subjectId), ], ); return { voted: !existing }; });