Skip to content

Commit

Permalink
feat(searchbox) add new SearchBox
Browse files Browse the repository at this point in the history
Implement new search box, new API routes for searching and search pages.
  • Loading branch information
mthmcalixto committed May 16, 2024
1 parent 2655261 commit 8ddef9c
Show file tree
Hide file tree
Showing 20 changed files with 1,592 additions and 369 deletions.
4 changes: 1 addition & 3 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,11 @@ POSTGRES_PORT=54320
DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB
NEXT_PUBLIC_WEBSERVER_HOST=localhost
NEXT_PUBLIC_WEBSERVER_PORT=3000
NEXT_PUBLIC_SEARCH_ID=6022f30ef892943e1
NEXT_PUBLIC_SEARCH_URL=https://cse.google.com/cse.js?cx=
NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY=3x00000000000000000000FF
EMAIL_SMTP_HOST=localhost
EMAIL_SMTP_PORT=1025
EMAIL_HTTP_HOST=localhost
EMAIL_HTTP_PORT=1080
EMAIL_USER=
EMAIL_PASSWORD=
UNDER_MAINTENANCE={"methodsAndPaths":["POST /api/v1/under-maintenance-test$"]}
UNDER_MAINTENANCE={"methodsAndPaths":["POST /api/v1/under-maintenance-test$"]}
56 changes: 56 additions & 0 deletions infra/migrations/1715808549919_add-search-box.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
exports.up = async (pgm) => {
await pgm.createTable('searches', {
id: {
type: 'uuid',
primaryKey: true,
notNull: true,
default: pgm.func('gen_random_uuid()'),
},
search_term: {
type: 'varchar(255)',
notNull: true,
},
search_count: {
type: 'integer',
notNull: true,
default: 0,
},
created_at: {
type: 'timestamp with time zone',
notNull: true,
default: pgm.func("(now() at time zone 'utc')"),
},
updated_at: {
type: 'timestamp with time zone',
notNull: true,
default: pgm.func("(now() at time zone 'utc')"),
},
});

await pgm.createConstraint('searches', 'search_term_unique', {
unique: 'search_term',
});

await pgm.db.query(`
CREATE INDEX idx_search ON contents USING GIN(to_tsvector('portuguese', title));
`);

await pgm.db.query(`
ALTER TABLE contents
ADD COLUMN ts tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('portuguese', coalesce(title, '')), 'A') ||
setweight(to_tsvector('portuguese', coalesce(body, '')), 'B')
) STORED;
`);
};

exports.down = async (pgm) => {
await pgm.dropConstraint('searches', 'search_term_unique', { ifExists: true });

await pgm.dropTable('searches', { ifExists: true });

await pgm.dropIndex('contents', 'idx_search', { ifExists: true });

await pgm.dropColumn('contents', 'ts', { ifExists: true });
};
217 changes: 217 additions & 0 deletions models/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import database from 'infra/database';

function formatMilliseconds(milliseconds) {
const seconds = milliseconds / 1000;
const formattedSeconds = seconds.toFixed(3);
const plural = seconds !== 1 ? 's' : '';

return `${formattedSeconds} segundo${plural}`;
}

