import { dbQuery, dbExec, ok, fail, extractJson, logInfo, logWarn, logError, newId, eventEmit, dbInsert, dbFetchOne, dbFetchAll, dbUpdate, dbDelete, dbCount, dbIncrement, dbSum } from 'sdk'; const nowISO = () => new Date().toISOString(); // ── Hooks ─────────────────────────────────────────────────── export function on_content_creating(input) { const data = extractJson(input, "body"); const ct = data.content_type; const body = data.data || {}; if (ct === "forum_reply") { const topicId = body.topic_id; if (topicId) { const topic = dbFetchOne("forum_topics", { id: topicId }); if (topic && topic.is_locked) { return fail(400, "topic is locked"); } } } return ok(data); } export function on_content_created(input) { const data = extractJson(input, "body"); const ct = data.content_type; const id = data.id; if (ct === "forum_topic") { const topic = dbFetchOne("forum_topics", { id }); if (topic) { const now = nowISO(); dbIncrement("forum_boards", { topic_count: 1, post_count: 1 }, { id: topic.board_id }, { set: { last_activity_at: now, last_topic_id: id } }); } } if (ct === "forum_reply") { const reply = dbFetchOne("forum_replies", { id }); if (reply) { const now = nowISO(); dbIncrement("forum_topics", { reply_count: 1 }, { id: reply.topic_id }, { set: { last_reply_at: now, last_reply_user_id: reply.author_id, updated_at: now } }); const topic = dbFetchOne("forum_topics", { id: reply.topic_id }); if (topic) { dbIncrement("forum_boards", { post_count: 1 }, { id: topic.board_id }, { set: { last_activity_at: now } }); } } } return ok(data); } export function on_content_deleted(input) { const data = extractJson(input, "body"); const ct = data.content_type; const id = data.id; if (ct === "forum_topic") { const topic = dbFetchOne("forum_topics", { id }); if (topic) { dbIncrement("forum_boards", { topic_count: -1, post_count: -1 }, { id: topic.board_id }, { min: 0 }); } } if (ct === "forum_reply") { const reply = dbFetchOne("forum_replies", { id }); if (reply) { dbIncrement("forum_topics", { reply_count: -1 }, { id: reply.topic_id }, { min: 0 }); } } return ok(data); } export function on_content_viewed(input) { const data = extractJson(input, "body"); const ct = data.content_type; const id = data.id; if (ct === "forum_topic") { dbIncrement("forum_topics", { view_count: 1 }, { id }); } return ok(data); } // ── GET /boards/:slug/topics ──────────────────────────────── export function listBoardTopics(input) { const slug = extractJson(input, "params.slug"); let page = 1; let pageSize = 20; if (page < 1) page = 1; if (pageSize < 1 || pageSize > 100) pageSize = 20; const offset = (page - 1) * pageSize; const board = dbFetchOne("forum_boards", { slug }); if (!board) return fail(404, `board not found for slug: ${slug}`); const boardId = board.id; const total = dbCount("forum_topics", { board_id: boardId }); const result = dbFetchAll("forum_topics", { board_id: boardId }, { order_by: "is_pinned DESC, last_reply_at DESC, created_at DESC", limit: pageSize, offset }); return ok({ items: result.data || [], total, page, page_size: pageSize, board_id: boardId }); } // ── PUT /replies/:id/accept ───────────────────────────────── export function acceptAnswer(input) { const replyId = extractJson(input, "params.id"); const data = extractJson(input, "body"); const userId = data.user_id; if (!userId) return fail(400, "user_id required"); const reply = dbFetchOne("forum_replies", { id: replyId }); if (!reply) return fail(404, "reply not found"); const topic = dbFetchOne("forum_topics", { id: reply.topic_id }); if (!topic) return fail(404, "topic not found"); if (topic.author_id !== userId) return fail(403, "only topic author can accept answer"); const now = nowISO(); dbUpdate("forum_replies", { is_answer: 0 }, { topic_id: reply.topic_id, is_answer: 1 }); dbUpdate("forum_replies", { is_answer: 1, updated_at: now }, { id: replyId }); dbUpdate("forum_topics", { is_solved: 1, updated_at: now }, { id: reply.topic_id }); return ok({ id: replyId, is_answer: true, topic_id: reply.topic_id }); } // ── POST /vote ────────────────────────────────────────────── export function vote(input) { const data = extractJson(input, "body"); let userId = data.user_id; const targetType = data.target_type; const targetId = data.target_id; let value = data.value || 1; if (value < -1) value = -1; if (value > 1) value = 1; if (!userId) return fail(400, "user_id required"); if (!targetType) return fail(400, "target_type required"); if (!targetId) return fail(400, "target_id required"); if (targetType !== "topic" && targetType !== "reply") return fail(400, "target_type must be topic or reply"); const existing = dbFetchOne("forum_votes", { target_type: targetType, target_id: targetId, user_id: userId }); if (existing) { const oldValue = parseInt(existing.value, 10); const diff = value - oldValue; if (diff === 0) return fail(400, "already voted"); dbUpdate("forum_votes", { value, updated_at: nowISO() }, { id: existing.id }); updateVoteCount(targetType, targetId, diff); } else { const id = newId(); const now = nowISO(); dbInsert("forum_votes", { id, tenant_id: "default", target_type: targetType, target_id: targetId, user_id: userId, value, created_at: now, updated_at: now }); updateVoteCount(targetType, targetId, value); } return ok({ target_type: targetType, target_id: targetId, value }); } // ── DELETE /vote ──────────────────────────────────────────── export function unvote(input) { const data = extractJson(input, "body"); const userId = data.user_id; const targetType = data.target_type; const targetId = data.target_id; if (!userId) return fail(400, "user_id required"); if (!targetType) return fail(400, "target_type required"); if (!targetId) return fail(400, "target_id required"); const existing = dbFetchOne("forum_votes", { target_type: targetType, target_id: targetId, user_id: userId }); if (!existing) return fail(404, "vote not found"); const oldValue = parseInt(existing.value, 10); dbDelete("forum_votes", { id: existing.id }); updateVoteCount(targetType, targetId, -oldValue); return ok({ removed: true }); } const updateVoteCount = (targetType, targetId, diff) => { const table = targetType === "topic" ? "forum_topics" : "forum_replies"; dbIncrement(table, { vote_count: diff }, { id: targetId }); }; // ── Polls ─────────────────────────────────────────────────── export function createPoll(input) { const data = extractJson(input, "body"); const userId = data.user_id; const topicId = data.topic_id; const question = (data.question || "").trim(); const options = data.options || []; let maxChoices = data.max_choices || 1; if (!userId) return fail(400, "user_id required"); if (!topicId) return fail(400, "topic_id required"); if (!question) return fail(400, "question required"); if (!options || options.length < 2) return fail(400, "at least 2 options required"); if (options.length > 20) return fail(400, "too many options (max 20)"); if (maxChoices < 1) maxChoices = 1; if (maxChoices > options.length) maxChoices = options.length; const topic = dbFetchOne("forum_topics", { id: topicId }); if (!topic) return fail(404, "topic not found"); if (topic.author_id !== userId) return fail(403, "only topic author can create poll"); const existing = dbFetchOne("forum_polls", { topic_id: topicId }); if (existing) return fail(400, "poll already exists for this topic"); const pollId = newId(); const now = nowISO(); dbInsert("forum_polls", { id: pollId, tenant_id: "default", topic_id: topicId, question, max_choices: maxChoices, is_closed: 0, created_at: now, updated_at: now }); const createdOptions = []; for (let i = 0; i < options.length; i++) { const optText = (options[i] || "").trim(); if (!optText) continue; const optId = newId(); dbInsert("forum_poll_options", { id: optId, tenant_id: "default", poll_id: pollId, text: optText, vote_count: 0, sort_order: i }); createdOptions.push({ id: optId, text: optText, vote_count: 0, sort_order: i }); } return ok({ id: pollId, topic_id: topicId, question, max_choices: maxChoices, is_closed: false, options: createdOptions, total_votes: 0, user_votes: [], created_at: now }); } export function getPoll(input) { const topicId = extractJson(input, "params.topicId"); let obj = input; if (typeof input === "string") { try { obj = JSON.parse(input); } catch {} } const fullPath = obj.path || ""; const qsIdx = fullPath.indexOf("?"); let userId = ""; if (qsIdx >= 0) { const qs = fullPath.substring(qsIdx + 1); const pairs = qs.split("&"); for (let p = 0; p < pairs.length; p++) { if (pairs[p].indexOf("user_id=") === 0) { userId = decodeURIComponent(pairs[p].substring(8)); } } } const poll = dbFetchOne("forum_polls", { topic_id: topicId }); if (!poll) return ok(null); const optsResult = dbFetchAll("forum_poll_options", { poll_id: poll.id }, { order_by: "sort_order ASC" }); const options = optsResult.data || []; const totalVotes = dbSum("forum_poll_options", "vote_count", { poll_id: poll.id }); const userVotes = []; if (userId) { const votesResult = dbFetchAll("forum_poll_votes", { poll_id: poll.id, user_id: userId }); if (votesResult.data) { for (const v of votesResult.data) { userVotes.push(v.option_id); } } } const fixedOptions = []; for (const opt of options) { fixedOptions.push({ id: opt.id, text: opt.text, vote_count: parseInt(opt.vote_count, 10) || 0, sort_order: parseInt(opt.sort_order, 10) || 0 }); } return ok({ id: poll.id, topic_id: poll.topic_id, question: poll.question, max_choices: parseInt(poll.max_choices, 10) || 1, is_closed: parseInt(poll.is_closed, 10) === 1, options: fixedOptions, total_votes: totalVotes, user_votes: userVotes, created_at: poll.created_at }); } export function castVote(input) { const pollId = extractJson(input, "params.pollId"); const data = extractJson(input, "body"); const userId = data.user_id; const optionIds = data.option_ids || []; if (!userId) return fail(400, "user_id required"); if (!optionIds || optionIds.length === 0) return fail(400, "option_ids required"); const poll = dbFetchOne("forum_polls", { id: pollId }); if (!poll) return fail(404, "poll not found"); if (parseInt(poll.is_closed, 10) === 1) return fail(400, "poll is closed"); const maxChoices = parseInt(poll.max_choices, 10); if (optionIds.length > maxChoices) return fail(400, `too many choices (max ${maxChoices})`); const existingVotes = dbFetchAll("forum_poll_votes", { poll_id: pollId, user_id: userId }); if (existingVotes.data && existingVotes.data.length > 0) return fail(400, "already voted"); for (const optId of optionIds) { const opt = dbFetchOne("forum_poll_options", { id: optId, poll_id: pollId }); if (!opt) return fail(400, `option not found: ${optId}`); const voteId = newId(); const now = nowISO(); dbInsert("forum_poll_votes", { id: voteId, tenant_id: "default", poll_id: pollId, option_id: optId, user_id: userId, created_at: now, updated_at: now }); dbIncrement("forum_poll_options", { vote_count: 1 }, { id: optId }); } return ok({ poll_id: pollId, voted_options: optionIds }); } export function deletePoll(input) { const pollId = extractJson(input, "params.pollId"); const data = extractJson(input, "body"); const userId = data.user_id; if (!userId) return fail(400, "user_id required"); const poll = dbFetchOne("forum_polls", { id: pollId }); if (!poll) return fail(404, "poll not found"); const topic = dbFetchOne("forum_topics", { id: poll.topic_id }); if (!topic || topic.author_id !== userId) return fail(403, "only topic author can delete poll"); dbDelete("forum_poll_votes", { poll_id: pollId }); dbDelete("forum_poll_options", { poll_id: pollId }); dbDelete("forum_polls", { id: pollId }); return ok({ deleted: true }); }