UNPKG

@replyke/express

Version:

Replyke: Build interactive apps with social features like comments, votes, feeds, user lists, notifications, and more.

415 lines (414 loc) 17.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const date_fns_1 = require("date-fns"); const sequelize_1 = require("sequelize"); const models_1 = require("../../../models"); const sequelize_query_params_1 = require("../../../constants/sequelize-query-params"); const scoreEntity_1 = __importDefault(require("../../../helpers/scoreEntity")); const config_1 = require("../../../config"); const configureSort = (sortBy) => { const { sequelize } = (0, config_1.getCoreConfig)(); let sort = [["createdAt", "DESC"]]; // Default is newest if (sortBy === "top") { sort = [ [ sequelize.literal(` (COALESCE(array_length(upvotes, 1), 0) - COALESCE(array_length(downvotes, 1), 0)) DESC, COALESCE(array_length(upvotes, 1), 0) DESC `), ], ["createdAt", "DESC"], ]; } else if (sortBy === "hot") { sort = [["score", "DESC"]]; } else if (sortBy === "controversial") { sort = [ [ sequelize.literal(` LOG(COALESCE(array_length(upvotes, 1), 0) + COALESCE(array_length(downvotes, 1), 0) + 1) * (LEAST(COALESCE(array_length(upvotes, 1), 0), COALESCE(array_length(downvotes, 1), 0)) / NULLIF(GREATEST(COALESCE(array_length(upvotes, 1), 0), COALESCE(array_length(downvotes, 1), 0)), 0)) `), "DESC", ], ["createdAt", "DESC"], // Secondary sort by creation time for ties ]; } return sort; }; const configureTimeframe = (query, timeFrame) => { if (!timeFrame) return; let dateThreshold; switch (timeFrame) { case "hour": dateThreshold = (0, date_fns_1.subHours)(new Date(), 1); break; case "day": dateThreshold = (0, date_fns_1.subDays)(new Date(), 1); break; case "week": dateThreshold = (0, date_fns_1.subWeeks)(new Date(), 1); break; case "month": dateThreshold = (0, date_fns_1.subMonths)(new Date(), 1); break; case "year": dateThreshold = (0, date_fns_1.subYears)(new Date(), 1); break; default: throw new Error("Invalid request: 'created_within' must be 'hour' 'day', 'week', 'month' or 'year'"); } query.createdAt = { [sequelize_1.Op.gte]: dateThreshold }; // Use Sequelize's Op.gte operator for date filtering }; const configureSourceId = (query, sourceId) => { if (!sourceId) return; query.sourceId = sourceId; }; const configureUserId = (query, userId) => { if (!userId) return; query.userId = userId; }; const configureKeywords = (query, keywordsFilters) => { if (!keywordsFilters) return; // Extract "includes" and "doesNotInclude" as arrays of non-empty strings let included = []; let excluded = []; if (Array.isArray(keywordsFilters.includes)) { included = keywordsFilters.includes .filter((k) => typeof k === "string" && k.trim() !== "") .map((k) => k.trim()); } if (Array.isArray(keywordsFilters.doesNotInclude)) { excluded = keywordsFilters.doesNotInclude .filter((k) => typeof k === "string" && k.trim() !== "") .map((k) => k.trim()); } // Handle "includes" + "excludes" together // ---------------------------------------- // 1) If both 'includes' and 'excludes' have data: // => AND together { keywords: { [Op.contains]: included } } // and { [Op.not]: { keywords: { [Op.overlap]: excluded } } } // // 2) If only 'includes' is set: // => query.keywords = { [Op.contains]: included } // // 3) If only 'excludes' is set: // => query[Op.not] = { keywords: { [Op.overlap]: excluded } } // // 4) If neither, do nothing. if (included.length > 0 && excluded.length > 0) { query[sequelize_1.Op.and] = [ // Must contain the included keywords { keywords: { [sequelize_1.Op.contains]: included } }, // Must NOT overlap with the excluded keywords { [sequelize_1.Op.not]: { keywords: { [sequelize_1.Op.overlap]: excluded } } }, ]; } else if (included.length > 0) { // Only includes query.keywords = { [sequelize_1.Op.contains]: included }; } else if (excluded.length > 0) { // Only excludes query[sequelize_1.Op.not] = { keywords: { [sequelize_1.Op.overlap]: excluded } }; } }; const configureTitle = (query, titleFilters) => { if (!titleFilters) return; // Possible filters: // 1. Filtering by existence (e.g., hasTitle = true means title is not null) // 2. Filtering by text match (partial or exact) const { hasTitle, includes, doesNotInclude } = titleFilters; query.title = query.title || {}; // Ensure query.content is defined // Normalize 'includes' and 'doesNotInclude' to arrays and filter out invalid values const includesArray = Array.isArray(includes) ? includes : includes ? [includes] : []; const doesNotIncludeArray = Array.isArray(doesNotInclude) ? doesNotInclude : doesNotInclude ? [doesNotInclude] : []; const trimmedIncludesArray = includesArray .map((value) => value.trim()) .filter(Boolean); const trimmedDoesNotIncludeArray = doesNotIncludeArray .map((value) => value.trim()) .filter(Boolean); if (hasTitle === "true") { query.title[sequelize_1.Op.ne] = null; } else if (hasTitle === "false") { query.title[sequelize_1.Op.eq] = null; } // Apply 'like' filters (OR condition for matches) if (trimmedIncludesArray.length > 0) { query.title[sequelize_1.Op.or] = trimmedIncludesArray.map((value) => ({ [sequelize_1.Op.iLike]: `%${value}%`, })); } // Apply 'notLike' filters (AND condition for exclusions) if (trimmedDoesNotIncludeArray.length > 0) { query.title[sequelize_1.Op.and] = trimmedDoesNotIncludeArray.map((value) => ({ [sequelize_1.Op.notILike]: `%${value}%`, })); } }; const configureContent = (query, contentFilters) => { if (!contentFilters) return; // Similar approach as title const { hasContent, includes, doesNotInclude } = contentFilters; // Normalize 'includes' and 'doesNotInclude' to arrays and filter out invalid values const includesArray = Array.isArray(includes) ? includes : includes ? [includes] : []; const doesNotIncludeArray = Array.isArray(doesNotInclude) ? doesNotInclude : doesNotInclude ? [doesNotInclude] : []; const trimmedIncludesArray = includesArray .map((value) => value.trim()) .filter(Boolean); const trimmedDoesNotIncludeArray = doesNotIncludeArray .map((value) => value.trim()) .filter(Boolean); query.content = query.content || {}; // Ensure query.content is defined if (hasContent === "true") { query.content[sequelize_1.Op.ne] = null; } else if (hasContent === "false") { query.content[sequelize_1.Op.eq] = null; } // Apply 'like' filters (OR condition for matches) if (trimmedIncludesArray.length > 0) { query.content[sequelize_1.Op.or] = trimmedIncludesArray.map((value) => ({ [sequelize_1.Op.iLike]: `%${value}%`, })); } // Apply 'notLike' filters (AND condition for exclusions) if (trimmedDoesNotIncludeArray.length > 0) { query.content[sequelize_1.Op.and] = trimmedDoesNotIncludeArray.map((value) => ({ [sequelize_1.Op.notILike]: `%${value}%`, })); } }; const configureAttachments = (query, attachmentsFilters) => { if (!attachmentsFilters) return; // Example: hasMedia = true/false to filter if entities have media // media array is empty if no media is present const { hasAttachments } = attachmentsFilters; if (hasAttachments === "true") { // Check that media array is not empty // array_length(media, 1) > 0 // A simple way: query.attachments = { [sequelize_1.Op.ne]: [] }; } else if (hasAttachments === "false") { query.attachments = { [sequelize_1.Op.eq]: [] }; } }; const configureLocation = (query, locationFilters) => { if (!locationFilters) return; const { sequelize } = (0, config_1.getCoreConfig)(); const { latitude, longitude, radius } = locationFilters; // Parse values into numbers const lat = parseFloat(latitude); const lon = parseFloat(longitude); const rad = parseFloat(radius); // Validate input const isValidLatitude = (lat) => lat >= -90 && lat <= 90; const isValidLongitude = (lon) => lon >= -180 && lon <= 180; const isValidRadius = (rad) => rad > 0; if (isNaN(lat) || isNaN(lon) || isNaN(rad) || !isValidLatitude(lat) || !isValidLongitude(lon) || !isValidRadius(rad)) { throw new Error("Invalid location filters: latitude must be between -90 and 90, longitude between -180 and 180, and radius must be greater than 0."); } // Add filter to query using PostgreSQL's ST_DWithin for geo-spatial queries query[sequelize_1.Op.and] = query[sequelize_1.Op.and] || []; query[sequelize_1.Op.and].push(sequelize.literal(` ST_DWithin( "Entity"."location"::geography, ST_MakePoint(${lon}, ${lat})::geography, ${rad} ) `)); // The code below might be used some how to sort by distance. If we implement it later, then we need to see how to make it play with the already existing sort param (hot, top, new etc). // Currently we just comment that out. We simply limit results to the allowed distance but don't sort them by distance // Add sorting by distance // query.order = sequelize.literal(` // ST_Distance( // "Entity"."location"::geography, // ST_MakePoint(${lon}, ${lat})::geography // ) // `); }; const configureMetadata = (query, metadataFilters) => { if (!metadataFilters) return; const { sequelize } = (0, config_1.getCoreConfig)(); if (typeof metadataFilters !== "object" || Array.isArray(metadataFilters) || metadataFilters === null) { throw new Error("Invalid metadata filter: must be a non-null JSON object"); } const { includes, includesAny, doesNotInclude, doesNotExist, exists } = metadataFilters; // Initialize a Sequelize `Op.and` array to build complex conditions const metadataConditions = []; // Helper: convert "true"/"false" strings into booleans, leave other values alone const serializeValues = (obj) => { Object.keys(obj).forEach((key) => { if (obj[key] === "true") obj[key] = true; else if (obj[key] === "false") obj[key] = false; }); return obj; }; // 1) AND-semantics for `includes` (all key/value pairs must match) if (typeof includes === "object" && Object.keys(includes).length) { const serialized = serializeValues({ ...includes }); metadataConditions.push(sequelize.literal(`"Entity"."metadata" @> '${JSON.stringify(serialized)}'`)); } // 2) OR-semantics for `includesAny` (at least one of these maps must match) if (Array.isArray(includesAny) && includesAny.length) { const orLiterals = includesAny.map((cond) => { const serialized = serializeValues({ ...cond }); return sequelize.literal(`"Entity"."metadata" @> '${JSON.stringify(serialized)}'`); }); metadataConditions.push({ [sequelize_1.Op.or]: orLiterals }); } // 3) doesNotInclude (none of these key/value pairs may match) if (typeof doesNotInclude === "object" && Object.keys(doesNotInclude).length) { const serialized = serializeValues({ ...doesNotInclude }); Object.entries(serialized).forEach(([key, value]) => { metadataConditions.push(sequelize.literal(`NOT ("Entity"."metadata" @> '{"${key}": ${JSON.stringify(value)}}')`)); }); } // 4a) exists: these keys must exist in the JSONB if (Array.isArray(doesNotExist)) { doesNotExist.forEach((key) => { metadataConditions.push(sequelize.literal(`NOT ("Entity"."metadata" ? '${key}')`)); }); } // 4b) doesNotExist: these keys must NOT exist if (Array.isArray(exists)) { exists.forEach((key) => { metadataConditions.push(sequelize.literal(`"Entity"."metadata" ? '${key}'`)); }); } // Add the combined conditions to filter only the `metadata` property of the Entity if (metadataConditions.length > 0) { query.metadata = { [sequelize_1.Op.and]: metadataConditions }; } }; const configureFollowedOnly = (query, followedOnly, loggedInUserId) => { const { sequelize } = (0, config_1.getCoreConfig)(); if (followedOnly === "true" && loggedInUserId) { query.userId = { [sequelize_1.Op.in]: sequelize.literal(`(SELECT "followedId" FROM "Follows" WHERE "followerId" = '${loggedInUserId}')`), }; } }; exports.default = async (req, res) => { try { const { page = 1, limit = 10, sortBy = "hot", timeFrame, sourceId, userId, // Filter posts by a specific account ID if provided followedOnly, keywordsFilters, metadataFilters, titleFilters, contentFilters, attachmentsFilters, locationFilters, } = req.query; const projectId = req.project.id; const loggedInUserId = req.userId; // User making the request (may be undefined) // Convert 'limit' and 'page' to numbers and validate them. let limitAsNumber = Number(limit); if (isNaN(limitAsNumber) || limitAsNumber <= 0) { res.status(400).json({ error: "Invalid request: limit must be a positive number.", code: "entity/invalid-limit", }); return; } limitAsNumber = Math.min(limitAsNumber, 100); // Maximum of 100 entities could be fetched a once const pageAsNumber = Number(page); if (isNaN(pageAsNumber) || pageAsNumber < 1 || pageAsNumber % 1 !== 0) { res.status(400).json({ error: "Invalid request: page must be a whole number greater than 0.", code: "entity/invalid-page", }); return; } if (pageAsNumber < 1 || pageAsNumber % 1 !== 0) { throw new Error("Invalid request: 'page' must be a whole number greater than 0"); } // Define the sort filter based on 'sort_by' query parameter. const sort = configureSort(sortBy); // Set up the query filters const query = { projectId }; configureTimeframe(query, timeFrame); configureSourceId(query, sourceId); configureUserId(query, userId); configureKeywords(query, keywordsFilters); configureMetadata(query, metadataFilters); configureTitle(query, titleFilters); configureContent(query, contentFilters); configureAttachments(query, attachmentsFilters); configureLocation(query, locationFilters); configureFollowedOnly(query, followedOnly, loggedInUserId); // Perform the query on the Entity model with pagination, sorting, and filtering const entities = (await models_1.Entity.findAll({ where: query, order: sort, limit: limitAsNumber, offset: (pageAsNumber - 1) * limitAsNumber, // Skip count for pagination ...sequelize_query_params_1.entityParams, })); res.status(200).json(entities.map((entity) => ({ ...entity.toJSON(), topComment: null, // ensure shape is preserved }))); // Schedule scoring updates asynchronously setImmediate(async () => { try { for (const entity of entities) { const entityJson = entity.toJSON(); const { newScore, newScoreUpdatedAt, updated } = (0, scoreEntity_1.default)(entityJson); if (updated) { entity.score = newScore; entity.scoreUpdatedAt = newScoreUpdatedAt; await entity.save(); } } } catch (updateErr) { console.error("Error updating entity scores asynchronously:", updateErr); } }); } catch (err) { console.error("Error fetching many entities:", err); res.status(500).json({ error: "Internal server error.", code: "entity/server-error", details: err.message, }); } };