@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
JavaScript
;
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,
});
}
};