Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions src/controllers/search.controller.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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,
Expand All @@ -51,6 +68,7 @@ export async function getSearchResults(request: FastifyRequest<{ Querystring: Se
uid,
pageSize,
pageInput,
authorIds,
})

if (serviceResponse.error || !serviceResponse.success) {
Expand Down Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion src/routes/search.route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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',
{
Expand Down
11 changes: 11 additions & 0 deletions src/schema/search/request.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions src/schema/search/response.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()) }),
Expand Down
139 changes: 138 additions & 1 deletion src/services/search.svc.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { QueryTypes, type Sequelize } from 'sequelize'

import type {
SearchAuthorRow,
SearchCounterQuery,
SearchCounterResponse,
SearchCountRow,
SearchFrequencyRow,
SearchHouseCountRow,
SearchMPDocResultsResponse,
SearchPlotResponse,
SearchQuery,
Expand All @@ -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?: {
Expand Down Expand Up @@ -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 = []
Expand All @@ -59,6 +77,30 @@ export class SearchService {
return ranges
}

public async findMatchingMPsByName(sequelize: Sequelize, mpName: string): Promise<Array<{ author_id: number; name: string }>> {
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<SearchAuthorRow>(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,
Expand All @@ -69,6 +111,7 @@ export class SearchService {
windowSize: number
q: string
uid?: number
authorIds?: number[]
pageSize: number
pageInput: number
},
Expand Down Expand Up @@ -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'
Expand All @@ -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)
}

Expand Down Expand Up @@ -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<SearchCounterServiceResponse> {
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<SearchHouseCountRow>(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,
Expand Down
3 changes: 2 additions & 1 deletion src/types/schema-derived/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof attendanceQuerySchema>
export type SearchQuery = z.infer<typeof searchQuerySchema>
export type SearchCounterQuery = z.infer<typeof searchCounterQuerySchema>
export type SearchPlotQuery = z.infer<typeof searchPlotQuerySchema>
export type AutocompleteQuery = z.infer<typeof autocompleteQuerySchema>
export type GetSittingQuery = z.infer<typeof getSittingQuerySchema>
Expand Down
8 changes: 7 additions & 1 deletion src/types/schema-derived/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof attendanceResponseSchema>
export type SearchResultsResponse = z.infer<typeof searchResultsResponseSchema>
export type SearchMPDocResultsResponse = z.infer<typeof searchMPDocResultsResponseSchema>
export type SearchCounterResponse = z.infer<typeof searchCounterResponseSchema>
export type SearchPlotResponse = z.infer<typeof searchPlotResponseSchema>
export type GetSittingResponse = z.infer<typeof getSittingResponseSchema>
export type UpsertSittingResponse = z.infer<typeof upsertSittingResponseSchema>
Expand Down
Loading
Loading