add polls

This commit is contained in:
2026-05-29 20:15:00 +02:00
parent 9365cb21c8
commit 754fc95d56
12 changed files with 804 additions and 9 deletions

View File

@@ -323,7 +323,18 @@
"attach_file": "Datei anhängen",
"uploading_file": "Wird hochgeladen...",
"remove_file": "Datei entfernen",
"file_too_large": "Datei zu groß"
"file_too_large": "Datei zu groß",
"poll_btn_title": "Umfrage erstellen",
"poll_question_placeholder": "Umfragefrage...",
"poll_option_placeholder": "Option...",
"poll_add_option": "Option hinzufügen",
"poll_remove": "Umfrage entfernen",
"poll_vote": "Abstimmen",
"poll_voted": "Abgestimmt",
"poll_votes": "Stimmen",
"poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen",
"poll_expired": "Umfrage geschlossen"
},
"upload_btn": {
"select_file": "Datei auswählen",

View File

@@ -323,7 +323,18 @@
"attach_file": "Attach file",
"uploading_file": "Uploading...",
"remove_file": "Remove file",
"file_too_large": "File too large"
"file_too_large": "File too large",
"poll_btn_title": "Create poll",
"poll_question_placeholder": "Poll question...",
"poll_option_placeholder": "Option...",
"poll_add_option": "Add option",
"poll_remove": "Remove poll",
"poll_vote": "Vote",
"poll_voted": "You voted",
"poll_votes": "votes",
"poll_vote_single": "vote",
"poll_delete": "Delete poll",
"poll_expired": "Poll closed"
},
"upload_btn": {
"select_file": "Select a file",

View File

@@ -323,7 +323,18 @@
"attach_file": "Bestand bijvoegen",
"uploading_file": "Uploaden...",
"remove_file": "Bestand verwijderen",
"file_too_large": "Bestand te groot"
"file_too_large": "Bestand te groot",
"poll_btn_title": "Peiling aanmaken",
"poll_question_placeholder": "Peilingvraag...",
"poll_option_placeholder": "Optie...",
"poll_add_option": "Optie toevoegen",
"poll_remove": "Peiling verwijderen",
"poll_vote": "Stemmen",
"poll_voted": "Je hebt gestemd",
"poll_votes": "stemmen",
"poll_vote_single": "stem",
"poll_delete": "Peiling verwijderen",
"poll_expired": "Peiling gesloten"
},
"upload_btn": {
"select_file": "Selecteer een bestand",

View File

@@ -321,7 +321,18 @@
"attach_file": "Datei anflanschen",
"uploading_file": "Wird aufladiert...",
"remove_file": "Datei entfernen",
"file_too_large": "Datei zu voluminös"
"file_too_large": "Datei zu voluminös",
"poll_btn_title": "Umfrage erstellen",
"poll_question_placeholder": "Frage",
"poll_option_placeholder": "Option",
"poll_add_option": "Option hinzufügen",
"poll_remove": "Umfrage entfernen",
"poll_vote": "Abstimmen",
"poll_voted": "Abgestimmt",
"poll_votes": "Stimmen",
"poll_vote_single": "Stimme",
"poll_delete": "Umfrage löschen",
"poll_expired": "Umfrage geschlossen"
},
"upload_btn": {
"select_file": "Datei auswählen",

View File

@@ -1011,6 +1011,52 @@ export default {
// Table might not exist yet, gracefully degrade
for (const c of comments) c.files = [];
}
// Fetch poll data for comments that have one
try {
const pollRows = await db`
SELECT
cp.id as poll_id,
cp.comment_id,
cp.question,
cp.expires_at,
json_agg(
json_build_object(
'id', cpo.id,
'text', cpo.text,
'sort_order', cpo.sort_order,
'vote_count', COALESCE(vote_counts.cnt, 0)
) ORDER BY cpo.sort_order ASC, cpo.id ASC
) AS options,
COALESCE(SUM(vote_counts.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
) vote_counts ON vote_counts.option_id = cpo.id
WHERE cp.comment_id = ANY(${commentIds}::int[])
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at
`;
const pollMap = new Map();
for (const p of pollRows) {
pollMap.set(p.comment_id, {
id: p.poll_id,
question: p.question,
expires_at: p.expires_at,
options: p.options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: null // filled in per-request context if needed
});
}
for (const c of comments) {
c.poll = pollMap.get(c.id) || null;
}
} catch (e) {
// Poll tables might not exist yet
for (const c of comments) c.poll = null;
}
}
console.log(`[${new Date().toISOString()}] [GETCOMMENTS] Fetched ${comments.length} comments for item ${itemId} in ${Date.now() - tStart}ms`);

