Skip to content

Commit

Permalink
feat(search): add endpoint for content search
Browse files Browse the repository at this point in the history
adds an endpoint for searching content by terms or keywords using Full-Text Search

filipedeschamps#927
  • Loading branch information
victorhcb committed May 9, 2023
1 parent 546d384 commit 4f6aa1b
Show file tree
Hide file tree
Showing 3 changed files with 219 additions and 0 deletions.
144 changes: 144 additions & 0 deletions models/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import database from 'infra/database';
import validator from 'models/validator.js';

async function doSearch(values) {
values = validateValues(values);

const scopes = {
contents: searchContent,
};

const scopeSearch = scopes[values.search_scope];
return await scopeSearch(values);

function validateValues(values) {
const cleanValues = validator(values, {
page: 'optional',
per_page: 'optional',
search_scope: 'required',
search_term: 'required',
});

return cleanValues;
}
}

async function searchContent(inputValues) {
const results = {};
let query = {};

inputValues.page = inputValues.page || 1;
const offset = (inputValues.page - 1) * inputValues.per_page;

query.values = [
inputValues.per_page || 30,
offset,
null,
'published',
getSearchWordsFromSearchTerm(inputValues.search_term),
];

query.text = `WITH content_window AS (
SELECT
COUNT(*) OVER()::INTEGER as total_rows,
id
FROM contents
WHERE contents.parent_id IS NOT DISTINCT FROM $3 AND contents.status = $4 AND (to_tsvector(title) @@ to_tsquery($5) OR to_tsvector(body) @@ to_tsquery($5))
ORDER BY ts_rank(to_tsvector(contents.body),to_tsquery($5)) DESC NULLS LAST
LIMIT $1 OFFSET $2
)
SELECT
contents.id,
contents.owner_id,
contents.parent_id,
contents.slug,
contents.title,
contents.status,
contents.source_url,
contents.created_at,
contents.updated_at,
contents.published_at,
contents.deleted_at,
users.username as owner_username,
content_window.total_rows,
get_current_balance('content:tabcoin', contents.id) as tabcoins,
(
WITH RECURSIVE children AS (
SELECT
id,
parent_id
FROM
contents as all_contents
WHERE
all_contents.parent_id = contents.id AND
all_contents.status = 'published'
UNION ALL
SELECT
all_contents.id,
all_contents.parent_id
FROM
contents as all_contents
INNER JOIN
children ON all_contents.parent_id = children.id
WHERE
all_contents.status = 'published'
)
SELECT
count(children.id)::integer
FROM
children
) as children_deep_count
FROM
contents
INNER JOIN
content_window ON contents.id = content_window.id
INNER JOIN
users ON contents.owner_id = users.id
ORDER BY ts_rank(to_tsvector(contents.body), to_tsquery($5)) DESC NULLS LAST;
`;

const queryResult = await database.query(query);

results.rows = queryResult.rows;
results.pagination = getPagination(queryResult.rows);

return results;

function getSearchWordsFromSearchTerm(searchTerm) {
return searchTerm
.split(' ')
.reduce((accumulator, word) => {
const ignoredWords = ['que', 'com'];

if (word.length > 2 && !ignoredWords.includes(word)) {
accumulator.push(word);
}

return accumulator;
}, [])
.join(' | ');
}

function getPagination(rows) {
const totalRows = rows.length;
const perPage = inputValues.per_page;
const firstPage = 1;
const lastPage = Math.ceil(totalRows / inputValues.per_page);
const nextPage = inputValues.page >= lastPage ? null : inputValues.page + 1;
const previousPage = inputValues.page <= 1 ? null : inputValues.page > lastPage ? lastPage : inputValues.page - 1;

return {
currentPage: inputValues.page,
totalRows: totalRows,
perPage: perPage,
firstPage: firstPage,
nextPage: nextPage,
previousPage: previousPage,
lastPage: lastPage,
};
}
}

export default Object.freeze({
doSearch,
});
31 changes: 31 additions & 0 deletions models/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,37 @@ const schemas = {
}),
});
},

search_term: function () {
return Joi.object({
search_term: Joi.string()
.allow(null, '')
.trim()
.when('$required.search_term', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
.messages({
'any.required': `"search_term" é um campo obrigatório.`,
'string.empty': `"search_term" não pode estar em branco.`,
'string.base': `"search_term" deve ser do tipo String.`,
}),
});
},

search_scope: function () {
return Joi.object({
search_scope: Joi.string()
.trim()
.valid('contents')
.invalid(null)
.when('$required.search_scope', { is: 'required', then: Joi.required(), otherwise: Joi.optional() })
.messages({
'any.required': `"search_scope" é um campo obrigatório.`,
'string.empty': `"search_scope" não pode estar em branco.`,
'string.base': `"search_scope" deve ser do tipo String.`,
'any.invalid': `"search_scope" possui o valor inválido "null".`,
'any.only': `"search_scope" deve possuir um dos seguintes valores: "contents".`,
}),
});
},
};

const withoutMarkdown = (value, helpers) => {
Expand Down
44 changes: 44 additions & 0 deletions pages/api/v1/search/index.public.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import nextConnect from 'next-connect';
import controller from 'models/controller.js';
import user from 'models/user.js';
import search from 'models/search.js';
import validator from 'models/validator.js';
import authorization from 'models/authorization.js';

export default nextConnect({
attachParams: true,
onNoMatch: controller.onNoMatchHandler,
onError: controller.onErrorHandler,
})
.use(controller.injectRequestMetadata)
.use(controller.logRequest)
.get(getValidationHandler, getHandler);

function getValidationHandler(request, response, next) {
const cleanValues = validator(request.query, {
page: 'optional',
per_page: 'optional',
search_term: 'required',
search_scope: 'required',
});

request.query = cleanValues;

next();
}

async function getHandler(request, response) {
const userTryingToList = user.createAnonymous();

const results = await search.doSearch(request.query);

const contentList = results.rows;

const secureOutputValues = authorization.filterOutput(userTryingToList, 'read:content:list', contentList);

controller.injectPaginationHeaders(results.pagination, '/api/v1/search', response);

response.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate');

return response.status(200).json(secureOutputValues);
}

0 comments on commit 4f6aa1b

Please sign in to comment.