function cleanSearchTerm(searchTerm) {
const wordsAndNumbers = searchTerm.match(/[a-zA-Z]+|\d+/g);
const filteredWordsAndNumbers = wordsAndNumbers.map((item) => {
return item.replace(/[^a-zA-Z0-9]/g, '');
});
const separatedWords = filteredWordsAndNumbers
.join(' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.filter(Boolean);
return separatedWords.join(':* | ');
}

function generateTsQuery(cleanedSearchTerm) {
return `to_tsquery('portuguese', websearch_to_tsquery('portuguese','${cleanedSearchTerm}')::text || ':*')`;
}

function generateOrderByClause(sortBy, tsQuery) {
let orderByClause = '';
if (sortBy === 'date') {
orderByClause = 'ORDER BY created_at DESC';
} else if (sortBy === 'relevance') {
orderByClause = `ORDER BY ts_rank(ts, ${tsQuery}) DESC`;
} else if (sortBy === 'old') {
orderByClause = 'ORDER BY created_at ASC';
}
return orderByClause;
}

async function findAll({ searchTerm, sortBy = 'date', page = 1, perPage = 30 }) {
const separatedWords = cleanSearchTerm(searchTerm);

const tsQuery = generateTsQuery(separatedWords);

const offset = (page - 1) * perPage;

const totalCountQuery = {
text: `
SELECT COUNT(*)
FROM contents
WHERE to_tsvector('portuguese', title) @@ ${tsQuery}
AND status = 'published';
`,
};

const searchQuery = {
text: `
WITH content_window AS (
SELECT
COUNT(*) OVER()::INTEGER as total_rows,
id
FROM
contents
WHERE
to_tsvector('portuguese', contents.title) @@ ${tsQuery}
AND contents.status = 'published'
${generateOrderByClause(sortBy, tsQuery)}
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,
contents.path,
users.username as owner_username,
tabcoins_count.total_balance as tabcoins,
tabcoins_count.total_credit as tabcoins_credit,
tabcoins_count.total_debit as tabcoins_debit,
(
SELECT COUNT(*)
FROM contents as children
WHERE children.path @> ARRAY[contents.id]
AND children.status = 'published'
) 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
LEFT JOIN LATERAL get_content_balance_credit_debit(contents.id) tabcoins_count ON true
WHERE
contents.status = 'published'
AND
to_tsvector('portuguese', contents.title) @@ ${tsQuery}
${generateOrderByClause(sortBy, tsQuery)}
`,
values: [perPage, offset],
};

const startTime = performance.now();

const searchResults = await database.query(searchQuery);

if (searchResults.rows.length > 0) {
const updateTrendsQuery = {
text: `
INSERT INTO searches (search_term, search_count, updated_at)
VALUES ($1, 1, CURRENT_TIMESTAMP)
ON CONFLICT (search_term)
DO UPDATE SET search_count = searches.search_count + 1, updated_at = CURRENT_TIMESTAMP
WHERE searches.search_term = $1 AND (CURRENT_TIMESTAMP - searches.updated_at) > INTERVAL '3 minutes';
`,
values: [searchTerm],
};

await database.query(updateTrendsQuery);

const totalCountResult = await database.query(totalCountQuery);
const totalResults = parseInt(totalCountResult.rows[0].count);

const firstPage = 1;
const lastPage = Math.ceil(totalResults / perPage);
const nextPage = page >= lastPage ? null : parseInt(page) + 1;
const previousPage = page <= 1 ? null : page - 1;

const pagination = {
currentPage: parseInt(page),
totalRows: totalResults,
perPage: parseInt(perPage),
firstPage: firstPage,
nextPage: nextPage,
previousPage: previousPage,
lastPage: lastPage,
};

const endTime = performance.now();
const searchRuntime = endTime - startTime;

return {
searchRuntime: formatMilliseconds(searchRuntime),
pagination,
results: searchResults.rows,
};
}
}

async function findAllTrends() {
const startTime = performance.now();

const query = {
text: `
SELECT *
FROM searches
WHERE updated_at >= CURRENT_DATE - INTERVAL '7 days'
AND search_count >= 20
ORDER BY search_count DESC
LIMIT 10;
`,
};

const res = await database.query(query);
const endTime = performance.now();
const searchRuntime = endTime - startTime;

const results = res.rows;

return {
searchRuntime: formatMilliseconds(searchRuntime),
results,
};
}

async function findAllSuggestions(partialTerm) {
const startTime = performance.now();

const separatedWords = cleanSearchTerm(partialTerm);

const tsQuery = generateTsQuery(separatedWords);

const query = {
text: `
SELECT
*
FROM searches s
WHERE to_tsvector('portuguese', s.search_term) @@ ${tsQuery}
ORDER BY s.search_count DESC NULLS LAST
LIMIT 10
`,
};

const res = await database.query(query);
const endTime = performance.now();
const searchRuntime = endTime - startTime;

const results = res.rows;

return {
searchRuntime: formatMilliseconds(searchRuntime),
results,
};
}

export default Object.freeze({
findAll,
findAllTrends,
findAllSuggestions,
});
32 changes: 32 additions & 0 deletions models/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,38 @@ const schemas = {
.when('$required.ban_type', { is: 'required', then: Joi.required(), otherwise: Joi.optional() }),
});
},

q: function () {
return Joi.object({
q: Joi.string()
.when('$required.query', {
is: 'required',
then: Joi.string().required(),
otherwise: Joi.optional(),
})
.trim()
.min(3)
.max(255)
.replace(
/^(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0)+|(\s|\p{C}|\u2800|\u034f|\u115f|\u1160|\u17b4|\u17b5|\u3164|\uffa0)+$|\u0000/gu,
'',
)
.messages({
'string.pattern.base': 'O campo query deve conter apenas letras, números e espaços.',
}),
});
},
sort: function () {
return Joi.object({
sort: Joi.string()
.when('$required.sort', {
is: 'required',
then: Joi.string().required(),
otherwise: Joi.optional(),
})
.trim(),
});
},
};

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

import { NotFoundError } from 'errors';
import authentication from 'models/authentication';
import authorization from 'models/authorization';
import cacheControl from 'models/cache-control';
import controller from 'models/controller';
import search from 'models/search';
import user from 'models/user';
import validator from 'models/validator';

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

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

request.query = cleanValues;

next();
}

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

const { q, page, per_page, sort } = request.query;

const contentFound = await search.findAll(q, page, per_page, sort);

if (!contentFound) {
throw new NotFoundError({
message: `A busca não foi encontrado no sistema.`,
action: 'Verifique se o "query" está digitado corretamente.',
stack: new Error().stack,
errorLocationCode: 'CONTROLLER:CONTENT:GET_HANDLER:SEARCH_NOT_FOUND',
key: 'q',
});
}

const secureOutputValues = authorization.filterOutput(userTryingToGet, 'read:content', contentFound);

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

0 comments on commit 8ddef9c

Please sign in to comment.