View File

@@ -43,6 +43,24 @@ export default (router, tpl) => {
if (sub.length > 0) is_subscribed = true;
}
// Fill in per-user poll votes
if (req.session && cfg.websrv.enable_comment_polls) {
const pollComments = comments.filter(c => c.poll);
if (pollComments.length > 0) {
const pollIds = pollComments.map(c => c.poll.id);
try {
const votes = await db`
SELECT poll_id, option_id FROM comment_poll_votes
WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id}
`;
const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id]));
for (const c of pollComments) {
if (c.poll) c.poll.user_vote_option_id = voteMap.get(c.poll.id) || null;
}
} catch (e) { /* graceful */ }
}
}
// Transform for frontend if needed, or send as is
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -271,7 +289,9 @@ export default (router, tpl) => {
const fileIdsRaw = body.file_ids || '';
const fileIds = fileIdsRaw ? fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) : [];
if ((!content || !content.trim()) && fileIds.length === 0) {
const hasPoll = body.has_poll === '1' || body.has_poll === 'true';
if ((!content || !content.trim()) && fileIds.length === 0 && !hasPoll) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
}
@@ -983,5 +1003,206 @@ export default (router, tpl) => {
}
});
// ──────────────────────────────────────────────────────────────────────────
// Poll creation — called after a comment is inserted (internal helper)
// ──────────────────────────────────────────────────────────────────────────
const createPollForComment = async (commentId, pollData) => {
if (!cfg.websrv.enable_comment_polls) return null;
const { question, options } = pollData || {};
if (!question || !question.trim()) 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);
if (cleanOptions.length < 2 || cleanOptions.length > 10) return null;
const [poll] = await db`
INSERT INTO comment_polls (comment_id, question)
VALUES (${commentId}, ${question.trim()})
RETURNING id
`;
const pollId = poll.id;
for (let i = 0; i < cleanOptions.length; i++) {
await db`
INSERT INTO comment_poll_options (poll_id, text, sort_order)
VALUES (${pollId}, ${cleanOptions[i]}, ${i})
`;
}
const optRows = await db`SELECT id, text, sort_order FROM comment_poll_options WHERE poll_id = ${pollId} ORDER BY sort_order ASC`;
return {
id: pollId,
question: question.trim(),
options: optRows.map(o => ({ id: o.id, text: o.text, sort_order: o.sort_order, vote_count: 0 })),
total_votes: 0,
user_vote_option_id: null
};
};
// Patch POST /api/comments to support optional poll payload
// We cannot re-define the same route, so we intercept via a pre-middleware trick.
// Instead we add a dedicated endpoint that the frontend always uses for polls.
// POST /api/polls/:commentId — attach a poll to an existing just-created comment
// (frontend calls this immediately after posting the comment)
router.post(/\/api\/polls\/attach\/(?<commentId>\d+)/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Polls disabled' }) });
const commentId = req.params.commentId;
const body = req.post || {};
// Verify this comment belongs to the logged-in user and has no poll yet
const comment = await db`SELECT id, user_id FROM comments WHERE id = ${commentId} AND is_deleted = false LIMIT 1`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: 'Comment not found' }) });
if (comment[0].user_id !== req.session.id && !req.session.admin && !req.session.is_moderator) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
}
const existing = await db`SELECT id FROM comment_polls WHERE comment_id = ${commentId} LIMIT 1`;
if (existing.length) return res.reply({ code: 409, body: JSON.stringify({ success: false, message: 'Poll already exists' }) });
let pollData;
try {
pollData = typeof body.poll === 'string' ? JSON.parse(body.poll) : body.poll;
} catch (e) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll JSON' }) });
}
try {
const poll = await createPollForComment(parseInt(commentId, 10), pollData);
if (!poll) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll data (need question + 2-10 options)' }) });
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, poll }) });
} catch (err) {
console.error('[POLLS] createPollForComment error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: 'Database error' }) });
}
});
// GET /api/polls/:pollId — fetch poll with current user's vote
router.get(/\/api\/polls\/(?<pollId>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
try {
const pollRows = await db`
SELECT
cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at,
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.id = ${pollId}
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at
`;
if (!pollRows.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const p = pollRows[0];
let userVoteOptionId = null;
if (req.session) {
const vote = await db`SELECT option_id FROM comment_poll_votes WHERE poll_id = ${pollId} AND user_id = ${req.session.id} LIMIT 1`;
if (vote.length) userVoteOptionId = vote[0].option_id;
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
poll: {
id: p.poll_id,
comment_id: p.comment_id,
question: p.question,
expires_at: p.expires_at,
options: p.options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: userVoteOptionId
}
})
});
} catch (err) {
console.error('[POLLS] GET error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// POST /api/polls/:pollId/vote — cast or change vote
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
const body = req.post || {};
const optionId = parseInt(body.option_id, 10);
if (!optionId) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Missing option_id' }) });
try {
// Verify option belongs to poll
const opt = await db`SELECT id FROM comment_poll_options WHERE id = ${optionId} AND poll_id = ${pollId} LIMIT 1`;
if (!opt.length) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid option' }) });
// Check expiry
const poll = await db`SELECT expires_at FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
if (poll[0].expires_at && new Date(poll[0].expires_at) < new Date()) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Poll has expired' }) });
}
// Upsert vote (change allowed)
await db`
INSERT INTO comment_poll_votes (poll_id, option_id, user_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
const rows = await db`
SELECT cpo.id, cpo.text, cpo.sort_order, COALESCE(vc.cnt, 0)::int AS vote_count
FROM comment_poll_options cpo
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes WHERE poll_id = ${pollId} GROUP BY option_id) vc ON vc.option_id = cpo.id
WHERE cpo.poll_id = ${pollId}
ORDER BY cpo.sort_order ASC
`;
const totalVotes = rows.reduce((s, r) => s + r.vote_count, 0);
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, options: rows, total_votes: totalVotes, user_vote_option_id: optionId })
});
} catch (err) {
console.error('[POLLS] vote error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// DELETE /api/polls/:pollId — admin/mod or creator can delete
router.post(/\/api\/polls\/(?<pollId>\d+)\/delete/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
try {
const poll = await db`
SELECT cp.id, cp.comment_id, c.user_id
FROM comment_polls cp
JOIN comments c ON c.id = cp.comment_id
WHERE cp.id = ${pollId} LIMIT 1
`;
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const isCreator = poll[0].user_id === req.session.id;
if (!isCreator && !req.session.admin && !req.session.is_moderator) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
}
await db`DELETE FROM comment_polls WHERE id = ${pollId}`;
// Notify live update
db.notify('comments', JSON.stringify({ type: 'poll_deleted', poll_id: pollId, comment_id: poll[0].comment_id }));
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
} catch (err) {
console.error('[POLLS] delete error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router;
};

View File

@@ -1154,6 +1154,7 @@ process.on('uncaughtException', err => {
fileupload_comments_max: cfg.websrv.fileupload_comments_max || 5,
fileupload_comments_mode: cfg.websrv.fileupload_comments_mode || 'attachment',
fileupload_comments_mimes: Array.isArray(cfg.websrv.fileupload_comments_mimes) ? cfg.websrv.fileupload_comments_mimes : ['image', 'video', 'audio'],
enable_comment_polls: cfg.websrv.enable_comment_polls || false,
get fonts() {
try {