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
66 changes: 66 additions & 0 deletions src/search/dto/search-query.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 53 additions & 0 deletions src/search/dto/search-result.dto.ts
Original file line number Diff line number Diff line change
@@ -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 <em> and </em> wrap the matching term.",
})
snippet?: string;

@ApiProperty({ description: "Full-text relevance score" })
score: number;

@ApiProperty({ description: "Original resource data" })
data: Record<string, unknown>;

@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;
}
65 changes: 65 additions & 0 deletions src/search/entities/search-index.entity.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

@CreateDateColumn()
createdAt: Date;

@UpdateDateColumn()
updatedAt: Date;
}
11 changes: 11 additions & 0 deletions src/search/interfaces/search-indexer.interface.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}
Loading