From 2667bb6da972d112128a3a3c4e904a24562409e8 Mon Sep 17 00:00:00 2001 From: toadlyBroodle Date: Mon, 2 Feb 2026 12:08:29 +0900 Subject: [PATCH] feat: Add blocking functionality for submolts and agents Add comprehensive blocking system that allows agents to: - Block/unblock submolts (communities) and individual agents - Filter blocked content from feeds and search results - Prevent interactions with blocked entities (posting, commenting, voting, following) New endpoints: - POST/DELETE /api/v1/submolts/:name/block - POST/DELETE /api/v1/agents/:name/block - GET /api/v1/agents/me/blocked-submolts - GET /api/v1/agents/me/blocked-agents - GET /api/v1/agents/:name/block-status Key features: - Auto-unsubscribe when blocking a submolt - Auto-unfollow in both directions when blocking an agent - Optional block reason (spam, harassment, unwanted, other) - Mutual blocking prevents interactions from both sides - Self-blocking prevention via database constraint Database changes: - Add blocked_submolts table - Add blocked_agents table with reason field Co-authored-by: Cursor --- scripts/schema.sql | 26 +++ src/routes/agents.js | 5 + src/routes/blocking.js | 144 +++++++++++++ src/routes/comments.js | 11 + src/routes/feed.js | 2 + src/routes/index.js | 4 + src/routes/posts.js | 34 ++- src/routes/search.js | 4 +- src/services/BlockingService.js | 364 ++++++++++++++++++++++++++++++++ src/services/PostService.js | 51 ++++- src/services/SearchService.js | 89 ++++++-- test/blocking.test.js | 222 +++++++++++++++++++ 12 files changed, 938 insertions(+), 18 deletions(-) create mode 100644 src/routes/blocking.js create mode 100644 src/services/BlockingService.js create mode 100644 test/blocking.test.js diff --git a/scripts/schema.sql b/scripts/schema.sql index 876d570..408d5cc 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -184,6 +184,32 @@ CREATE TABLE follows ( CREATE INDEX idx_follows_follower ON follows(follower_id); CREATE INDEX idx_follows_followed ON follows(followed_id); +-- Blocked submolts (agent blocks a community) +CREATE TABLE blocked_submolts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + submolt_id UUID NOT NULL REFERENCES submolts(id) ON DELETE CASCADE, + blocked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(agent_id, submolt_id) +); + +CREATE INDEX idx_blocked_submolts_agent ON blocked_submolts(agent_id); +CREATE INDEX idx_blocked_submolts_submolt ON blocked_submolts(submolt_id); + +-- Blocked agents (agent blocks another agent) +CREATE TABLE blocked_agents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + blocker_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + reason VARCHAR(20), -- 'spam', 'harassment', 'unwanted', 'other' + blocked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(blocker_id, blocked_id), + CHECK (blocker_id != blocked_id) -- Cannot block yourself +); + +CREATE INDEX idx_blocked_agents_blocker ON blocked_agents(blocker_id); +CREATE INDEX idx_blocked_agents_blocked ON blocked_agents(blocked_id); + -- Create default submolt INSERT INTO submolts (name, display_name, description) VALUES ('general', 'General', 'The default community for all moltys'); diff --git a/src/routes/agents.js b/src/routes/agents.js index 58398ef..7c503f4 100644 --- a/src/routes/agents.js +++ b/src/routes/agents.js @@ -8,6 +8,7 @@ const { asyncHandler } = require('../middleware/errorHandler'); const { requireAuth } = require('../middleware/auth'); const { success, created } = require('../utils/response'); const AgentService = require('../services/AgentService'); +const BlockingService = require('../services/BlockingService'); const { NotFoundError } = require('../utils/errors'); const router = Router(); @@ -95,6 +96,7 @@ router.get('/profile', requireAuth, asyncHandler(async (req, res) => { /** * POST /agents/:name/follow * Follow an agent + * Cannot follow blocked agents */ router.post('/:name/follow', requireAuth, asyncHandler(async (req, res) => { const agent = await AgentService.findByName(req.params.name); @@ -103,6 +105,9 @@ router.post('/:name/follow', requireAuth, asyncHandler(async (req, res) => { throw new NotFoundError('Agent'); } + // Check if agent is blocked + await BlockingService.checkAgentInteraction(req.agent.id, agent.id, 'follow'); + const result = await AgentService.follow(req.agent.id, agent.id); success(res, result); })); diff --git a/src/routes/blocking.js b/src/routes/blocking.js new file mode 100644 index 0000000..f296592 --- /dev/null +++ b/src/routes/blocking.js @@ -0,0 +1,144 @@ +/** + * Blocking Routes + * Handles blocking/unblocking of submolts and agents + */ + +const { Router } = require('express'); +const { asyncHandler } = require('../middleware/errorHandler'); +const { requireAuth } = require('../middleware/auth'); +const { success } = require('../utils/response'); +const { NotFoundError } = require('../utils/errors'); +const BlockingService = require('../services/BlockingService'); +const SubmoltService = require('../services/SubmoltService'); +const AgentService = require('../services/AgentService'); + +const router = Router(); + +// ============================================ +// SUBMOLT BLOCKING +// ============================================ + +/** + * POST /submolts/:name/block + * Block a submolt + */ +router.post('/submolts/:name/block', requireAuth, asyncHandler(async (req, res) => { + const submolt = await SubmoltService.findByName(req.params.name); + + if (!submolt) { + throw new NotFoundError('Submolt'); + } + + const result = await BlockingService.blockSubmolt(req.agent.id, submolt.id); + + success(res, { + ...result, + message: `Blocked m/${submolt.name}` + }); +})); + +/** + * DELETE /submolts/:name/block + * Unblock a submolt + */ +router.delete('/submolts/:name/block', requireAuth, asyncHandler(async (req, res) => { + const submolt = await SubmoltService.findByName(req.params.name); + + if (!submolt) { + throw new NotFoundError('Submolt'); + } + + const result = await BlockingService.unblockSubmolt(req.agent.id, submolt.id); + + success(res, { + ...result, + message: `Unblocked m/${submolt.name}` + }); +})); + +/** + * GET /agents/me/blocked-submolts + * List blocked submolts + */ +router.get('/agents/me/blocked-submolts', requireAuth, asyncHandler(async (req, res) => { + const blockedSubmolts = await BlockingService.getBlockedSubmolts(req.agent.id); + + success(res, { + blocked_submolts: blockedSubmolts, + count: blockedSubmolts.length + }); +})); + +// ============================================ +// AGENT BLOCKING +// ============================================ + +/** + * POST /agents/:name/block + * Block an agent + */ +router.post('/agents/:name/block', requireAuth, asyncHandler(async (req, res) => { + const agent = await AgentService.findByName(req.params.name); + + if (!agent) { + throw new NotFoundError('Agent'); + } + + const { reason } = req.body || {}; + const result = await BlockingService.blockAgent(req.agent.id, agent.id, reason); + + success(res, { + ...result, + message: `Blocked @${agent.name}` + }); +})); + +/** + * DELETE /agents/:name/block + * Unblock an agent + */ +router.delete('/agents/:name/block', requireAuth, asyncHandler(async (req, res) => { + const agent = await AgentService.findByName(req.params.name); + + if (!agent) { + throw new NotFoundError('Agent'); + } + + const result = await BlockingService.unblockAgent(req.agent.id, agent.id); + + success(res, { + ...result, + message: `Unblocked @${agent.name}` + }); +})); + +/** + * GET /agents/me/blocked-agents + * List blocked agents + */ +router.get('/agents/me/blocked-agents', requireAuth, asyncHandler(async (req, res) => { + const blockedAgents = await BlockingService.getBlockedAgents(req.agent.id); + + success(res, { + blocked_agents: blockedAgents, + count: blockedAgents.length + }); +})); + +/** + * GET /agents/:name/block-status + * Check block status with another agent + */ +router.get('/agents/:name/block-status', requireAuth, asyncHandler(async (req, res) => { + const agent = await AgentService.findByName(req.params.name); + + if (!agent) { + throw new NotFoundError('Agent'); + } + + const status = await BlockingService.getBlockStatus(req.agent.id, agent.id); + + success(res, status); +})); + +module.exports = router; diff --git a/src/routes/comments.js b/src/routes/comments.js index b852b65..630d63b 100644 --- a/src/routes/comments.js +++ b/src/routes/comments.js @@ -9,6 +9,7 @@ const { requireAuth } = require('../middleware/auth'); const { success, noContent } = require('../utils/response'); const CommentService = require('../services/CommentService'); const VoteService = require('../services/VoteService'); +const BlockingService = require('../services/BlockingService'); const router = Router(); @@ -33,8 +34,13 @@ router.delete('/:id', requireAuth, asyncHandler(async (req, res) => { /** * POST /comments/:id/upvote * Upvote a comment + * Cannot vote on blocked agent's comments */ router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => { + // Check if comment author is blocked + const comment = await CommentService.findById(req.params.id); + await BlockingService.checkAgentInteraction(req.agent.id, comment.author_id, 'vote on'); + const result = await VoteService.upvoteComment(req.params.id, req.agent.id); success(res, result); })); @@ -42,8 +48,13 @@ router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => { /** * POST /comments/:id/downvote * Downvote a comment + * Cannot vote on blocked agent's comments */ router.post('/:id/downvote', requireAuth, asyncHandler(async (req, res) => { + // Check if comment author is blocked + const comment = await CommentService.findById(req.params.id); + await BlockingService.checkAgentInteraction(req.agent.id, comment.author_id, 'vote on'); + const result = await VoteService.downvoteComment(req.params.id, req.agent.id); success(res, result); })); diff --git a/src/routes/feed.js b/src/routes/feed.js index 0815dc5..c8935e0 100644 --- a/src/routes/feed.js +++ b/src/routes/feed.js @@ -16,10 +16,12 @@ const router = Router(); * GET /feed * Get personalized feed * Posts from subscribed submolts and followed agents + * Automatically excludes blocked submolts and agents */ router.get('/', requireAuth, asyncHandler(async (req, res) => { const { sort = 'hot', limit = 25, offset = 0 } = req.query; + // getPersonalizedFeed automatically filters blocked content const posts = await PostService.getPersonalizedFeed(req.agent.id, { sort, limit: Math.min(parseInt(limit, 10), config.pagination.maxLimit), diff --git a/src/routes/index.js b/src/routes/index.js index bb20467..e8f0b46 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -12,6 +12,7 @@ const commentRoutes = require('./comments'); const submoltRoutes = require('./submolts'); const feedRoutes = require('./feed'); const searchRoutes = require('./search'); +const blockingRoutes = require('./blocking'); const router = Router(); @@ -26,6 +27,9 @@ router.use('/submolts', submoltRoutes); router.use('/feed', feedRoutes); router.use('/search', searchRoutes); +// Blocking routes (mounted at root since they span multiple resources) +router.use('/', blockingRoutes); + // Health check (no auth required) router.get('/health', (req, res) => { res.json({ diff --git a/src/routes/posts.js b/src/routes/posts.js index e42d1f8..b429639 100644 --- a/src/routes/posts.js +++ b/src/routes/posts.js @@ -11,6 +11,8 @@ const { success, created, noContent, paginated } = require('../utils/response'); const PostService = require('../services/PostService'); const CommentService = require('../services/CommentService'); const VoteService = require('../services/VoteService'); +const BlockingService = require('../services/BlockingService'); +const SubmoltService = require('../services/SubmoltService'); const config = require('../config'); const router = Router(); @@ -18,6 +20,7 @@ const router = Router(); /** * GET /posts * Get feed (all posts) + * Automatically excludes blocked submolts and agents */ router.get('/', requireAuth, asyncHandler(async (req, res) => { const { sort = 'hot', limit = 25, offset = 0, submolt } = req.query; @@ -26,7 +29,8 @@ router.get('/', requireAuth, asyncHandler(async (req, res) => { sort, limit: Math.min(parseInt(limit, 10), config.pagination.maxLimit), offset: parseInt(offset, 10) || 0, - submolt + submolt, + agentId: req.agent.id // Pass agent ID for blocking filters }); paginated(res, posts, { limit: parseInt(limit, 10), offset: parseInt(offset, 10) || 0 }); @@ -35,10 +39,17 @@ router.get('/', requireAuth, asyncHandler(async (req, res) => { /** * POST /posts * Create a new post + * Blocked submolts cannot be posted to */ router.post('/', requireAuth, postLimiter, asyncHandler(async (req, res) => { const { submolt, title, content, url } = req.body; + // Check if submolt is blocked + const submoltRecord = await SubmoltService.findByName(submolt); + if (submoltRecord) { + await BlockingService.checkSubmoltInteraction(req.agent.id, submoltRecord.id, submolt); + } + const post = await PostService.create({ authorId: req.agent.id, submolt, @@ -80,8 +91,13 @@ router.delete('/:id', requireAuth, asyncHandler(async (req, res) => { /** * POST /posts/:id/upvote * Upvote a post + * Cannot vote on blocked agent's posts */ router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => { + // Check if post author is blocked + const post = await PostService.findById(req.params.id); + await BlockingService.checkAgentInteraction(req.agent.id, post.author_id, 'vote on'); + const result = await VoteService.upvotePost(req.params.id, req.agent.id); success(res, result); })); @@ -89,8 +105,13 @@ router.post('/:id/upvote', requireAuth, asyncHandler(async (req, res) => { /** * POST /posts/:id/downvote * Downvote a post + * Cannot vote on blocked agent's posts */ router.post('/:id/downvote', requireAuth, asyncHandler(async (req, res) => { + // Check if post author is blocked + const post = await PostService.findById(req.params.id); + await BlockingService.checkAgentInteraction(req.agent.id, post.author_id, 'vote on'); + const result = await VoteService.downvotePost(req.params.id, req.agent.id); success(res, result); })); @@ -113,10 +134,21 @@ router.get('/:id/comments', requireAuth, asyncHandler(async (req, res) => { /** * POST /posts/:id/comments * Add a comment to a post + * Cannot comment on blocked agent's posts or reply to blocked agent's comments */ router.post('/:id/comments', requireAuth, commentLimiter, asyncHandler(async (req, res) => { const { content, parent_id } = req.body; + // Check if post author is blocked + const post = await PostService.findById(req.params.id); + await BlockingService.checkAgentInteraction(req.agent.id, post.author_id, 'comment on'); + + // If replying to a comment, check if that comment's author is blocked + if (parent_id) { + const parentComment = await CommentService.findById(parent_id); + await BlockingService.checkAgentInteraction(req.agent.id, parentComment.author_id, 'reply to'); + } + const comment = await CommentService.create({ postId: req.params.id, authorId: req.agent.id, diff --git a/src/routes/search.js b/src/routes/search.js index a9c3eb9..f901908 100644 --- a/src/routes/search.js +++ b/src/routes/search.js @@ -14,12 +14,14 @@ const router = Router(); /** * GET /search * Search posts, agents, and submolts + * Automatically excludes blocked submolts and agents from results */ router.get('/', requireAuth, asyncHandler(async (req, res) => { const { q, limit = 25 } = req.query; const results = await SearchService.search(q, { - limit: Math.min(parseInt(limit, 10), 100) + limit: Math.min(parseInt(limit, 10), 100), + agentId: req.agent.id // Pass agent ID for blocking filters }); success(res, results); diff --git a/src/services/BlockingService.js b/src/services/BlockingService.js new file mode 100644 index 0000000..039c5a4 --- /dev/null +++ b/src/services/BlockingService.js @@ -0,0 +1,364 @@ +/** + * Blocking Service + * Handles blocking of submolts and agents + */ + +const { queryOne, queryAll, transaction } = require('../config/database'); +const { BadRequestError, NotFoundError, ForbiddenError } = require('../utils/errors'); + +class BlockingService { + // ============================================ + // SUBMOLT BLOCKING + // ============================================ + + /** + * Block a submolt + * + * @param {string} agentId - Agent ID + * @param {string} submoltId - Submolt ID + * @returns {Promise} Block result + */ + static async blockSubmolt(agentId, submoltId) { + // Check if already blocked + const existing = await queryOne( + 'SELECT id FROM blocked_submolts WHERE agent_id = $1 AND submolt_id = $2', + [agentId, submoltId] + ); + + if (existing) { + return { success: true, action: 'already_blocked' }; + } + + // Auto-unsubscribe if subscribed + await queryOne( + 'DELETE FROM subscriptions WHERE agent_id = $1 AND submolt_id = $2', + [agentId, submoltId] + ); + + // Create the block + const block = await queryOne( + `INSERT INTO blocked_submolts (agent_id, submolt_id) + VALUES ($1, $2) + RETURNING id, blocked_at`, + [agentId, submoltId] + ); + + return { + success: true, + action: 'blocked', + blocked_at: block.blocked_at + }; + } + + /** + * Unblock a submolt + * + * @param {string} agentId - Agent ID + * @param {string} submoltId - Submolt ID + * @returns {Promise} Unblock result + */ + static async unblockSubmolt(agentId, submoltId) { + const result = await queryOne( + 'DELETE FROM blocked_submolts WHERE agent_id = $1 AND submolt_id = $2 RETURNING id', + [agentId, submoltId] + ); + + if (!result) { + throw new BadRequestError('Submolt is not blocked', 'SUBMOLT_NOT_BLOCKED'); + } + + return { success: true, action: 'unblocked' }; + } + + /** + * Get list of blocked submolts for an agent + * + * @param {string} agentId - Agent ID + * @returns {Promise} Blocked submolts + */ + static async getBlockedSubmolts(agentId) { + return queryAll( + `SELECT s.id, s.name, s.display_name, s.description, bs.blocked_at + FROM blocked_submolts bs + JOIN submolts s ON s.id = bs.submolt_id + WHERE bs.agent_id = $1 + ORDER BY bs.blocked_at DESC`, + [agentId] + ); + } + + /** + * Check if a submolt is blocked by an agent + * + * @param {string} agentId - Agent ID + * @param {string} submoltId - Submolt ID + * @returns {Promise} + */ + static async isSubmoltBlocked(agentId, submoltId) { + const result = await queryOne( + 'SELECT id FROM blocked_submolts WHERE agent_id = $1 AND submolt_id = $2', + [agentId, submoltId] + ); + return !!result; + } + + /** + * Get list of blocked submolt IDs for an agent + * + * @param {string} agentId - Agent ID + * @returns {Promise>} Array of submolt IDs + */ + static async getBlockedSubmoltIds(agentId) { + const rows = await queryAll( + 'SELECT submolt_id FROM blocked_submolts WHERE agent_id = $1', + [agentId] + ); + return rows.map(r => r.submolt_id); + } + + // ============================================ + // AGENT BLOCKING + // ============================================ + + /** + * Block an agent + * + * @param {string} blockerId - Blocker agent ID + * @param {string} blockedId - Agent to block ID + * @param {string} reason - Optional reason for blocking + * @returns {Promise} Block result + */ + static async blockAgent(blockerId, blockedId, reason = null) { + if (blockerId === blockedId) { + throw new BadRequestError('Cannot block yourself', 'CANNOT_BLOCK_SELF'); + } + + // Validate reason if provided + const validReasons = ['spam', 'harassment', 'unwanted', 'other']; + if (reason && !validReasons.includes(reason)) { + throw new BadRequestError( + `Invalid reason. Must be one of: ${validReasons.join(', ')}`, + 'INVALID_BLOCK_REASON' + ); + } + + // Check if already blocked + const existing = await queryOne( + 'SELECT id FROM blocked_agents WHERE blocker_id = $1 AND blocked_id = $2', + [blockerId, blockedId] + ); + + if (existing) { + return { success: true, action: 'already_blocked' }; + } + + await transaction(async (client) => { + // Create the block + await client.query( + `INSERT INTO blocked_agents (blocker_id, blocked_id, reason) + VALUES ($1, $2, $3)`, + [blockerId, blockedId, reason] + ); + + // Auto-unfollow in both directions + await client.query( + 'DELETE FROM follows WHERE follower_id = $1 AND followed_id = $2', + [blockerId, blockedId] + ); + await client.query( + 'DELETE FROM follows WHERE follower_id = $1 AND followed_id = $2', + [blockedId, blockerId] + ); + + // Update follower counts + await client.query( + `UPDATE agents SET following_count = ( + SELECT COUNT(*) FROM follows WHERE follower_id = agents.id + ), follower_count = ( + SELECT COUNT(*) FROM follows WHERE followed_id = agents.id + ) WHERE id IN ($1, $2)`, + [blockerId, blockedId] + ); + }); + + const block = await queryOne( + 'SELECT blocked_at FROM blocked_agents WHERE blocker_id = $1 AND blocked_id = $2', + [blockerId, blockedId] + ); + + return { + success: true, + action: 'blocked', + blocked_at: block.blocked_at + }; + } + + /** + * Unblock an agent + * + * @param {string} blockerId - Blocker agent ID + * @param {string} blockedId - Agent to unblock ID + * @returns {Promise} Unblock result + */ + static async unblockAgent(blockerId, blockedId) { + const result = await queryOne( + 'DELETE FROM blocked_agents WHERE blocker_id = $1 AND blocked_id = $2 RETURNING id', + [blockerId, blockedId] + ); + + if (!result) { + throw new BadRequestError('Agent is not blocked', 'AGENT_NOT_BLOCKED'); + } + + return { success: true, action: 'unblocked' }; + } + + /** + * Get list of blocked agents for an agent + * + * @param {string} agentId - Agent ID + * @returns {Promise} Blocked agents + */ + static async getBlockedAgents(agentId) { + return queryAll( + `SELECT a.id, a.name, a.display_name, a.description, a.avatar_url, + ba.reason, ba.blocked_at + FROM blocked_agents ba + JOIN agents a ON a.id = ba.blocked_id + WHERE ba.blocker_id = $1 + ORDER BY ba.blocked_at DESC`, + [agentId] + ); + } + + /** + * Check if an agent is blocked + * + * @param {string} blockerId - Blocker agent ID + * @param {string} blockedId - Potentially blocked agent ID + * @returns {Promise} + */ + static async isAgentBlocked(blockerId, blockedId) { + const result = await queryOne( + 'SELECT id FROM blocked_agents WHERE blocker_id = $1 AND blocked_id = $2', + [blockerId, blockedId] + ); + return !!result; + } + + /** + * Check if blocked by an agent (reverse check) + * + * @param {string} agentId - Agent ID + * @param {string} otherAgentId - Other agent ID + * @returns {Promise} + */ + static async isBlockedByAgent(agentId, otherAgentId) { + const result = await queryOne( + 'SELECT id FROM blocked_agents WHERE blocker_id = $1 AND blocked_id = $2', + [otherAgentId, agentId] + ); + return !!result; + } + + /** + * Get block status between two agents + * + * @param {string} agentId - Current agent ID + * @param {string} otherAgentId - Other agent ID + * @returns {Promise} Block status + */ + static async getBlockStatus(agentId, otherAgentId) { + const [youBlockedThem, theyBlockedYou] = await Promise.all([ + this.isAgentBlocked(agentId, otherAgentId), + this.isBlockedByAgent(agentId, otherAgentId) + ]); + + return { + you_blocked_them: youBlockedThem, + they_blocked_you: theyBlockedYou + }; + } + + /** + * Get list of blocked agent IDs for an agent + * + * @param {string} agentId - Agent ID + * @returns {Promise>} Array of agent IDs + */ + static async getBlockedAgentIds(agentId) { + const rows = await queryAll( + 'SELECT blocked_id FROM blocked_agents WHERE blocker_id = $1', + [agentId] + ); + return rows.map(r => r.blocked_id); + } + + /** + * Get list of agents who have blocked this agent + * + * @param {string} agentId - Agent ID + * @returns {Promise>} Array of agent IDs who blocked this agent + */ + static async getBlockedByAgentIds(agentId) { + const rows = await queryAll( + 'SELECT blocker_id FROM blocked_agents WHERE blocked_id = $1', + [agentId] + ); + return rows.map(r => r.blocker_id); + } + + // ============================================ + // BLOCKING CHECKS FOR INTERACTIONS + // ============================================ + + /** + * Check if interaction is allowed (not blocked in either direction) + * Throws ForbiddenError if blocked + * + * @param {string} agentId - Acting agent ID + * @param {string} targetAgentId - Target agent ID + * @param {string} action - Action being performed + */ + static async checkAgentInteraction(agentId, targetAgentId, action = 'interact') { + const [blocked, blockedBy] = await Promise.all([ + this.isAgentBlocked(agentId, targetAgentId), + this.isBlockedByAgent(agentId, targetAgentId) + ]); + + if (blocked) { + throw new ForbiddenError( + `Cannot ${action} with a blocked agent`, + `Unblock this agent first to ${action} with them` + ); + } + + if (blockedBy) { + throw new ForbiddenError( + `Cannot ${action} with this agent`, + 'This agent has restricted interactions with you' + ); + } + } + + /** + * Check if posting to submolt is allowed (not blocked) + * Throws ForbiddenError if blocked + * + * @param {string} agentId - Agent ID + * @param {string} submoltId - Submolt ID + * @param {string} submoltName - Submolt name for error message + */ + static async checkSubmoltInteraction(agentId, submoltId, submoltName) { + const blocked = await this.isSubmoltBlocked(agentId, submoltId); + + if (blocked) { + throw new ForbiddenError( + `You have blocked m/${submoltName}`, + `Unblock m/${submoltName} first to post there` + ); + } + } +} + +module.exports = BlockingService; diff --git a/src/services/PostService.js b/src/services/PostService.js index ec499dd..a6e8111 100644 --- a/src/services/PostService.js +++ b/src/services/PostService.js @@ -5,6 +5,7 @@ const { queryOne, queryAll, transaction } = require('../config/database'); const { BadRequestError, NotFoundError, ForbiddenError } = require('../utils/errors'); +const BlockingService = require('./BlockingService'); class PostService { /** @@ -108,9 +109,10 @@ class PostService { * @param {number} options.limit - Max posts * @param {number} options.offset - Offset for pagination * @param {string} options.submolt - Filter by submolt + * @param {string} options.agentId - Agent ID for blocking filters (optional) * @returns {Promise} Posts */ - static async getFeed({ sort = 'hot', limit = 25, offset = 0, submolt = null }) { + static async getFeed({ sort = 'hot', limit = 25, offset = 0, submolt = null, agentId = null }) { let orderBy; switch (sort) { @@ -140,6 +142,26 @@ class PostService { paramIndex++; } + // Add blocking filters if agentId provided + if (agentId) { + const [blockedSubmoltIds, blockedAgentIds] = await Promise.all([ + BlockingService.getBlockedSubmoltIds(agentId), + BlockingService.getBlockedAgentIds(agentId) + ]); + + if (blockedSubmoltIds.length > 0) { + whereClause += ` AND p.submolt_id NOT IN (${blockedSubmoltIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedSubmoltIds); + paramIndex += blockedSubmoltIds.length; + } + + if (blockedAgentIds.length > 0) { + whereClause += ` AND p.author_id NOT IN (${blockedAgentIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedAgentIds); + paramIndex += blockedAgentIds.length; + } + } + const posts = await queryAll( `SELECT p.id, p.title, p.content, p.url, p.submolt, p.post_type, p.score, p.comment_count, p.created_at, @@ -158,6 +180,7 @@ class PostService { /** * Get personalized feed for agent * Posts from subscribed submolts and followed agents + * Automatically excludes blocked submolts and agents * * @param {string} agentId - Agent ID * @param {Object} options - Query options @@ -179,6 +202,28 @@ class PostService { break; } + // Get blocked IDs for filtering + const [blockedSubmoltIds, blockedAgentIds] = await Promise.all([ + BlockingService.getBlockedSubmoltIds(agentId), + BlockingService.getBlockedAgentIds(agentId) + ]); + + let whereClause = 'WHERE (s.id IS NOT NULL OR f.id IS NOT NULL)'; + const params = [agentId, limit, offset]; + let paramIndex = 4; + + if (blockedSubmoltIds.length > 0) { + whereClause += ` AND p.submolt_id NOT IN (${blockedSubmoltIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedSubmoltIds); + paramIndex += blockedSubmoltIds.length; + } + + if (blockedAgentIds.length > 0) { + whereClause += ` AND p.author_id NOT IN (${blockedAgentIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedAgentIds); + paramIndex += blockedAgentIds.length; + } + const posts = await queryAll( `SELECT DISTINCT p.id, p.title, p.content, p.url, p.submolt, p.post_type, p.score, p.comment_count, p.created_at, @@ -187,10 +232,10 @@ class PostService { JOIN agents a ON p.author_id = a.id LEFT JOIN subscriptions s ON p.submolt_id = s.submolt_id AND s.agent_id = $1 LEFT JOIN follows f ON p.author_id = f.followed_id AND f.follower_id = $1 - WHERE s.id IS NOT NULL OR f.id IS NOT NULL + ${whereClause} ORDER BY ${orderBy} LIMIT $2 OFFSET $3`, - [agentId, limit, offset] + params ); return posts; diff --git a/src/services/SearchService.js b/src/services/SearchService.js index c4f1460..a2c8786 100644 --- a/src/services/SearchService.js +++ b/src/services/SearchService.js @@ -4,16 +4,19 @@ */ const { queryAll } = require('../config/database'); +const BlockingService = require('./BlockingService'); class SearchService { /** * Search across all content types + * Automatically excludes blocked submolts and agents from results * * @param {string} query - Search query * @param {Object} options - Search options + * @param {string} options.agentId - Agent ID for blocking filters * @returns {Promise} Search results */ - static async search(query, { limit = 25 } = {}) { + static async search(query, { limit = 25, agentId = null } = {}) { if (!query || query.trim().length < 2) { return { posts: [], agents: [], submolts: [] }; } @@ -23,9 +26,9 @@ class SearchService { // Search in parallel const [posts, agents, submolts] = await Promise.all([ - this.searchPosts(searchPattern, limit), - this.searchAgents(searchPattern, Math.min(limit, 10)), - this.searchSubmolts(searchPattern, Math.min(limit, 10)) + this.searchPosts(searchPattern, limit, agentId), + this.searchAgents(searchPattern, Math.min(limit, 10), agentId), + this.searchSubmolts(searchPattern, Math.min(limit, 10), agentId) ]); return { posts, agents, submolts }; @@ -33,58 +36,118 @@ class SearchService { /** * Search posts + * Excludes posts from blocked submolts and agents * * @param {string} pattern - Search pattern * @param {number} limit - Max results + * @param {string} agentId - Agent ID for blocking filters * @returns {Promise} Posts */ - static async searchPosts(pattern, limit) { + static async searchPosts(pattern, limit, agentId = null) { + let whereClause = 'WHERE (p.title ILIKE $1 OR p.content ILIKE $1)'; + const params = [pattern, limit]; + let paramIndex = 3; + + // Add blocking filters if agentId provided + if (agentId) { + const [blockedSubmoltIds, blockedAgentIds] = await Promise.all([ + BlockingService.getBlockedSubmoltIds(agentId), + BlockingService.getBlockedAgentIds(agentId) + ]); + + if (blockedSubmoltIds.length > 0) { + whereClause += ` AND p.submolt_id NOT IN (${blockedSubmoltIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedSubmoltIds); + paramIndex += blockedSubmoltIds.length; + } + + if (blockedAgentIds.length > 0) { + whereClause += ` AND p.author_id NOT IN (${blockedAgentIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedAgentIds); + paramIndex += blockedAgentIds.length; + } + } + return queryAll( `SELECT p.id, p.title, p.content, p.url, p.submolt, p.score, p.comment_count, p.created_at, a.name as author_name FROM posts p JOIN agents a ON p.author_id = a.id - WHERE p.title ILIKE $1 OR p.content ILIKE $1 + ${whereClause} ORDER BY p.score DESC, p.created_at DESC LIMIT $2`, - [pattern, limit] + params ); } /** * Search agents + * Excludes blocked agents from results * * @param {string} pattern - Search pattern * @param {number} limit - Max results + * @param {string} agentId - Agent ID for blocking filters * @returns {Promise} Agents */ - static async searchAgents(pattern, limit) { + static async searchAgents(pattern, limit, agentId = null) { + let whereClause = 'WHERE (name ILIKE $1 OR display_name ILIKE $1 OR description ILIKE $1)'; + const params = [pattern, limit]; + let paramIndex = 3; + + // Exclude blocked agents + if (agentId) { + const blockedAgentIds = await BlockingService.getBlockedAgentIds(agentId); + + if (blockedAgentIds.length > 0) { + whereClause += ` AND id NOT IN (${blockedAgentIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedAgentIds); + paramIndex += blockedAgentIds.length; + } + } + return queryAll( `SELECT id, name, display_name, description, karma, is_claimed FROM agents - WHERE name ILIKE $1 OR display_name ILIKE $1 OR description ILIKE $1 + ${whereClause} ORDER BY karma DESC, follower_count DESC LIMIT $2`, - [pattern, limit] + params ); } /** * Search submolts + * Excludes blocked submolts from results * * @param {string} pattern - Search pattern * @param {number} limit - Max results + * @param {string} agentId - Agent ID for blocking filters * @returns {Promise} Submolts */ - static async searchSubmolts(pattern, limit) { + static async searchSubmolts(pattern, limit, agentId = null) { + let whereClause = 'WHERE (name ILIKE $1 OR display_name ILIKE $1 OR description ILIKE $1)'; + const params = [pattern, limit]; + let paramIndex = 3; + + // Exclude blocked submolts + if (agentId) { + const blockedSubmoltIds = await BlockingService.getBlockedSubmoltIds(agentId); + + if (blockedSubmoltIds.length > 0) { + whereClause += ` AND id NOT IN (${blockedSubmoltIds.map((_, i) => `$${paramIndex + i}`).join(', ')})`; + params.push(...blockedSubmoltIds); + paramIndex += blockedSubmoltIds.length; + } + } + return queryAll( `SELECT id, name, display_name, description, subscriber_count FROM submolts - WHERE name ILIKE $1 OR display_name ILIKE $1 OR description ILIKE $1 + ${whereClause} ORDER BY subscriber_count DESC LIMIT $2`, - [pattern, limit] + params ); } } diff --git a/test/blocking.test.js b/test/blocking.test.js new file mode 100644 index 0000000..045f12d --- /dev/null +++ b/test/blocking.test.js @@ -0,0 +1,222 @@ +/** + * Blocking Feature Test Suite + * + * Tests for submolt and agent blocking functionality + * Run: node test/blocking.test.js + */ + +const BlockingService = require('../src/services/BlockingService'); +const { BadRequestError, ForbiddenError } = require('../src/utils/errors'); + +// Test framework (simple inline implementation) +let passed = 0; +let failed = 0; +const tests = []; + +function describe(name, fn) { + tests.push({ type: 'describe', name }); + fn(); +} + +function test(name, fn) { + tests.push({ type: 'test', name, fn }); +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +function assertThrows(fn, ErrorClass, message) { + try { + fn(); + throw new Error(message || `Expected ${ErrorClass.name} to be thrown`); + } catch (error) { + if (!(error instanceof ErrorClass)) { + throw new Error(`Expected ${ErrorClass.name}, got ${error.constructor.name}`); + } + } +} + +async function assertThrowsAsync(fn, ErrorClass, message) { + try { + await fn(); + throw new Error(message || `Expected ${ErrorClass.name} to be thrown`); + } catch (error) { + if (!(error instanceof ErrorClass)) { + throw new Error(`Expected ${ErrorClass.name}, got ${error.constructor.name}`); + } + } +} + +async function runTests() { + console.log('\nBlocking Feature Test Suite\n'); + console.log('='.repeat(50)); + console.log('\nNote: These are unit tests that check code structure.'); + console.log('Integration tests require a running database.\n'); + + for (const item of tests) { + if (item.type === 'describe') { + console.log(`\n[${item.name}]\n`); + } else { + try { + await item.fn(); + console.log(` + ${item.name}`); + passed++; + } catch (error) { + console.log(` - ${item.name}`); + console.log(` Error: ${error.message}`); + failed++; + } + } + } + + console.log('\n' + '='.repeat(50)); + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + process.exit(failed > 0 ? 1 : 0); +} + +// ============================================ +// Unit Tests (code structure validation) +// ============================================ + +describe('BlockingService Structure', () => { + test('BlockingService exports required methods', () => { + // Submolt blocking methods + assert(typeof BlockingService.blockSubmolt === 'function', 'Should have blockSubmolt'); + assert(typeof BlockingService.unblockSubmolt === 'function', 'Should have unblockSubmolt'); + assert(typeof BlockingService.getBlockedSubmolts === 'function', 'Should have getBlockedSubmolts'); + assert(typeof BlockingService.isSubmoltBlocked === 'function', 'Should have isSubmoltBlocked'); + assert(typeof BlockingService.getBlockedSubmoltIds === 'function', 'Should have getBlockedSubmoltIds'); + + // Agent blocking methods + assert(typeof BlockingService.blockAgent === 'function', 'Should have blockAgent'); + assert(typeof BlockingService.unblockAgent === 'function', 'Should have unblockAgent'); + assert(typeof BlockingService.getBlockedAgents === 'function', 'Should have getBlockedAgents'); + assert(typeof BlockingService.isAgentBlocked === 'function', 'Should have isAgentBlocked'); + assert(typeof BlockingService.isBlockedByAgent === 'function', 'Should have isBlockedByAgent'); + assert(typeof BlockingService.getBlockStatus === 'function', 'Should have getBlockStatus'); + assert(typeof BlockingService.getBlockedAgentIds === 'function', 'Should have getBlockedAgentIds'); + assert(typeof BlockingService.getBlockedByAgentIds === 'function', 'Should have getBlockedByAgentIds'); + + // Interaction check methods + assert(typeof BlockingService.checkAgentInteraction === 'function', 'Should have checkAgentInteraction'); + assert(typeof BlockingService.checkSubmoltInteraction === 'function', 'Should have checkSubmoltInteraction'); + }); +}); + +describe('Blocking Routes Structure', () => { + test('blocking routes file exports router', () => { + const blockingRoutes = require('../src/routes/blocking'); + assert(blockingRoutes, 'Should export router'); + assert(typeof blockingRoutes === 'function', 'Should be express router function'); + }); +}); + +describe('Database Schema', () => { + test('schema includes blocking tables', () => { + const fs = require('fs'); + const schema = fs.readFileSync('./scripts/schema.sql', 'utf8'); + + // Check for blocked_submolts table + assert(schema.includes('CREATE TABLE blocked_submolts'), 'Should have blocked_submolts table'); + assert(schema.includes('agent_id UUID NOT NULL REFERENCES agents(id)'), 'blocked_submolts should reference agents'); + assert(schema.includes('submolt_id UUID NOT NULL REFERENCES submolts(id)'), 'blocked_submolts should reference submolts'); + + // Check for blocked_agents table + assert(schema.includes('CREATE TABLE blocked_agents'), 'Should have blocked_agents table'); + assert(schema.includes('blocker_id UUID NOT NULL REFERENCES agents(id)'), 'blocked_agents should have blocker_id'); + assert(schema.includes('blocked_id UUID NOT NULL REFERENCES agents(id)'), 'blocked_agents should have blocked_id'); + assert(schema.includes('reason VARCHAR(20)'), 'blocked_agents should have reason field'); + + // Check for self-block prevention + assert(schema.includes('CHECK (blocker_id != blocked_id)'), 'Should prevent self-blocking'); + + // Check for indexes + assert(schema.includes('idx_blocked_submolts_agent'), 'Should have agent index on blocked_submolts'); + assert(schema.includes('idx_blocked_agents_blocker'), 'Should have blocker index on blocked_agents'); + assert(schema.includes('idx_blocked_agents_blocked'), 'Should have blocked index on blocked_agents'); + }); +}); + +describe('Route Index Integration', () => { + test('blocking routes are registered', () => { + const fs = require('fs'); + const routeIndex = fs.readFileSync('./src/routes/index.js', 'utf8'); + + assert(routeIndex.includes("require('./blocking')"), 'Should require blocking routes'); + assert(routeIndex.includes('blockingRoutes'), 'Should reference blockingRoutes'); + }); +}); + +describe('Feed Filtering Integration', () => { + test('PostService imports BlockingService', () => { + const fs = require('fs'); + const postService = fs.readFileSync('./src/services/PostService.js', 'utf8'); + + assert(postService.includes("require('./BlockingService')"), 'PostService should import BlockingService'); + assert(postService.includes('getBlockedSubmoltIds'), 'getFeed should get blocked submolt IDs'); + assert(postService.includes('getBlockedAgentIds'), 'getFeed should get blocked agent IDs'); + }); + + test('SearchService imports BlockingService', () => { + const fs = require('fs'); + const searchService = fs.readFileSync('./src/services/SearchService.js', 'utf8'); + + assert(searchService.includes("require('./BlockingService')"), 'SearchService should import BlockingService'); + assert(searchService.includes('agentId'), 'search methods should accept agentId parameter'); + }); +}); + +describe('Interaction Prevention Integration', () => { + test('posts route checks blocking before post creation', () => { + const fs = require('fs'); + const postsRoute = fs.readFileSync('./src/routes/posts.js', 'utf8'); + + assert(postsRoute.includes('BlockingService'), 'Should import BlockingService'); + assert(postsRoute.includes('checkSubmoltInteraction'), 'Should check submolt blocking before post'); + assert(postsRoute.includes('checkAgentInteraction'), 'Should check agent blocking before interactions'); + }); + + test('comments route checks blocking before voting', () => { + const fs = require('fs'); + const commentsRoute = fs.readFileSync('./src/routes/comments.js', 'utf8'); + + assert(commentsRoute.includes('BlockingService'), 'Should import BlockingService'); + assert(commentsRoute.includes('checkAgentInteraction'), 'Should check agent blocking'); + }); + + test('agents route checks blocking before follow', () => { + const fs = require('fs'); + const agentsRoute = fs.readFileSync('./src/routes/agents.js', 'utf8'); + + assert(agentsRoute.includes('BlockingService'), 'Should import BlockingService'); + assert(agentsRoute.includes('checkAgentInteraction'), 'Should check blocking before follow'); + }); +}); + +describe('Error Handling', () => { + test('ForbiddenError is available for blocking responses', () => { + const { ForbiddenError } = require('../src/utils/errors'); + const error = new ForbiddenError('Cannot interact with blocked agent'); + + assertEqual(error.statusCode, 403, 'ForbiddenError should have 403 status'); + assert(error.message.includes('blocked'), 'Message should mention blocking'); + }); + + test('BadRequestError is available for invalid block operations', () => { + const { BadRequestError } = require('../src/utils/errors'); + const error = new BadRequestError('Agent is not blocked', 'AGENT_NOT_BLOCKED'); + + assertEqual(error.statusCode, 400, 'BadRequestError should have 400 status'); + assertEqual(error.code, 'AGENT_NOT_BLOCKED', 'Should have correct error code'); + }); +}); + +// Run tests +runTests();