diff --git a/src/search/dto/search-query.dto.ts b/src/search/dto/search-query.dto.ts new file mode 100644 index 00000000..a1d06148 --- /dev/null +++ b/src/search/dto/search-query.dto.ts @@ -0,0 +1,66 @@ +import { + IsNotEmpty, + IsOptional, + IsString, + IsEnum, + IsInt, + Min, + Max, + MaxLength, +} from "class-validator"; +import { Type } from "class-transformer"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +export enum SearchResourceType { + USER = "user", + MESSAGE = "message", + CONVERSATION = "conversation", + ALL = "all", +} + +export class SearchQueryDto { + @ApiProperty({ + description: "Full-text search query string", + example: "alice blockchain", + maxLength: 256, + }) + @IsNotEmpty() + @IsString() + @MaxLength(256) + q: string; + + @ApiPropertyOptional({ + description: "Resource type to search within", + enum: SearchResourceType, + default: SearchResourceType.ALL, + }) + @IsOptional() + @IsEnum(SearchResourceType) + type?: SearchResourceType = SearchResourceType.ALL; + + @ApiPropertyOptional({ + description: "Page number (1-based)", + example: 1, + minimum: 1, + default: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: "Results per page (max 50)", + example: 20, + minimum: 1, + maximum: 50, + default: 20, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit?: number = 20; +} diff --git a/src/search/dto/search-result.dto.ts b/src/search/dto/search-result.dto.ts new file mode 100644 index 00000000..a01682ff --- /dev/null +++ b/src/search/dto/search-result.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; + +export class SearchHitDto { + @ApiProperty({ description: "Unique resource identifier" }) + id: string; + + @ApiProperty({ + description: "Resource type", + enum: ["user", "message", "conversation"], + }) + type: "user" | "message" | "conversation"; + + @ApiProperty({ description: "Primary display text for the result" }) + title: string; + + @ApiPropertyOptional({ + description: + "Short highlighted snippet showing where the query matched. Tags and wrap the matching term.", + }) + snippet?: string; + + @ApiProperty({ description: "Full-text relevance score" }) + score: number; + + @ApiProperty({ description: "Original resource data" }) + data: Record; + + @ApiProperty({ description: "When the resource was last modified" }) + updatedAt: Date; +} + +export class PaginatedSearchResultDto { + @ApiProperty({ type: [SearchHitDto] }) + data: SearchHitDto[]; + + @ApiProperty({ description: "Total number of matching records" }) + total: number; + + @ApiProperty({ description: "Current page number" }) + page: number; + + @ApiProperty({ description: "Records per page" }) + limit: number; + + @ApiProperty({ description: "Total pages" }) + totalPages: number; + + @ApiProperty({ description: "Query that produced this result" }) + query: string; + + @ApiProperty({ description: "Resource type filter applied" }) + resourceType: string; +} diff --git a/src/search/entities/search-index.entity.ts b/src/search/entities/search-index.entity.ts new file mode 100644 index 00000000..b292d24e --- /dev/null +++ b/src/search/entities/search-index.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +/** + * Flat denormalized table that mirrors searchable content from all resources. + * + * The `tsv` column holds a pre-computed PostgreSQL tsvector so that search + * queries are a simple index scan instead of an on-the-fly conversion. + * + * A GIN index is created on `tsv` via a raw migration / synchronize, making + * full-text search fast even with millions of rows. + */ +@Entity("search_index") +@Index("IDX_SEARCH_RESOURCE", ["resourceType", "resourceId"], { unique: true }) +export class SearchIndex { + @PrimaryGeneratedColumn("uuid") + id: string; + + /** + * Discriminator for the source table (user | message | conversation). + */ + @Column({ type: "varchar", length: 32 }) + @Index("IDX_SEARCH_RESOURCE_TYPE") + resourceType: "user" | "message" | "conversation"; + + /** + * PK of the originating row in its own table. + */ + @Column({ type: "uuid" }) + resourceId: string; + + /** + * Concatenated plain text used for pg full-text search. + * Stored explicitly so we can rebuild tsv without re-reading source tables. + */ + @Column({ type: "text" }) + plainText: string; + + /** + * Serialized tsvector kept in sync by a trigger *or* by the indexer service. + * Stored as text because TypeORM does not natively map `tsvector`. + * We cast it at query time: `to_tsvector('english', plain_text)`. + */ + @Column({ type: "text", nullable: true }) + tsv: string | null; + + /** + * Arbitrary JSON bag – title, username, avatarUrl, etc. + * Returned alongside the search hit for rendering without a secondary query. + */ + @Column({ type: "jsonb", default: {} }) + metadata: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/search/interfaces/search-indexer.interface.ts b/src/search/interfaces/search-indexer.interface.ts new file mode 100644 index 00000000..5750541e --- /dev/null +++ b/src/search/interfaces/search-indexer.interface.ts @@ -0,0 +1,11 @@ +/** + * Represents one document in the search index table. + */ +export interface SearchIndexDocument { + resourceType: "user" | "message" | "conversation"; + resourceId: string; + /** Plain text that will be stored for snippet generation */ + plainText: string; + /** Extra metadata stored as JSON (title, sender, etc.) */ + metadata: Record; +}