import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { repoDb } from "~/server/scoped-db"; import { randomId } from "~/server/crypto"; import { limitWrite } from "~/server/limits"; import { convertIssue, repoCategories, requireDiscussionAccess } from "~/server/discussions"; import { loadIssue, setIssueState } from "~/server/issues"; import { assertCount, PLANNING_CAPS } from "~/server/planning"; import { badRequest, notFoundError } from "~/server/errors"; import { repoRef } from "~/lib/validation"; const issueRef = repoRef.extend({ number: z.number().int().positive() }); export const saveDiscussionCategory = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ id: z.string().max(64).nullable(), name: z.string().trim().min(1).max(40), emoji: z.string().max(8), description: z.string().max(200), answerable: z.boolean(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireDiscussionAccess(data, viewer, "write", true); if (data.id) { await repoDb(repo.id).run( "UPDATE discussion_categories SET name = ?, emoji = ?, description = ?, answerable = ? WHERE id = ? AND repo_id = ?", data.name, data.emoji, data.description, data.answerable ? 1 : 0, data.id, repo.id, ); return { ok: true }; } await assertCount( "SELECT COUNT(*) AS count FROM discussion_categories WHERE repo_id = ?", repo.id, PLANNING_CAPS.categoriesPerRepo, "discussion categories", repoDb(repo.id), ); await repoDb(repo.id).run( "INSERT INTO discussion_categories (id, repo_id, name, emoji, description, answerable) VALUES (?, ?, ?, ?, ?, ?)", randomId(), repo.id, data.name, data.emoji, data.description, data.answerable ? 1 : 0, ); return { ok: true }; }); export const deleteDiscussionCategory = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ id: z.string().max(64) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireDiscussionAccess(data, viewer, "write", true); const used = await repoDb(repo.id).first( "SELECT 1 FROM discussions WHERE category_id = ? AND repo_id = ? LIMIT 1", data.id, repo.id, ); if (used) throw badRequest("Move or delete this category's discussions first"); await repoDb(repo.id).run("DELETE FROM discussion_categories WHERE id = ? AND repo_id = ?", data.id, repo.id); return { ok: true }; }); export const convertIssueToDiscussion = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(issueRef.extend({ categoryId: z.string().max(64).optional() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireDiscussionAccess(data, viewer, "write", true); const issue = await loadIssue(repo.id, data.number); if (!issue) throw notFoundError("Issue not found"); if (issue.is_pull === 1) throw badRequest("Pull requests cannot become discussions"); const converted = await repoDb(repo.id).first<{ number: number | null }>( `SELECT json_extract(data, '$.number') AS number FROM issue_events WHERE issue_id = ? AND kind = 'converted_to_discussion' ORDER BY created_at LIMIT 1`, issue.id, ); if (converted?.number) return { number: converted.number }; const categories = await repoCategories(repo.id); const category = categories.find((item) => item.id === data.categoryId) ?? categories.find((item) => item.name === "General") ?? categories[0]; if (!category) throw badRequest("Create a discussion category first"); const number = await convertIssue(issue, category.id, viewer.id); await setIssueState(issue, "closed", viewer.id, { discussion: number }); return { number }; });