diff --git a/src/db/repo/search.repo.ts b/src/db/repo/search.repo.ts index a6e0a840757c3ed292f589ec4c278703d18967b6..4e19cdd38a322bfac178e63b0a7b9efb10795943 100644 --- a/src/db/repo/search.repo.ts +++ b/src/db/repo/search.repo.ts @@ -5,6 +5,7 @@ import { resourceIndexSchema, type ResourceIndex } from "../schema/search.schema import { userIndexSchema, type UserIndex } from "../schema/search.schema"; import { collectionIndexSchema, type CollectionIndex } from "../schema/search.schema"; + @Service() export class SearchRepo { async findResourcesForIndexing(id: number): Promise<ResourceIndex[]> { @@ -21,7 +22,7 @@ export class SearchRepo { subjects: sql`string_agg(distinct sj.name, ',')`, license: sql`lc.name`, object_type: sql`ot.name`, - state: sql`re.state`, + resource_state: sql`re.resource_state`, user: sql`us.name`, user_id: sql`re.user_id`, views: sql`rest.views`, @@ -32,12 +33,12 @@ export class SearchRepo { comments: sql`rest.comments`, }) .from(sql`resources re`) - .leftJoin(sql`resource_educational_stages resed`, sql`resed.resource_id = re.id`) - .leftJoin(sql`educational_stages es`, sql`resed.educational_stage_id = es.id`) + .leftJoin(sql`resource_educational_stages res`, sql`res.resource_id = re.id`) + .leftJoin(sql`educational_stages es`, sql`res.educational_stage_id = es.id`) .leftJoin(sql`resource_languages rel`, sql`rel.resource_id = re.id`) .leftJoin(sql`languages lg`, sql`rel.language_id = lg.id`) - .leftJoin(sql`resource_subjects res`, sql`res.resource_id = re.id`) - .leftJoin(sql`subjects sj`, sql`res.subject_id = sj.id`) + .leftJoin(sql`resource_subjects rs`, sql`rs.resource_id = re.id`) + .leftJoin(sql`subjects sj`, sql`rs.subject_id = sj.id`) .leftJoin(sql`licenses lc`, sql`re.license_id = lc.id`) .leftJoin(sql`object_types ot`, sql`re.object_type_id = ot.id`) .leftJoin(sql`users us`, sql`us.id = re.user_id`) @@ -63,12 +64,25 @@ export class SearchRepo { sql`rest.comments` ); - let results = await query.execute(); + let results = await query.execute(); + + // O driver do postgres (pg) usado no drizzle retorna Numeric como uma string por padrão + // para evitar problemas com precisão numérica. Por isso precisamos parsear o score aqui + results = results.map(result => ({ ...result, score: Number(result.score) })); + // return results.map((result) => resourceIndexSchema.parse(result)); + // // Parse and validate each result using Zod schema + // return results.map((result) => resourceIndexSchema.parse(result)); - // O driver do postgres (pg) usado no drizzle retorna Numeric como uma string por padrão - // para evitar problemas com precisão numérica. Por isso precisamos parsear o score aqui - results = results.map(result => ({ ...result, score: Number(result.score) })); - return results.map((result) => resourceIndexSchema.parse(result)); + // converte strings separadas por vírgula em arrays + return results.map((result) => + resourceIndexSchema.parse({ + ...result, + author: (result.author as string | null)?.split(',').map((i) => i.trim()) ?? [], + educational_stages: (result.educational_stages as string | null)?.split(',').map((r) => r.trim()) ?? [], + languages: (result.languages as string | null)?.split(',').map((i) => i.trim()) ?? [], + subjects: (result.subjects as string | null)?.split(',').map((r) => r.trim()) ?? [], + }) + ); } async findUsersForIndexing(id: number): Promise<UserIndex[]> { @@ -76,7 +90,7 @@ export class SearchRepo { .select({ id: sql`users.id`, name: sql`users.name`, - username: sql`users.username`, + user: sql`users.username`, description: sql`users.description`, roles: sql`string_agg(distinct roles.name, ',')`, institutions: sql`string_agg(distinct inst.name, ',')`, @@ -110,8 +124,14 @@ export class SearchRepo { const results = await query.execute(); - // Parse and validate each result using Zod schema - return results.map((result) => userIndexSchema.parse(result)); + // converte strings separadas por vírgula em arrays + return results.map((result) => + userIndexSchema.parse({ + ...result, + roles: (result.roles as string | null)?.split(',').map((r) => r.trim()) ?? [], + institutions: (result.institutions as string | null)?.split(',').map((i) => i.trim()) ?? [], + }) + ); } // TODO fix the collections query @@ -120,7 +140,7 @@ export class SearchRepo { .select({ id: sql`col.id`, name: sql`col.name`, - author: sql`users.name`, + user: sql`users.name`, description: sql`col.description`, created_at: sql`col.created_at`, score: sql`colst.score`, diff --git a/src/db/schema/search-filters.schema.ts b/src/db/schema/search-filters.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..36c6e867343b687d8e61792382aa3e377c99a495 --- /dev/null +++ b/src/db/schema/search-filters.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const exactFilterSchema = z.object({ + language: z.string().optional(), + object_type: z.string().optional(), + institution: z.string().optional(), + resource_state: z.coerce.number().optional(), + is_active: z.coerce.boolean().optional(), + is_private: z.coerce.boolean().optional(), +}); + +export type ExactFilterFields = z.infer<typeof exactFilterSchema>; \ No newline at end of file diff --git a/src/db/schema/search-sortable.schema.ts b/src/db/schema/search-sortable.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..3544cb422bd5735f4c02590bce46a3dd66ee555a --- /dev/null +++ b/src/db/schema/search-sortable.schema.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const sortableFields = [ + 'created_at', + 'views', + 'downloads', + 'likes', + 'shares', + 'score', + 'comments', + 'followers', + 'approved_resources' +] as const; + +export const sortBySchema = z.enum(sortableFields); + +export type SortableField = typeof sortableFields[number]; \ No newline at end of file diff --git a/src/db/schema/search.schema.ts b/src/db/schema/search.schema.ts index f969933bf68d2ae1c1278295d01ab10dd97d473f..79b34598df77c5996425b79a0c02fc3cd5623157 100644 --- a/src/db/schema/search.schema.ts +++ b/src/db/schema/search.schema.ts @@ -3,16 +3,17 @@ import { z } from "zod"; export const resourceIndexSchema = z.object({ id: z.number(), name: z.string(), - author: z.string().nullable(), + author: z.array(z.string()), description: z.string().nullable(), link: z.string().nullable(), - created_at: z.string().optional().nullable(), - educational_stages: z.string().nullable(), - languages: z.string(), - subjects: z.string().nullable(), + // created_at: z.string(), + created_at: z.coerce.date(), + educational_stages: z.array(z.string()).nullable(), + languages: z.array(z.string()), + subjects: z.array(z.string()), license: z.string(), object_type: z.string(), - state: z.enum(['draft', 'submitted', 'accepted', 'reported', 'deleted']), + resource_state: z.number(), user: z.string(), user_id: z.number(), views: z.number(), @@ -28,9 +29,10 @@ export const userIndexSchema = z.object({ name: z.string(), username: z.string(), description: z.string().nullable(), - roles: z.string().nullable(), - institutions: z.string().nullable(), - created_at: z.string(), + roles: z.array(z.string()), + institutions: z.array(z.string()), + // created_at: z.string(), + created_at: z.coerce.date(), is_active: z.boolean(), score: z.number(), likes: z.number(), @@ -41,9 +43,12 @@ export const userIndexSchema = z.object({ export const collectionIndexSchema = z.object({ id: z.number(), name: z.string(), - author: z.string().nullable(), + user: z.string().nullable(), description: z.string().nullable(), - created_at: z.string(), + // created_at: z.string(), + created_at: z.coerce.date(), + is_private: z.boolean(), + is_active: z.boolean(), score: z.number(), views: z.number(), downloads: z.number(), diff --git a/src/routes/resource.route.ts b/src/routes/resource.route.ts index 012551dec84278cd819614c3cf50b4e3b9dc5464..dbeca2888f6adcc0fb2aae4589741610356e253e 100644 --- a/src/routes/resource.route.ts +++ b/src/routes/resource.route.ts @@ -236,6 +236,8 @@ export const resourceRouter = honoWithJwt() //indexando await searchService.indexResource(resource.id) + await searchService.indexResource(resource.id) + // 5. Retorna o recurso com os arrays de IDs return c.json({ ...resourceSchema.return.parse(resource), diff --git a/src/routes/search.route.ts b/src/routes/search.route.ts index 2cf6112e9b25c2b9ef4fa72ffcfd6e1322273d73..e942c6542c4c4c45fb626bb76739338330fd0f97 100644 --- a/src/routes/search.route.ts +++ b/src/routes/search.route.ts @@ -5,6 +5,8 @@ import { Hono } from "hono"; import { z } from "zod"; import { zValidator } from "@hono/zod-validator"; import { createApexError, HttpStatus } from "@/services/error.service"; +import { exactFilterSchema } from "@/db/schema/search-filters.schema"; +import { sortBySchema } from "@/db/schema/search-sortable.schema"; const searchService = Container.get(SearchService); @@ -16,24 +18,34 @@ export const searchRouter = new Hono() 'query', z.object({ query: z.string().optional(), - sortBy: z.enum(['created_at']).optional(), + filters: z.string().optional(), + sortBy: sortBySchema.optional(), sortOrder: z.enum(['asc', 'desc']).optional(), offset: z.coerce.number().int().nonnegative().optional(), page_size: z.coerce.number().int().positive().optional(), - - // filters - language: z.string().optional(), - objectType: z.string().optional(), - institution: z.string().optional(), + index: z.string().optional().transform((val) => (val ? val.split(',').map(s => s.trim()): undefined)) }) + .merge(exactFilterSchema) // ✅ merges in all filter fields from your schema ), async (c) => { try { - const { query } = c.req.valid('query'); + // const { query } = c.req.valid('query'); + + const { query, sortBy, sortOrder = 'desc', index, offset = 0, page_size = 1000, ...filters} = c.req.valid('query'); + + const results = await searchService.searchIndex( + query ?? '', + filters, + sortBy, + sortOrder, + index, // indexes to search + offset, + page_size + ); - const results = await searchService.searchIndex(query); + // const results = await searchService.searchIndex(query); console.log("results", results) return c.json({ results }); diff --git a/src/search/index.ts b/src/search/index.ts index 4d209a0d82e7f2b10fd400f4dbf3a2663445d85e..b66108f697a4aacd0df902cddcded61bc20b284a 100644 --- a/src/search/index.ts +++ b/src/search/index.ts @@ -14,9 +14,102 @@ export const es = new Client({ }) // Register the Elasticsearch client in the DI container -console.log("Registering Elasticsearch client..."); Container.set('ElasticSearchClient', es) +const indexMappings = { + [env.ELASTIC_INDEX_USERS]: { + mappings: { + properties: { + name: { type: 'text' }, + username: { type: 'text' }, + description: { type: 'text' }, + roles: {type: 'text'}, + institutions: { + type: 'text', + fields: { raw: { type: 'keyword' } }, + }, + created_at: { + type: "date", + fields: { + text: { type: "text" } + } + }, + is_active: { type: 'boolean' }, + score: { type: 'float' }, + likes: { type: 'integer' }, + followers: { type: 'integer' }, + approved_resources: { type: 'integer' }, + }, + }, + }, + + [env.ELASTIC_INDEX_COLLECTIONS]: { + mappings: { + properties: { + name: { type: 'text' }, + user: { type: 'text' }, + description: { type: 'text' }, + created_at: { + type: "date", + fields: { + text: { type: "text" } + } + }, + is_private: { type: 'boolean' }, + is_active: { type: 'boolean' }, + score: { type: 'float' }, + views: { type: 'integer' }, + downloads: { type: 'integer' }, + likes: { type: 'integer' }, + shares: { type: 'integer' }, + followers: { type: 'integer' }, + }, + }, + }, + + [env.ELASTIC_INDEX_RESOURCES]: { + mappings: { + properties: { + name: { type: 'text' }, + author: { type: 'text' }, + description: { type: 'text' }, + link: { type: 'text' }, + created_at: { + type: "date", + fields: { + text: { type: "text" } + } + }, + educational_stages: { + type: 'text', + fields: { raw: { type: 'keyword' } }, + }, + languages: { + type: 'text', + fields: { raw: { type: 'keyword' } }, + }, + subjects: { + type: 'text', + fields: { raw: { type: 'keyword' } }, + }, + license: { type: 'text' }, + objectType: { + type: 'text', + fields: { raw: { type: 'keyword' } }, + }, + resource_state: { type: 'integer' }, + user: { type: 'text' }, + views: { type: 'integer' }, + downloads: { type: 'integer' }, + likes: { type: 'integer' }, + shares: { type: 'integer' }, + score: { type: 'float' }, + comments: { type: 'integer' }, + }, + }, + }, +} + export const checkElasticsearchConnection = async () => { try { const health = await es.cluster.health() @@ -27,7 +120,25 @@ export const checkElasticsearchConnection = async () => { } } -checkElasticsearchConnection() +export const ensureIndexesExist = async () => { + for (const [indexName, config] of Object.entries(indexMappings)) { + const exists = await es.indices.exists({ index: indexName }) + if (!exists) { + console.log(`Creating index: ${indexName}`) + await es.indices.create({ + index: indexName, + ...config, + }) + } else { + console.log(`Index already exists: ${indexName}`) + } + } +} + +;(async () => { + await checkElasticsearchConnection() + await ensureIndexesExist() +})() export type es = typeof es diff --git a/src/services/search.service.ts b/src/services/search.service.ts index 0504e8dc483bd3fd34c5a3d136cee649957dcbab..17e436a9fb399b73bd6ed9b9ed60bd6f53246958 100644 --- a/src/services/search.service.ts +++ b/src/services/search.service.ts @@ -2,25 +2,29 @@ import { Inject, Service } from "typedi" import es from '@/search' import env from '@/env' import { SearchRepo } from "@/db/repo/search.repo"; +import type { ExactFilterFields } from '@/db/schema/search-filters.schema'; +import type { SortableField } from "@/db/schema/search-sortable.schema"; -export type ExactFilterFields = { - language?: string; - objectType?: string; - institution?: string; - state?: number; -}; +// export type ExactFilterFields = { +// language?: string; +// object_type?: string; +// institution?: string; +// resource_state?: number; +// is_active?: boolean; +// is_private?: boolean; +// }; -export type SortableFilterFields = { - created_at?: string; - views?: number; - downloads?: number; - likes?: number; - shares?: number; - score?: number; - comments?: number; - followers?: number; - approved_resources?: number; -} +// export type SortableFilterFields = { +// created_at?: string; +// views?: number; +// downloads?: number; +// likes?: number; +// shares?: number; +// score?: number; +// comments?: number; +// followers?: number; +// approved_resources?: number; +// } @Service() export class SearchService { @@ -45,16 +49,16 @@ export class SearchService { id: document.id.toString(), body: { name: document.name, - author: document.author ?? "", + author: document.author ?? [], description: document.description ?? "", link: document.link ?? "", created_at: document.created_at, - educational_stages: document.educational_stages?.split(','), // Converte para array - languages: document.languages?.split(',') ?? [], // Converte para array - subjects: document.subjects?.split(',') ?? [], // Converte para array + educational_stages: document.educational_stages ?? [], + languages: document.languages ?? [], + subjects: document.subjects ?? [], license: document.license, - objectType: document.object_type, - state: document.state, + object_type: document.object_type, + resource_state: document.resource_state, user: document.user, user_id: document.user_id, views: document.views, @@ -87,9 +91,11 @@ export class SearchService { id: document.id.toString(), body: { name: document.name, - author: document.author ?? "", + user: document.user ?? "", description: document.description ?? "", created_at: document.created_at, + is_private: document.is_private, + is_active: document.is_active, score: document.score, views: document.views, downloads: document.downloads, @@ -122,8 +128,8 @@ export class SearchService { name: document.name, username: document.username, description: document.description ?? "", - roles: document.roles?.split(',') ?? [], - institutions: document.institutions?.split(',') ?? [], // Converte para array + roles: document.roles ?? [], + institutions: document.institutions ?? [], created_at: document.created_at, is_active: document.is_active, score: document.score, @@ -146,10 +152,11 @@ export class SearchService { // offset: offset da paginação, número da página // page_size: tamanho da página async searchIndex( - query: string | undefined, - filters: Partial<ExactFilterFields> = {}, - sortBy: Partial<SortableFilterFields> = {}, - // sortBy?: 'created_at', + query: string, + // filters: Partial<ExactFilterFields> = {}, + filters: ExactFilterFields, + // sortBy?: keyof SortableFilterFields, + sortBy?: SortableField, sortOrder: 'asc' | 'desc' = 'desc', index: string[] = [env.ELASTIC_INDEX_USERS, env.ELASTIC_INDEX_COLLECTIONS, env.ELASTIC_INDEX_RESOURCES], offset: number = 0,