feat: Implement comments, notifications, and custom emojis with new API routes, UI components, and database migrations.
This commit is contained in:
186
src/inc/routes/comments.mjs
Normal file
186
src/inc/routes/comments.mjs
Normal file
@@ -0,0 +1,186 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs"; // Assuming this exists or we need to check imports
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
|
||||
|
||||
// Fetch comments for an item
|
||||
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
|
||||
const itemId = req.params.itemid;
|
||||
const sort = req.url.qs?.sort || 'new'; // 'new' or 'old'
|
||||
|
||||
try {
|
||||
const comments = await db`
|
||||
SELECT
|
||||
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
u.user as username, u.id as user_id, uo.avatar,
|
||||
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE c.item_id = ${itemId}
|
||||
ORDER BY c.created_at ${db.unsafe(sort === 'new' ? 'DESC' : 'ASC')}
|
||||
`;
|
||||
|
||||
let is_subscribed = false;
|
||||
if (req.session) {
|
||||
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
|
||||
if (sub.length > 0) is_subscribed = true;
|
||||
}
|
||||
|
||||
// Transform for frontend if needed, or send as is
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
comments,
|
||||
is_subscribed,
|
||||
user_id: req.session ? req.session.user : null,
|
||||
is_admin: req.session ? req.session.admin : false
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
body: JSON.stringify({ success: false, message: "Database error" })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Post a comment
|
||||
router.post('/api/comments', async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
|
||||
|
||||
console.log("DEBUG: POST /api/comments");
|
||||
|
||||
// Use standard framework parsing
|
||||
const body = req.post || {};
|
||||
const item_id = parseInt(body.item_id, 10);
|
||||
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
|
||||
const content = body.content;
|
||||
|
||||
console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
||||
|
||||
if (!content || !content.trim()) {
|
||||
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
|
||||
}
|
||||
|
||||
try {
|
||||
const newComment = await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id,
|
||||
user_id: req.session.id,
|
||||
parent_id: parent_id || null,
|
||||
content: content
|
||||
})}
|
||||
RETURNING id, created_at
|
||||
`;
|
||||
|
||||
const commentId = parseInt(newComment[0].id, 10);
|
||||
|
||||
// Notify Subscribers (excluding the author)
|
||||
// 1. Get subscribers of the item
|
||||
// 2. If it's a reply, notify parent author? (Optional, complex logic. Let's stick to item subscription for now + Parent author)
|
||||
|
||||
// Logic: Notify users who subscribed to this item OR are the parent author.
|
||||
// Exclude current user.
|
||||
|
||||
// 1. Get subscribers
|
||||
const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id}`;
|
||||
|
||||
// 2. Get parent author
|
||||
let parentAuthor = [];
|
||||
if (parent_id) {
|
||||
parentAuthor = await db`SELECT user_id FROM comments WHERE id = ${parent_id}`;
|
||||
}
|
||||
|
||||
// 3. Collect unique recipients
|
||||
const recipients = new Set();
|
||||
subscribers.forEach(s => recipients.add(s.user_id));
|
||||
parentAuthor.forEach(p => recipients.add(p.user_id));
|
||||
|
||||
// Remove self
|
||||
recipients.delete(req.session.id);
|
||||
|
||||
// 4. Batch insert
|
||||
if (recipients.size > 0) {
|
||||
const notificationsToAdd = Array.from(recipients).map(uid => ({
|
||||
user_id: uid,
|
||||
type: 'comment_reply',
|
||||
item_id: item_id, // Already parsed as int
|
||||
comment_id: commentId, // Already parsed as int
|
||||
reference_id: commentId // Already parsed as int
|
||||
}));
|
||||
|
||||
await db`INSERT INTO notifications ${db(notificationsToAdd)}`;
|
||||
}
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, comment: newComment[0] })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
body: JSON.stringify({ success: false, message: "Database error" })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe toggle
|
||||
router.post(/\/api\/subscribe\/(?<itemid>\d+)/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const itemId = req.params.itemid;
|
||||
|
||||
try {
|
||||
const existing = await db`
|
||||
SELECT 1 FROM comment_subscriptions
|
||||
WHERE user_id = ${req.session.id} AND item_id = ${itemId}
|
||||
`;
|
||||
|
||||
let subscribed = false;
|
||||
if (existing.length > 0) {
|
||||
await db`DELETE FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
|
||||
} else {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemId})`;
|
||||
subscribed = true;
|
||||
}
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, subscribed })
|
||||
});
|
||||
} catch (e) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete comment
|
||||
router.post(/\/api\/comments\/(?<id>\d+)\/delete/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const commentId = req.params.id;
|
||||
|
||||
try {
|
||||
const comment = await db`SELECT user_id FROM comments WHERE id = ${commentId}`;
|
||||
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
||||
|
||||
if (!req.session.admin && comment[0].user_id !== req.session.id) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
|
||||
await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`;
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (e) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
85
src/inc/routes/emojis.mjs
Normal file
85
src/inc/routes/emojis.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
import db from "../sql.mjs";
|
||||
|
||||
import lib from "../lib.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// Admin View
|
||||
router.get(/^\/admin\/emojis\/?$/, lib.auth, async (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render("admin/emojis", { session: req.session, tmp: null }, req)
|
||||
});
|
||||
});
|
||||
|
||||
// List all emojis (Public)
|
||||
router.get('/api/v2/emojis', async (req, res) => {
|
||||
try {
|
||||
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, emojis })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Add emoji (Admin only)
|
||||
router.post('/api/v2/admin/emojis', async (req, res) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
|
||||
const body = req.post || {};
|
||||
const name = body.name ? body.name.trim().toLowerCase() : '';
|
||||
const url = body.url ? body.url.trim() : '';
|
||||
|
||||
if (!name || !url) {
|
||||
return res.reply({ body: JSON.stringify({ success: false, message: "Name and URL required" }) });
|
||||
}
|
||||
|
||||
// Basic name validation (alphanumeric)
|
||||
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||
return res.reply({ body: JSON.stringify({ success: false, message: "Invalid name. Use lowercase a-z, 0-9, _ only." }) });
|
||||
}
|
||||
|
||||
try {
|
||||
const newEmoji = await db`
|
||||
INSERT INTO custom_emojis (name, url) VALUES (${name}, ${url})
|
||||
RETURNING id, name, url
|
||||
`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, emoji: newEmoji[0] })
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.code === '23505') { // Unique violation
|
||||
return res.reply({ body: JSON.stringify({ success: false, message: "Emoji name already exists" }) });
|
||||
}
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete emoji (Admin only)
|
||||
router.post(/\/api\/v2\/admin\/emojis\/(?<id>\d+)\/delete/, async (req, res) => {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
const id = req.params.id;
|
||||
|
||||
try {
|
||||
await db`DELETE FROM custom_emojis WHERE id = ${id}`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
67
src/inc/routes/notifications.mjs
Normal file
67
src/inc/routes/notifications.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import db from "../sql.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// Get unread notifications
|
||||
router.get('/api/notifications', async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
|
||||
try {
|
||||
const notifications = await db`
|
||||
SELECT n.id, n.type, n.item_id, n.comment_id, n.reference_id, n.created_at, n.is_read,
|
||||
u.user as from_user, u.id as from_user_id
|
||||
FROM notifications n
|
||||
-- Join on reference_id (which is comment_id) or comment_id.
|
||||
-- Since we just set both, let's join on comment_id if present, fallback to reference_id?
|
||||
-- The join was: JOIN comments c ON n.comment_id = c.id
|
||||
-- If comment_id was null before my fix, this join would fail for old notifs.
|
||||
-- Let's assume we use reference_id as the ID for now.
|
||||
JOIN comments c ON (n.comment_id = c.id OR n.reference_id = c.id)
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, notifications })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all as read
|
||||
router.post('/api/notifications/read', async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
|
||||
try {
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark single as read (optional, for clicking)
|
||||
router.post(/\/api\/notifications\/(?<id>\d+)\/read/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const id = req.params.id;
|
||||
try {
|
||||
await db`UPDATE notifications SET is_read = true WHERE id = ${id} AND user_id = ${req.session.id}`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
Reference in New Issue
Block a user