diff --git a/src/controllers/search.controller.ts b/src/controllers/search.controller.ts index 77460d6..a9af28a 100644 --- a/src/controllers/search.controller.ts +++ b/src/controllers/search.controller.ts @@ -1,7 +1,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { SearchService } from '@/services/search.svc' -import type { SearchMPDocResultsResponse, SearchQuery, SearchResultsResponse } from '@/types' +import type { SearchCounterQuery, SearchCounterResponse, SearchMPDocResultsResponse, SearchQuery, SearchResultsResponse } from '@/types' import { type House, HOUSE_CODE, HOUSE_TO_CODE } from '@/types/enum' import { deriveDefaultStartDateDR } from '@/utils' @@ -36,12 +36,29 @@ export async function getSearchResults(request: FastifyRequest<{ Querystring: Se const startDate = await startDatePromise const endDate = request.query.end_date ?? new Date().toISOString().slice(0, 10) const windowSize = Number(request.query.window_size ?? 120) - const q = (request.query.q ?? '').toString().trim().toLowerCase() + const rawQ = (request.query.q ?? '').toString().trim() const uid = request.query.uid ? Number(request.query.uid) : undefined const pageSize = Number(request.query.page_size ?? DEFAULT_PAGE_SIZE) const pageInput = Math.max(1, Number(request.query.page ?? 1)) - const searchSvc = new SearchService() + let q = rawQ + let authorIds: number[] | undefined + + const separatorIndex = rawQ.indexOf(':') + if (separatorIndex > -1) { + const keywordQuery = rawQ.slice(0, separatorIndex).trim() + const mpNameQuery = rawQ.slice(separatorIndex + 1).trim() + + if (keywordQuery && mpNameQuery) { + const matchedMPs = await searchSvc.findMatchingMPsByName(sequelize, mpNameQuery) + + if (matchedMPs.length > 0) { + q = keywordQuery + authorIds = matchedMPs.map(mp => mp.author_id) + } + } + } + const serviceResponse = await searchSvc.search(sequelize, request.query, { startDate, endDate, @@ -51,6 +68,7 @@ export async function getSearchResults(request: FastifyRequest<{ Querystring: Se uid, pageSize, pageInput, + authorIds, }) if (serviceResponse.error || !serviceResponse.success) { @@ -118,6 +136,39 @@ export async function getSearchMPDocResults(request: FastifyRequest<{ Querystrin } } +export async function getSearchCounter(request: FastifyRequest<{ Querystring: SearchCounterQuery }>, reply: FastifyReply) { + try { + const { sequelize } = request.server + const startDate = request.query.start_date ?? (await deriveDefaultStartDateDR(request.server.models)) + const endDate = request.query.end_date ?? new Date().toISOString().slice(0, 10) + const q = (request.query.q ?? '').toString().trim() + const uid = request.query.uid ? Number(request.query.uid) : undefined + + const searchSvc = new SearchService() + const serviceResponse = await searchSvc.searchCounter(sequelize, request.query, { + startDate, + endDate, + q, + uid, + }) + + if (serviceResponse.error || !serviceResponse.success) { + const { code, type, message } = serviceResponse.error ?? { + code: 500, + type: 'text/plain', + message: 'Internal Server Error', + } + return reply.code(code).type(type).send(message) + } + + const response: SearchCounterResponse = serviceResponse.success + return reply.send(response) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Bad Request' + return reply.code(400).send({ error: message }) + } +} + export async function getSearchPlot(request: FastifyRequest<{ Querystring: SearchQuery }>, reply: FastifyReply) { try { const { sequelize } = request.server diff --git a/src/routes/search.route.ts b/src/routes/search.route.ts index ced12da..845b61f 100644 --- a/src/routes/search.route.ts +++ b/src/routes/search.route.ts @@ -1,7 +1,9 @@ import type { FastifyInstance } from 'fastify' -import { getSearchMPDocResults, getSearchPlot, getSearchResults } from '@/controllers/search.controller' +import { getSearchCounter, getSearchMPDocResults, getSearchPlot, getSearchResults } from '@/controllers/search.controller' import { + searchCounterQuerySchema, + searchCounterResponseSchema, searchMPDocResultsResponseSchema, searchPlotQuerySchema, searchPlotResponseSchema, @@ -23,6 +25,19 @@ export async function registerSearchRoutes(app: FastifyInstance) { getSearchResults, ) + app.get( + '/search/counter', + { + schema: { + tags: ['Search'], + summary: 'Keyword result counter by house', + querystring: searchCounterQuerySchema, + response: { 200: searchCounterResponseSchema }, + }, + }, + getSearchCounter, + ) + app.get( '/search-mp-doc', { diff --git a/src/schema/search/request.schema.ts b/src/schema/search/request.schema.ts index 80416e5..8ca1d20 100644 --- a/src/schema/search/request.schema.ts +++ b/src/schema/search/request.schema.ts @@ -17,4 +17,15 @@ export const searchQuerySchema = z.object({ page_size: z.coerce.number().optional(), }) +export const searchCounterQuerySchema = z.object({ + start_date: z.string().optional(), + end_date: z.string().optional(), + party: z.string().optional(), + age_group: z.string().optional(), + sex: z.enum(['m', 'f']).optional(), + ethnicity: z.string().optional(), + q: z.string().optional(), + uid: z.coerce.number().optional(), +}) + export const searchPlotQuerySchema = searchQuerySchema diff --git a/src/schema/search/response.schema.ts b/src/schema/search/response.schema.ts index 00d1865..020f78c 100644 --- a/src/schema/search/response.schema.ts +++ b/src/schema/search/response.schema.ts @@ -40,6 +40,14 @@ export const searchMPDocResultsResponseSchema = z.object({ previous: z.number().nullable(), }) +export const searchCounterResponseSchema = z.object({ + house_counts: z.object({ + dewan_rakyat: z.number(), + dewan_negara: z.number(), + kamar_khas: z.number(), + }), +}) + // Raw payload for GET /search-plot export const searchPlotResponseSchema = z.object({ chart_data: z.object({ date: z.array(z.string()), freq: z.array(z.number()) }), diff --git a/src/services/search.svc.ts b/src/services/search.svc.ts index cb5a07c..029acd1 100644 --- a/src/services/search.svc.ts +++ b/src/services/search.svc.ts @@ -1,8 +1,12 @@ import { QueryTypes, type Sequelize } from 'sequelize' import type { + SearchAuthorRow, + SearchCounterQuery, + SearchCounterResponse, SearchCountRow, SearchFrequencyRow, + SearchHouseCountRow, SearchMPDocResultsResponse, SearchPlotResponse, SearchQuery, @@ -12,8 +16,13 @@ import type { SearchTopSpeakerRow, SqlBindings, } from '@/types' +import { HOUSE_CODE } from '@/types/enum' import { buildHeadlineFragment, paginate, resampleSeries, translateAgeGroupToBirthYearBounds } from '@/utils' +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, '\\$&') +} + export interface SearchServiceResponse { success?: SearchResultsResponse error?: { @@ -41,6 +50,15 @@ export interface SearchServicePlotResponse { } } +export interface SearchCounterServiceResponse { + success?: SearchCounterResponse + error?: { + code: number + type: string + message: string + } +} + export class SearchService { private async generateYearRange(startDate: Date, endDate: Date, yearsBatchSize = 5) { const ranges = [] @@ -59,6 +77,30 @@ export class SearchService { return ranges } + public async findMatchingMPsByName(sequelize: Sequelize, mpName: string): Promise> { + const normalizedName = mpName.trim().toLowerCase() + if (!normalizedName) return [] + + const sql = ` + SELECT a.new_author_id as author_id, a.name + FROM api_author a + WHERE LOWER(a.name) LIKE :namePattern ESCAPE '\\' + ORDER BY a.new_author_id ASC + ` + const rows = await sequelize.query(sql, { + replacements: { namePattern: `%${escapeLikePattern(normalizedName)}%` }, + type: QueryTypes.SELECT, + }) + + return rows + .map(row => { + const authorId = Number(row.author_id) + if (!Number.isFinite(authorId)) return null + return { author_id: authorId, name: row.name ?? '' } + }) + .filter((row): row is { author_id: number; name: string } => row != null) + } + public async search( sequelize: Sequelize, query: SearchQuery, @@ -69,6 +111,7 @@ export class SearchService { windowSize: number q: string uid?: number + authorIds?: number[] pageSize: number pageInput: number }, @@ -112,6 +155,11 @@ export class SearchService { repl.uid = parameters.uid } + if (parameters.authorIds && parameters.authorIds.length > 0) { + whereParts.push('a.new_author_id IN (:authorIds)') + repl.authorIds = parameters.authorIds + } + let selectHeadline = '' let selectRank = '' let orderBy = 'si.date DESC' @@ -120,7 +168,7 @@ export class SearchService { selectHeadline = headlineFragment.select selectRank = headlineFragment.rankSelect orderBy = headlineFragment.order - repl.q = parameters.q + Object.assign(repl, headlineFragment.params) whereParts.push(headlineFragment.condition) } @@ -185,6 +233,95 @@ export class SearchService { return response } + public async searchCounter( + sequelize: Sequelize, + query: SearchCounterQuery, + parameters: { + startDate: string + endDate: string + q: string + uid?: number + }, + ): Promise { + const whereParts: string[] = ['pc.house IN (:houses)', 'si.date >= :startDate', 'si.date <= :endDate'] + const repl: SqlBindings = { + houses: [HOUSE_CODE.DEWAN_RAKYAT, HOUSE_CODE.DEWAN_NEGARA, HOUSE_CODE.KAMAR_KHAS], + startDate: parameters.startDate, + endDate: parameters.endDate, + } + + if (query.party) { + whereParts.push('ah.party = :party') + repl.party = query.party + } + + if (query.sex) { + whereParts.push('a.sex = :sex') + repl.sex = query.sex + } + + if (query.ethnicity) { + whereParts.push('a.ethnicity = :ethnicity') + repl.ethnicity = query.ethnicity + } + + if (query.age_group) { + const grp = query.age_group + const currentYear = new Date().getFullYear() + const trans = translateAgeGroupToBirthYearBounds(grp, currentYear) + if (trans) { + whereParts.push(trans.clause) + Object.assign(repl, trans.params) + } + } + + if (parameters.uid) { + whereParts.push('a.new_author_id = :uid') + repl.uid = parameters.uid + } + + const headlineFragment = parameters.q ? buildHeadlineFragment(parameters.q, 120) : null + if (headlineFragment) { + Object.assign(repl, headlineFragment.params) + whereParts.push(headlineFragment.condition) + } + + const baseFrom = ` + FROM api_speech s + JOIN api_sitting si ON s.sitting_id = si.sitting_id + JOIN api_parliamentary_cycle pc ON si.cycle_id = pc.cycle_id + LEFT JOIN api_author_history ah ON s.speaker_id = ah.record_id + LEFT JOIN api_author a ON ah.author_id = a.new_author_id + ` + const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '' + const countSql = ` + SELECT pc.house as house, count(*) as count + ${baseFrom} + ${whereSql} + GROUP BY pc.house + ` + + const countRows = await sequelize.query(countSql, { + replacements: repl, + type: QueryTypes.SELECT, + }) + + const houseCounts: SearchCounterResponse['house_counts'] = { + dewan_rakyat: 0, + dewan_negara: 0, + kamar_khas: 0, + } + for (const row of countRows) { + const house = Number(row.house) + const count = Number(row.count ?? 0) + if (house === HOUSE_CODE.DEWAN_RAKYAT) houseCounts.dewan_rakyat = count + if (house === HOUSE_CODE.DEWAN_NEGARA) houseCounts.dewan_negara = count + if (house === HOUSE_CODE.KAMAR_KHAS) houseCounts.kamar_khas = count + } + + return { success: { house_counts: houseCounts } } + } + public async searchMPDoc( sequelize: Sequelize, query: SearchQuery, diff --git a/src/types/schema-derived/requests.ts b/src/types/schema-derived/requests.ts index dda1577..535c9d0 100644 --- a/src/types/schema-derived/requests.ts +++ b/src/types/schema-derived/requests.ts @@ -3,12 +3,13 @@ import { z } from 'zod' import { attendanceQuerySchema } from '@/schema/attendance/request.schema' import { autocompleteQuerySchema } from '@/schema/autocomplete/request.schema' import { catalogueQuerySchema, createCycleBodySchema } from '@/schema/parliamentary/request.schema' -import { searchPlotQuerySchema, searchQuerySchema } from '@/schema/search/request.schema' +import { searchCounterQuerySchema, searchPlotQuerySchema, searchQuerySchema } from '@/schema/search/request.schema' import { getSittingListQuerySchema, getSittingQuerySchema, upsertSittingBodySchema } from '@/schema/sitting/request.schema' import { speechBulkBodySchema } from '@/schema/speech/request.schema' export type AttendanceQuery = z.infer export type SearchQuery = z.infer +export type SearchCounterQuery = z.infer export type SearchPlotQuery = z.infer export type AutocompleteQuery = z.infer export type GetSittingQuery = z.infer diff --git a/src/types/schema-derived/responses.ts b/src/types/schema-derived/responses.ts index e7c73e2..d55b0e3 100644 --- a/src/types/schema-derived/responses.ts +++ b/src/types/schema-derived/responses.ts @@ -3,12 +3,18 @@ import { z } from 'zod' import { attendanceResponseSchema } from '@/schema/attendance/response.schema' import { autocompleteResponseSchema } from '@/schema/autocomplete/response.schema' import { getCatalogueResponseSchema } from '@/schema/catalogue/response.schema' -import { searchMPDocResultsResponseSchema, searchPlotResponseSchema, searchResultsResponseSchema } from '@/schema/search/response.schema' +import { + searchCounterResponseSchema, + searchMPDocResultsResponseSchema, + searchPlotResponseSchema, + searchResultsResponseSchema, +} from '@/schema/search/response.schema' import { getSittingResponseSchema, upsertSittingResponseSchema } from '@/schema/sitting/response.schema' export type AttendanceResponse = z.infer export type SearchResultsResponse = z.infer export type SearchMPDocResultsResponse = z.infer +export type SearchCounterResponse = z.infer export type SearchPlotResponse = z.infer export type GetSittingResponse = z.infer export type UpsertSittingResponse = z.infer diff --git a/src/types/sql-rows.ts b/src/types/sql-rows.ts index cb19376..cb877fa 100644 --- a/src/types/sql-rows.ts +++ b/src/types/sql-rows.ts @@ -21,6 +21,16 @@ export interface SearchCountRow { count: number | string | null } +export interface SearchAuthorRow { + author_id: number | string | null + name: string | null +} + +export interface SearchHouseCountRow { + house: number | string | null + count: number | string | null +} + export interface SearchSpeechRow { index: number | string speaker_name: string | null diff --git a/src/utils/query/fts.util.ts b/src/utils/query/fts.util.ts index 29f3029..9db9a71 100644 --- a/src/utils/query/fts.util.ts +++ b/src/utils/query/fts.util.ts @@ -1,17 +1,40 @@ import type { SqlBindings } from '@/types' +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, '\\$&') +} + export function buildHeadlineFragment( q: string, windowSize: number, ): { select: string; rankSelect: string; order: string; params: SqlBindings; condition: string } | null { - const trimmed = q.trim().toLowerCase() + const trimmed = q.trim() if (!trimmed) return null const adjustedWindow = Math.max(10, windowSize - 10) + + const isQuoted = trimmed.startsWith('"') && trimmed.endsWith('"') + if (isQuoted) { + const literal = trimmed.slice(1, -1).trim().toLowerCase() + if (!literal) return null + + return { + select: `, ts_headline('english', s.speech, plainto_tsquery('english', :q), 'StartSel===, StopSel===, MinWords=${adjustedWindow}, MaxWords=${windowSize}') as headline`, + rankSelect: ", ts_rank(s.speech_vector, plainto_tsquery('english', :q)) as rank", + order: 'si.date DESC, rank DESC', + params: { + q: literal, + qLiteral: `%${escapeLikePattern(literal)}%`, + }, + condition: "LOWER(s.speech) LIKE :qLiteral ESCAPE '\\'", + } + } + + const normalized = trimmed.toLowerCase() return { select: `, ts_headline('english', s.speech, plainto_tsquery('english', :q), 'StartSel===, StopSel===, MinWords=${adjustedWindow}, MaxWords=${windowSize}') as headline`, rankSelect: ", ts_rank(s.speech_vector, plainto_tsquery('english', :q)) as rank", order: 'si.date DESC, rank DESC', - params: { q: trimmed }, + params: { q: normalized }, condition: "s.speech_vector @@ plainto_tsquery('english', :q)", } }