This commit is contained in:
2026-05-29 20:49:30 +02:00
parent e0e0456768
commit 67643f17a9

View File

@@ -930,6 +930,49 @@ export default (router, tpl) => {
} }
} }
// Fetch poll data for these comments
const pollMap = new Map();
if (comments.length > 0 && cfg.websrv.enable_comment_polls) {
try {
const commentIds = comments.map(c => c.id);
const pollRows = await db`
SELECT
cp.id as poll_id,
cp.comment_id,
cp.question,
cp.expires_at,
COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg(
json_build_object(
'id', cpo.id,
'text', cpo.text,
'sort_order', cpo.sort_order,
'vote_count', COALESCE(vc.cnt, 0)
) ORDER BY cpo.sort_order ASC, cpo.id ASC
) AS options,
COALESCE(SUM(vc.cnt), 0)::int AS total_votes
FROM comment_polls cp
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id
WHERE cp.comment_id = ANY(${commentIds}::int[])
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
`;
for (const p of pollRows) {
pollMap.set(p.comment_id, {
id: p.poll_id,
question: p.question,
expires_at: p.expires_at,
is_anonymous: p.is_anonymous,
options: p.options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: null
});
}
} catch (e) {
console.error('[ACTIVITY] Failed to fetch polls:', e.message);
}
}
const processedComments = comments.map(c => { const processedComments = comments.map(c => {
let ratingLabel = '?'; let ratingLabel = '?';
let ratingClass = 'untagged'; let ratingClass = 'untagged';
@@ -943,7 +986,8 @@ export default (router, tpl) => {
username_color: c.username_color, username_color: c.username_color,
item_rating_class: ratingClass, item_rating_class: ratingClass,
item_rating_label: ratingLabel, item_rating_label: ratingLabel,
files: filesMap.get(c.id) || [] files: filesMap.get(c.id) || [],
poll: pollMap.get(c.id) || null
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it // created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
}; };
}); });
@@ -1008,16 +1052,17 @@ export default (router, tpl) => {
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
const createPollForComment = async (commentId, pollData) => { const createPollForComment = async (commentId, pollData) => {
if (!cfg.websrv.enable_comment_polls) return null; if (!cfg.websrv.enable_comment_polls) return null;
const { question, options } = pollData || {}; const { question, options, is_anonymous } = pollData || {};
if (!question || !question.trim()) return null; if (!question || !question.trim()) return null;
if (!Array.isArray(options) || options.length < 2) return null; if (!Array.isArray(options) || options.length < 2) return null;
const cleanOptions = options.map(o => (typeof o === 'string' ? o : String(o)).trim()).filter(Boolean); const cleanOptions = options.map(o => (typeof o === 'string' ? o : String(o)).trim()).filter(Boolean);
if (cleanOptions.length < 2 || cleanOptions.length > 10) return null; if (cleanOptions.length < 2 || cleanOptions.length > 10) return null;
const anonymous = is_anonymous !== false; // default true
const [poll] = await db` const [poll] = await db`
INSERT INTO comment_polls (comment_id, question) INSERT INTO comment_polls (comment_id, question, is_anonymous)
VALUES (${commentId}, ${question.trim()}) VALUES (${commentId}, ${question.trim()}, ${anonymous})
RETURNING id RETURNING id, is_anonymous
`; `;
const pollId = poll.id; const pollId = poll.id;
for (let i = 0; i < cleanOptions.length; i++) { for (let i = 0; i < cleanOptions.length; i++) {
@@ -1030,7 +1075,8 @@ export default (router, tpl) => {
return { return {
id: pollId, id: pollId,
question: question.trim(), question: question.trim(),
options: optRows.map(o => ({ id: o.id, text: o.text, sort_order: o.sort_order, vote_count: 0 })), is_anonymous: poll.is_anonymous,
options: optRows.map(o => ({ id: o.id, text: o.text, sort_order: o.sort_order, vote_count: 0, voters: [] })),
total_votes: 0, total_votes: 0,
user_vote_option_id: null user_vote_option_id: null
}; };
@@ -1083,7 +1129,7 @@ export default (router, tpl) => {
try { try {
const pollRows = await db` const pollRows = await db`
SELECT SELECT
cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at, cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at, COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg( json_agg(
json_build_object( json_build_object(
'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order, 'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order,
@@ -1095,7 +1141,7 @@ export default (router, tpl) => {
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id
WHERE cp.id = ${pollId} WHERE cp.id = ${pollId}
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
`; `;
if (!pollRows.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) }); if (!pollRows.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const p = pollRows[0]; const p = pollRows[0];
@@ -1106,6 +1152,24 @@ export default (router, tpl) => {
if (vote.length) userVoteOptionId = vote[0].option_id; if (vote.length) userVoteOptionId = vote[0].option_id;
} }
// If not anonymous, attach voter usernames to each option
let options = p.options;
if (!p.is_anonymous) {
const voterRows = await db`
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
FROM comment_poll_votes cpv
JOIN public."user" u ON u.id = cpv.user_id
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
WHERE cpv.poll_id = ${pollId}
`;
const voterMap = new Map();
for (const v of voterRows) {
if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []);
voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
}
options = options.map(o => ({ ...o, voters: voterMap.get(o.id) || [] }));
}
return res.reply({ return res.reply({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -1115,7 +1179,8 @@ export default (router, tpl) => {
comment_id: p.comment_id, comment_id: p.comment_id,
question: p.question, question: p.question,
expires_at: p.expires_at, expires_at: p.expires_at,
options: p.options, is_anonymous: p.is_anonymous,
options,
total_votes: parseInt(p.total_votes) || 0, total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: userVoteOptionId user_vote_option_id: userVoteOptionId
} }
@@ -1149,14 +1214,29 @@ export default (router, tpl) => {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Poll has expired' }) }); return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Poll has expired' }) });
} }
// Upsert vote (change allowed) // Upsert vote (change allowed) — manual check avoids needing a unique constraint
const existing = await db`
SELECT poll_id FROM comment_poll_votes
WHERE poll_id = ${pollId} AND user_id = ${req.session.id}
LIMIT 1
`;
if (existing.length) {
await db`
UPDATE comment_poll_votes
SET option_id = ${optionId}, created_at = now()
WHERE poll_id = ${pollId} AND user_id = ${req.session.id}
`;
} else {
await db` await db`
INSERT INTO comment_poll_votes (poll_id, option_id, user_id) INSERT INTO comment_poll_votes (poll_id, option_id, user_id)
VALUES (${pollId}, ${optionId}, ${req.session.id}) VALUES (${pollId}, ${optionId}, ${req.session.id})
ON CONFLICT (poll_id, user_id) DO UPDATE SET option_id = ${optionId}, created_at = now()
`; `;
}
// Return updated tally // Return updated tally
const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true;
const rows = await db` const rows = await db`
SELECT cpo.id, cpo.text, cpo.sort_order, COALESCE(vc.cnt, 0)::int AS vote_count SELECT cpo.id, cpo.text, cpo.sort_order, COALESCE(vc.cnt, 0)::int AS vote_count
FROM comment_poll_options cpo FROM comment_poll_options cpo
@@ -1166,9 +1246,26 @@ export default (router, tpl) => {
`; `;
const totalVotes = rows.reduce((s, r) => s + r.vote_count, 0); const totalVotes = rows.reduce((s, r) => s + r.vote_count, 0);
let options = rows;
if (!isAnon) {
const voterRows = await db`
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
FROM comment_poll_votes cpv
JOIN public."user" u ON u.id = cpv.user_id
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
WHERE cpv.poll_id = ${pollId}
`;
const voterMap = new Map();
for (const v of voterRows) {
if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []);
voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
}
options = rows.map(o => ({ ...o, voters: voterMap.get(o.id) || [] }));
}
return res.reply({ return res.reply({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, options: rows, total_votes: totalVotes, user_vote_option_id: optionId }) body: JSON.stringify({ success: true, is_anonymous: isAnon, options, total_votes: totalVotes, user_vote_option_id: optionId })
}); });
} catch (err) { } catch (err) {
console.error('[POLLS] vote error:', err); console.error('[POLLS] vote error:', err);