From c6a65869c6f6c2e84e75271675b7b193f917ca35 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 20:34:52 -0600 Subject: [PATCH 1/3] docs: add CONTRIBUTING.md --- CONTRIBUTING.md | 408 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..68b02db --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,408 @@ +# Contributing to Mosaic Stack + +Thank you for your interest in contributing to Mosaic Stack! This document provides guidelines and processes for contributing effectively. + +## Table of Contents + +- [Development Environment Setup](#development-environment-setup) +- [Code Style Guidelines](#code-style-guidelines) +- [Branch Naming Conventions](#branch-naming-conventions) +- [Commit Message Format](#commit-message-format) +- [Pull Request Process](#pull-request-process) +- [Testing Requirements](#testing-requirements) +- [Where to Ask Questions](#where-to-ask-questions) + +## Development Environment Setup + +### Prerequisites + +- **Node.js:** 20.0.0 or higher +- **pnpm:** 10.19.0 or higher (package manager) +- **Docker:** 20.10+ and Docker Compose 2.x+ (for database services) +- **Git:** 2.30+ for version control + +### Installation Steps + +1. **Clone the repository** + + ```bash + git clone https://git.mosaicstack.dev/mosaic/stack mosaic-stack + cd mosaic-stack + ``` + +2. **Install dependencies** + + ```bash + pnpm install + ``` + +3. **Set up environment variables** + + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + + Key variables to configure: + - `DATABASE_URL` - PostgreSQL connection string + - `OIDC_ISSUER` - Authentik OIDC issuer URL + - `OIDC_CLIENT_ID` - OAuth client ID + - `OIDC_CLIENT_SECRET` - OAuth client secret + - `JWT_SECRET` - Random secret for session tokens + +4. **Initialize the database** + + ```bash + # Start Docker services (PostgreSQL, Valkey) + docker compose up -d + + # Generate Prisma client + pnpm prisma:generate + + # Run migrations + pnpm prisma:migrate + + # Seed development data (optional) + pnpm prisma:seed + ``` + +5. **Start development servers** + + ```bash + pnpm dev + ``` + + This starts all services: + - Web: http://localhost:3000 + - API: http://localhost:3001 + +### Quick Reference Commands + +| Command | Description | +|---------|-------------| +| `pnpm dev` | Start all development servers | +| `pnpm dev:api` | Start API only | +| `pnpm dev:web` | Start Web only | +| `docker compose up -d` | Start Docker services | +| `docker compose logs -f` | View Docker logs | +| `pnpm prisma:studio` | Open Prisma Studio GUI | +| `make help` | View all available commands | + +## Code Style Guidelines + +Mosaic Stack follows strict code style guidelines to maintain consistency and quality. For comprehensive guidelines, see [CLAUDE.md](./CLAUDE.md). + +### Formatting + +We use **Prettier** for consistent code formatting: + +- **Semicolons:** Required +- **Quotes:** Double quotes (`"`) +- **Indentation:** 2 spaces +- **Trailing commas:** ES5 compatible +- **Line width:** 100 characters +- **End of line:** LF (Unix style) + +Run the formatter: +```bash +pnpm format # Format all files +pnpm format:check # Check formatting without changes +``` + +### Linting + +We use **ESLint** for code quality checks: + +```bash +pnpm lint # Run linter +pnpm lint:fix # Auto-fix linting issues +``` + +### TypeScript + +All code must be **strictly typed** TypeScript: +- No `any` types allowed +- Explicit type annotations for function returns +- Interfaces over type aliases for object shapes +- Use shared types from `@mosaic/shared` package + +### PDA-Friendly Design (NON-NEGOTIABLE) + +**Never** use demanding or stressful language in UI text: + +| ❌ AVOID | ✅ INSTEAD | +|---------|------------| +| OVERDUE | Target passed | +| URGENT | Approaching target | +| MUST DO | Scheduled for | +| CRITICAL | High priority | +| YOU NEED TO | Consider / Option to | +| REQUIRED | Recommended | + +See [docs/3-architecture/3-design-principles/1-pda-friendly.md](./docs/3-architecture/3-design-principles/1-pda-friendly.md) for complete design principles. + +## Branch Naming Conventions + +We follow a Git-based workflow with the following branch types: + +### Branch Types + +| Prefix | Purpose | Example | +|--------|---------|---------| +| `feature/` | New features | `feature/42-user-dashboard` | +| `fix/` | Bug fixes | `fix/123-auth-redirect` | +| `docs/` | Documentation | `docs/contributing` | +| `refactor/` | Code refactoring | `refactor/prisma-queries` | +| `test/` | Test-only changes | `test/coverage-improvements` | + +### Workflow + +1. Always branch from `develop` +2. Merge back to `develop` via pull request +3. `main` is for stable releases only + +```bash +# Start a new feature +git checkout develop +git pull --rebase +git checkout -b feature/my-feature-name + +# Make your changes +# ... + +# Commit and push +git push origin feature/my-feature-name +``` + +## Commit Message Format + +We use **Conventional Commits** for clear, structured commit messages: + +### Format + +``` +(#issue): Brief description + +Detailed explanation (optional). + +References: #123 +``` + +### Types + +| Type | Description | +|------|-------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation changes | +| `test` | Adding or updating tests | +| `refactor` | Code refactoring (no functional change) | +| `chore` | Maintenance tasks, dependencies | + +### Examples + +```bash +feat(#42): add user dashboard widget + +Implements the dashboard widget with task and event summary cards. +Responsive design with PDA-friendly language. + +fix(#123): resolve auth redirect loop + +Fixed OIDC token refresh causing redirect loops on session expiry. +refactor(#45): extract database query utilities + +Moved duplicate query logic to shared utilities package. +test(#67): add coverage for activity service + +Added unit tests for all activity service methods. +docs: update API documentation for endpoints + +Clarified pagination and filtering parameters. +``` + +### Commit Guidelines + +- Keep the subject line under 72 characters +- Use imperative mood ("add" not "added" or "adds") +- Reference issue numbers when applicable +- Group related commits before creating PR + +## Pull Request Process + +### Before Creating a PR + +1. **Ensure tests pass** + ```bash + pnpm test + pnpm build + ``` + +2. **Check code coverage** (minimum 85%) + ```bash + pnpm test:coverage + ``` + +3. **Format and lint** + ```bash + pnpm format + pnpm lint + ``` + +4. **Update documentation** if needed + - API docs in `docs/4-api/` + - Architecture docs in `docs/3-architecture/` + +### Creating a Pull Request + +1. Push your branch to the remote + ```bash + git push origin feature/my-feature + ``` + +2. Create a PR via GitLab at: + https://git.mosaicstack.dev/mosaic/stack/-/merge_requests + +3. Target branch: `develop` + +4. Fill in the PR template: + - **Title:** `feat(#issue): Brief description` (follows commit format) + - **Description:** Summary of changes, testing done, and any breaking changes + +5. Link related issues using `Closes #123` or `References #123` + +### PR Review Process + +- **Automated checks:** CI runs tests, linting, and coverage +- **Code review:** At least one maintainer approval required +- **Feedback cycle:** Address review comments and push updates +- **Merge:** Maintainers merge after approval and checks pass + +### Merge Guidelines + +- **Rebase commits** before merging (keep history clean) +- **Squash** small fix commits into the main feature commit +- **Delete feature branch** after merge +- **Update milestone** if applicable + +## Testing Requirements + +### Test-Driven Development (TDD) + +**All new code must follow TDD principles.** This is non-negotiable. + +#### TDD Workflow: Red-Green-Refactor + +1. **RED** - Write a failing test first + ```bash + # Write test for new functionality + pnpm test:watch # Watch it fail + git add feature.test.ts + git commit -m "test(#42): add test for getUserById" + ``` + +2. **GREEN** - Write minimal code to pass the test + ```bash + # Implement just enough to pass + pnpm test:watch # Watch it pass + git add feature.ts + git commit -m "feat(#42): implement getUserById" + ``` + +3. **REFACTOR** - Clean up while keeping tests green + ```bash + # Improve code quality + pnpm test:watch # Ensure still passing + git add feature.ts + git commit -m "refactor(#42): extract user mapping logic" + ``` + +### Coverage Requirements + +- **Minimum 85% code coverage** for all new code +- **Write tests BEFORE implementation** — no exceptions +- Test files co-located with source: + - `feature.service.ts` → `feature.service.spec.ts` + - `component.tsx` → `component.test.tsx` + +### Test Types + +| Type | Purpose | Tool | +|------|---------|------| +| **Unit tests** | Test functions/methods in isolation | Vitest | +| **Integration tests** | Test module interactions (service + DB) | Vitest | +| **E2E tests** | Test complete user workflows | Playwright | + +### Running Tests + +```bash +pnpm test # Run all tests +pnpm test:watch # Watch mode for TDD +pnpm test:coverage # Generate coverage report +pnpm test:api # API tests only +pnpm test:web # Web tests only +pnpm test:e2e # Playwright E2E tests +``` + +### Coverage Verification + +After implementation: +```bash +pnpm test:coverage +# Open coverage/index.html in browser +# Verify your files show ≥85% coverage +``` + +### Test Guidelines + +- **Descriptive names:** `it("should return user when valid token provided")` +- **Group related tests:** Use `describe()` blocks +- **Mock external dependencies:** Database, APIs, file system +- **Avoid implementation details:** Test behavior, not internals + +## Where to Ask Questions + +### Issue Tracker + +All questions, bug reports, and feature requests go through the issue tracker: +https://git.mosaicstack.dev/mosaic/stack/issues + +### Issue Labels + +| Category | Labels | +|----------|--------| +| Priority | `p0` (critical), `p1` (high), `p2` (medium), `p3` (low) | +| Type | `api`, `web`, `database`, `auth`, `plugin`, `ai`, `devops`, `docs`, `testing` | +| Status | `todo`, `in-progress`, `review`, `blocked`, `done` | + +### Documentation + +Check existing documentation first: +- [README.md](./README.md) - Project overview +- [CLAUDE.md](./CLAUDE.md) - Comprehensive development guidelines +- [docs/](./docs/) - Full documentation suite + +### Getting Help + +1. **Search existing issues** - Your question may already be answered +2. **Create an issue** with: + - Clear title and description + - Steps to reproduce (for bugs) + - Expected vs actual behavior + - Environment details (Node version, OS, etc.) + +### Communication Channels + +- **Issues:** For bugs, features, and questions (primary channel) +- **Pull Requests:** For code review and collaboration +- **Documentation:** For clarifications and improvements + +--- + +**Thank you for contributing to Mosaic Stack!** Every contribution helps make this platform better for everyone. + +For more details, see: +- [Project README](./README.md) +- [Development Guidelines](./CLAUDE.md) +- [API Documentation](./docs/4-api/) +- [Architecture](./docs/3-architecture/) From c26b7d4e64d048b19a02262f6cb414952c86ea59 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 20:35:07 -0600 Subject: [PATCH 2/3] feat(knowledge): add search service --- apps/api/src/knowledge/dto/index.ts | 5 + .../api/src/knowledge/dto/search-query.dto.ts | 81 ++++ apps/api/src/knowledge/knowledge.module.ts | 8 +- .../src/knowledge/search.controller.spec.ts | 197 +++++++++ apps/api/src/knowledge/search.controller.ts | 88 ++++ .../knowledge/services/search.service.spec.ts | 318 ++++++++++++++ .../src/knowledge/services/search.service.ts | 415 ++++++++++++++++++ 7 files changed, 1109 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/knowledge/dto/search-query.dto.ts create mode 100644 apps/api/src/knowledge/search.controller.spec.ts create mode 100644 apps/api/src/knowledge/search.controller.ts create mode 100644 apps/api/src/knowledge/services/search.service.spec.ts create mode 100644 apps/api/src/knowledge/services/search.service.ts diff --git a/apps/api/src/knowledge/dto/index.ts b/apps/api/src/knowledge/dto/index.ts index 120371e..1fe3c76 100644 --- a/apps/api/src/knowledge/dto/index.ts +++ b/apps/api/src/knowledge/dto/index.ts @@ -3,3 +3,8 @@ export { UpdateEntryDto } from "./update-entry.dto"; export { EntryQueryDto } from "./entry-query.dto"; export { CreateTagDto } from "./create-tag.dto"; export { UpdateTagDto } from "./update-tag.dto"; +export { + SearchQueryDto, + TagSearchDto, + RecentEntriesDto, +} from "./search-query.dto"; diff --git a/apps/api/src/knowledge/dto/search-query.dto.ts b/apps/api/src/knowledge/dto/search-query.dto.ts new file mode 100644 index 0000000..81f48bd --- /dev/null +++ b/apps/api/src/knowledge/dto/search-query.dto.ts @@ -0,0 +1,81 @@ +import { + IsOptional, + IsString, + IsInt, + Min, + Max, + IsArray, + IsEnum, +} from "class-validator"; +import { Type, Transform } from "class-transformer"; +import { EntryStatus } from "@prisma/client"; + +/** + * DTO for full-text search query parameters + */ +export class SearchQueryDto { + @IsString({ message: "q (query) must be a string" }) + q!: string; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} + +/** + * DTO for searching by tags + */ +export class TagSearchDto { + @Transform(({ value }) => + typeof value === "string" ? value.split(",") : value + ) + @IsArray({ message: "tags must be an array" }) + @IsString({ each: true, message: "each tag must be a string" }) + tags!: string[]; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "page must be an integer" }) + @Min(1, { message: "page must be at least 1" }) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(100, { message: "limit must not exceed 100" }) + limit?: number; +} + +/** + * DTO for recent entries query + */ +export class RecentEntriesDto { + @IsOptional() + @Type(() => Number) + @IsInt({ message: "limit must be an integer" }) + @Min(1, { message: "limit must be at least 1" }) + @Max(50, { message: "limit must not exceed 50" }) + limit?: number; + + @IsOptional() + @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) + status?: EntryStatus; +} diff --git a/apps/api/src/knowledge/knowledge.module.ts b/apps/api/src/knowledge/knowledge.module.ts index d826d33..a22a58c 100644 --- a/apps/api/src/knowledge/knowledge.module.ts +++ b/apps/api/src/knowledge/knowledge.module.ts @@ -3,12 +3,14 @@ import { PrismaModule } from "../prisma/prisma.module"; import { AuthModule } from "../auth/auth.module"; import { KnowledgeService } from "./knowledge.service"; import { KnowledgeController } from "./knowledge.controller"; +import { SearchController } from "./search.controller"; import { LinkResolutionService } from "./services/link-resolution.service"; +import { SearchService } from "./services/search.service"; @Module({ imports: [PrismaModule, AuthModule], - controllers: [KnowledgeController], - providers: [KnowledgeService, LinkResolutionService], - exports: [KnowledgeService, LinkResolutionService], + controllers: [KnowledgeController, SearchController], + providers: [KnowledgeService, LinkResolutionService, SearchService], + exports: [KnowledgeService, LinkResolutionService, SearchService], }) export class KnowledgeModule {} diff --git a/apps/api/src/knowledge/search.controller.spec.ts b/apps/api/src/knowledge/search.controller.spec.ts new file mode 100644 index 0000000..7c25562 --- /dev/null +++ b/apps/api/src/knowledge/search.controller.spec.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { EntryStatus } from "@prisma/client"; +import { SearchController } from "./search.controller"; +import { SearchService } from "./services/search.service"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; + +describe("SearchController", () => { + let controller: SearchController; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000"; + + const mockSearchService = { + search: vi.fn(), + searchByTags: vi.fn(), + recentEntries: vi.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SearchController], + providers: [ + { + provide: SearchService, + useValue: mockSearchService, + }, + ], + }) + .overrideGuard(AuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(WorkspaceGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(PermissionGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(SearchController); + + vi.clearAllMocks(); + }); + + describe("search", () => { + it("should call searchService.search with correct parameters", async () => { + const mockResult = { + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + query: "test", + }; + mockSearchService.search.mockResolvedValue(mockResult); + + const result = await controller.search(mockWorkspaceId, { + q: "test", + page: 1, + limit: 20, + }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + "test", + mockWorkspaceId, + { + status: undefined, + page: 1, + limit: 20, + } + ); + expect(result).toEqual(mockResult); + }); + + it("should pass status filter to service", async () => { + mockSearchService.search.mockResolvedValue({ + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + query: "test", + }); + + await controller.search(mockWorkspaceId, { + q: "test", + status: EntryStatus.PUBLISHED, + }); + + expect(mockSearchService.search).toHaveBeenCalledWith( + "test", + mockWorkspaceId, + { + status: EntryStatus.PUBLISHED, + page: undefined, + limit: undefined, + } + ); + }); + }); + + describe("searchByTags", () => { + it("should call searchService.searchByTags with correct parameters", async () => { + const mockResult = { + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + }; + mockSearchService.searchByTags.mockResolvedValue(mockResult); + + const result = await controller.searchByTags(mockWorkspaceId, { + tags: ["api", "documentation"], + page: 1, + limit: 20, + }); + + expect(mockSearchService.searchByTags).toHaveBeenCalledWith( + ["api", "documentation"], + mockWorkspaceId, + { + status: undefined, + page: 1, + limit: 20, + } + ); + expect(result).toEqual(mockResult); + }); + + it("should pass status filter to service", async () => { + mockSearchService.searchByTags.mockResolvedValue({ + data: [], + pagination: { page: 1, limit: 20, total: 0, totalPages: 0 }, + }); + + await controller.searchByTags(mockWorkspaceId, { + tags: ["api"], + status: EntryStatus.DRAFT, + }); + + expect(mockSearchService.searchByTags).toHaveBeenCalledWith( + ["api"], + mockWorkspaceId, + { + status: EntryStatus.DRAFT, + page: undefined, + limit: undefined, + } + ); + }); + }); + + describe("recentEntries", () => { + it("should call searchService.recentEntries with correct parameters", async () => { + const mockEntries = [ + { + id: "entry-1", + title: "Recent Entry", + slug: "recent-entry", + tags: [], + }, + ]; + mockSearchService.recentEntries.mockResolvedValue(mockEntries); + + const result = await controller.recentEntries(mockWorkspaceId, { + limit: 10, + }); + + expect(mockSearchService.recentEntries).toHaveBeenCalledWith( + mockWorkspaceId, + 10, + undefined + ); + expect(result).toEqual({ + data: mockEntries, + count: 1, + }); + }); + + it("should use default limit of 10", async () => { + mockSearchService.recentEntries.mockResolvedValue([]); + + await controller.recentEntries(mockWorkspaceId, {}); + + expect(mockSearchService.recentEntries).toHaveBeenCalledWith( + mockWorkspaceId, + 10, + undefined + ); + }); + + it("should pass status filter to service", async () => { + mockSearchService.recentEntries.mockResolvedValue([]); + + await controller.recentEntries(mockWorkspaceId, { + status: EntryStatus.PUBLISHED, + limit: 5, + }); + + expect(mockSearchService.recentEntries).toHaveBeenCalledWith( + mockWorkspaceId, + 5, + EntryStatus.PUBLISHED + ); + }); + }); +}); diff --git a/apps/api/src/knowledge/search.controller.ts b/apps/api/src/knowledge/search.controller.ts new file mode 100644 index 0000000..5d3f7b0 --- /dev/null +++ b/apps/api/src/knowledge/search.controller.ts @@ -0,0 +1,88 @@ +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { SearchService } from "./services/search.service"; +import { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./dto"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import { WorkspaceGuard, PermissionGuard } from "../common/guards"; +import { Workspace, Permission, RequirePermission } from "../common/decorators"; + +/** + * Controller for knowledge search endpoints + * All endpoints require authentication and workspace context + */ +@Controller("knowledge/search") +@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + /** + * GET /api/knowledge/search + * Full-text search across knowledge entries + * Searches title and content with relevance ranking + * Requires: Any workspace member + * + * @query q - The search query string (required) + * @query status - Filter by entry status (optional) + * @query page - Page number (default: 1) + * @query limit - Results per page (default: 20, max: 100) + */ + @Get() + @RequirePermission(Permission.WORKSPACE_ANY) + async search( + @Workspace() workspaceId: string, + @Query() query: SearchQueryDto + ) { + return this.searchService.search(query.q, workspaceId, { + status: query.status, + page: query.page, + limit: query.limit, + }); + } + + /** + * GET /api/knowledge/search/by-tags + * Search entries by tags (entries must have ALL specified tags) + * Requires: Any workspace member + * + * @query tags - Comma-separated list of tag slugs (required) + * @query status - Filter by entry status (optional) + * @query page - Page number (default: 1) + * @query limit - Results per page (default: 20, max: 100) + */ + @Get("by-tags") + @RequirePermission(Permission.WORKSPACE_ANY) + async searchByTags( + @Workspace() workspaceId: string, + @Query() query: TagSearchDto + ) { + return this.searchService.searchByTags(query.tags, workspaceId, { + status: query.status, + page: query.page, + limit: query.limit, + }); + } + + /** + * GET /api/knowledge/search/recent + * Get recently modified entries + * Requires: Any workspace member + * + * @query limit - Maximum number of entries (default: 10, max: 50) + * @query status - Filter by entry status (optional) + */ + @Get("recent") + @RequirePermission(Permission.WORKSPACE_ANY) + async recentEntries( + @Workspace() workspaceId: string, + @Query() query: RecentEntriesDto + ) { + const entries = await this.searchService.recentEntries( + workspaceId, + query.limit || 10, + query.status + ); + return { + data: entries, + count: entries.length, + }; + } +} diff --git a/apps/api/src/knowledge/services/search.service.spec.ts b/apps/api/src/knowledge/services/search.service.spec.ts new file mode 100644 index 0000000..d7f96ce --- /dev/null +++ b/apps/api/src/knowledge/services/search.service.spec.ts @@ -0,0 +1,318 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { EntryStatus } from "@prisma/client"; +import { SearchService } from "./search.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +describe("SearchService", () => { + let service: SearchService; + let prismaService: any; + + const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440000"; + + beforeEach(async () => { + const mockQueryRaw = vi.fn(); + const mockKnowledgeEntryCount = vi.fn(); + const mockKnowledgeEntryFindMany = vi.fn(); + const mockKnowledgeEntryTagFindMany = vi.fn(); + + const mockPrismaService = { + $queryRaw: mockQueryRaw, + knowledgeEntry: { + count: mockKnowledgeEntryCount, + findMany: mockKnowledgeEntryFindMany, + }, + knowledgeEntryTag: { + findMany: mockKnowledgeEntryTagFindMany, + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SearchService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(SearchService); + prismaService = module.get(PrismaService); + }); + + describe("search", () => { + it("should return empty results for empty query", async () => { + const result = await service.search("", mockWorkspaceId); + + expect(result.data).toEqual([]); + expect(result.pagination.total).toBe(0); + expect(result.query).toBe(""); + }); + + it("should return empty results for whitespace-only query", async () => { + const result = await service.search(" ", mockWorkspaceId); + + expect(result.data).toEqual([]); + expect(result.pagination.total).toBe(0); + }); + + it("should perform full-text search and return ranked results", async () => { + const mockSearchResults = [ + { + id: "entry-1", + workspace_id: mockWorkspaceId, + slug: "test-entry", + title: "Test Entry", + content: "This is test content", + content_html: "

This is test content

", + summary: "Test summary", + status: EntryStatus.PUBLISHED, + visibility: "WORKSPACE", + created_at: new Date(), + updated_at: new Date(), + created_by: "user-1", + updated_by: "user-1", + rank: 0.5, + headline: "This is test content", + }, + ]; + + prismaService.$queryRaw + .mockResolvedValueOnce(mockSearchResults) + .mockResolvedValueOnce([{ count: BigInt(1) }]); + + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([ + { + entryId: "entry-1", + tag: { + id: "tag-1", + name: "Documentation", + slug: "documentation", + color: "#blue", + }, + }, + ]); + + const result = await service.search("test", mockWorkspaceId); + + expect(result.data).toHaveLength(1); + expect(result.data[0].title).toBe("Test Entry"); + expect(result.data[0].rank).toBe(0.5); + expect(result.data[0].headline).toBe("This is test content"); + expect(result.data[0].tags).toHaveLength(1); + expect(result.pagination.total).toBe(1); + expect(result.query).toBe("test"); + }); + + it("should sanitize search query removing special characters", async () => { + prismaService.$queryRaw + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: BigInt(0) }]); + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); + + await service.search("test & query | !special:chars*", mockWorkspaceId); + + // Should have been called with sanitized query + expect(prismaService.$queryRaw).toHaveBeenCalled(); + }); + + it("should apply status filter when provided", async () => { + prismaService.$queryRaw + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: BigInt(0) }]); + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); + + await service.search("test", mockWorkspaceId, { + status: EntryStatus.DRAFT, + }); + + expect(prismaService.$queryRaw).toHaveBeenCalled(); + }); + + it("should handle pagination correctly", async () => { + prismaService.$queryRaw + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ count: BigInt(50) }]); + prismaService.knowledgeEntryTag.findMany.mockResolvedValue([]); + + const result = await service.search("test", mockWorkspaceId, { + page: 2, + limit: 10, + }); + + expect(result.pagination.page).toBe(2); + expect(result.pagination.limit).toBe(10); + expect(result.pagination.total).toBe(50); + expect(result.pagination.totalPages).toBe(5); + }); + }); + + describe("searchByTags", () => { + it("should return empty results for empty tags array", async () => { + const result = await service.searchByTags([], mockWorkspaceId); + + expect(result.data).toEqual([]); + expect(result.pagination.total).toBe(0); + }); + + it("should find entries with all specified tags", async () => { + const mockEntries = [ + { + id: "entry-1", + workspaceId: mockWorkspaceId, + slug: "tagged-entry", + title: "Tagged Entry", + content: "Content with tags", + contentHtml: "

Content with tags

", + summary: null, + status: EntryStatus.PUBLISHED, + visibility: "WORKSPACE", + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "user-1", + updatedBy: "user-1", + tags: [ + { + tag: { + id: "tag-1", + name: "API", + slug: "api", + color: "#blue", + }, + }, + { + tag: { + id: "tag-2", + name: "Documentation", + slug: "documentation", + color: "#green", + }, + }, + ], + }, + ]; + + prismaService.knowledgeEntry.count.mockResolvedValue(1); + prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries); + + const result = await service.searchByTags( + ["api", "documentation"], + mockWorkspaceId + ); + + expect(result.data).toHaveLength(1); + expect(result.data[0].title).toBe("Tagged Entry"); + expect(result.data[0].tags).toHaveLength(2); + expect(result.pagination.total).toBe(1); + }); + + it("should apply status filter when provided", async () => { + prismaService.knowledgeEntry.count.mockResolvedValue(0); + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.searchByTags(["api"], mockWorkspaceId, { + status: EntryStatus.DRAFT, + }); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: EntryStatus.DRAFT, + }), + }) + ); + }); + + it("should handle pagination correctly", async () => { + prismaService.knowledgeEntry.count.mockResolvedValue(25); + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + const result = await service.searchByTags(["api"], mockWorkspaceId, { + page: 2, + limit: 10, + }); + + expect(result.pagination.page).toBe(2); + expect(result.pagination.limit).toBe(10); + expect(result.pagination.total).toBe(25); + expect(result.pagination.totalPages).toBe(3); + }); + }); + + describe("recentEntries", () => { + it("should return recently modified entries", async () => { + const mockEntries = [ + { + id: "entry-1", + workspaceId: mockWorkspaceId, + slug: "recent-entry", + title: "Recent Entry", + content: "Recently updated content", + contentHtml: "

Recently updated content

", + summary: null, + status: EntryStatus.PUBLISHED, + visibility: "WORKSPACE", + createdAt: new Date(), + updatedAt: new Date(), + createdBy: "user-1", + updatedBy: "user-1", + tags: [], + }, + ]; + + prismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries); + + const result = await service.recentEntries(mockWorkspaceId); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Recent Entry"); + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { updatedAt: "desc" }, + take: 10, + }) + ); + }); + + it("should respect the limit parameter", async () => { + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.recentEntries(mockWorkspaceId, 5); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 5, + }) + ); + }); + + it("should apply status filter when provided", async () => { + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.recentEntries(mockWorkspaceId, 10, EntryStatus.DRAFT); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: EntryStatus.DRAFT, + }), + }) + ); + }); + + it("should exclude archived entries by default", async () => { + prismaService.knowledgeEntry.findMany.mockResolvedValue([]); + + await service.recentEntries(mockWorkspaceId); + + expect(prismaService.knowledgeEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: { not: EntryStatus.ARCHIVED }, + }), + }) + ); + }); + }); +}); diff --git a/apps/api/src/knowledge/services/search.service.ts b/apps/api/src/knowledge/services/search.service.ts new file mode 100644 index 0000000..18add2d --- /dev/null +++ b/apps/api/src/knowledge/services/search.service.ts @@ -0,0 +1,415 @@ +import { Injectable } from "@nestjs/common"; +import { EntryStatus, Prisma } from "@prisma/client"; +import { PrismaService } from "../../prisma/prisma.service"; +import type { + KnowledgeEntryWithTags, + PaginatedEntries, +} from "../entities/knowledge-entry.entity"; + +/** + * Search options for full-text search + */ +export interface SearchOptions { + status?: EntryStatus | undefined; + page?: number | undefined; + limit?: number | undefined; +} + +/** + * Search result with relevance ranking + */ +export interface SearchResult extends KnowledgeEntryWithTags { + rank: number; + headline?: string | undefined; +} + +/** + * Paginated search results + */ +export interface PaginatedSearchResults { + data: SearchResult[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + query: string; +} + +/** + * Raw search result from PostgreSQL query + */ +interface RawSearchResult { + id: string; + workspace_id: string; + slug: string; + title: string; + content: string; + content_html: string | null; + summary: string | null; + status: EntryStatus; + visibility: string; + created_at: Date; + updated_at: Date; + created_by: string; + updated_by: string; + rank: number; + headline: string | null; +} + +/** + * Service for searching knowledge entries using PostgreSQL full-text search + */ +@Injectable() +export class SearchService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Full-text search on title and content using PostgreSQL ts_vector + * + * @param query - The search query string + * @param workspaceId - The workspace to search within + * @param options - Search options (status filter, pagination) + * @returns Paginated search results ranked by relevance + */ + async search( + query: string, + workspaceId: string, + options: SearchOptions = {} + ): Promise { + const page = options.page || 1; + const limit = options.limit || 20; + const offset = (page - 1) * limit; + + // Sanitize and prepare the search query + const sanitizedQuery = this.sanitizeSearchQuery(query); + + if (!sanitizedQuery) { + return { + data: [], + pagination: { + page, + limit, + total: 0, + totalPages: 0, + }, + query, + }; + } + + // Build status filter + const statusFilter = options.status + ? Prisma.sql`AND e.status = ${options.status}::text::"EntryStatus"` + : Prisma.sql`AND e.status != 'ARCHIVED'`; + + // PostgreSQL full-text search query + // Uses ts_rank for relevance scoring with weights: title (A=1.0), content (B=0.4) + const searchResults = await this.prisma.$queryRaw` + WITH search_query AS ( + SELECT plainto_tsquery('english', ${sanitizedQuery}) AS query + ) + SELECT + e.id, + e.workspace_id, + e.slug, + e.title, + e.content, + e.content_html, + e.summary, + e.status, + e.visibility, + e.created_at, + e.updated_at, + e.created_by, + e.updated_by, + ts_rank( + setweight(to_tsvector('english', e.title), 'A') || + setweight(to_tsvector('english', e.content), 'B'), + sq.query + ) AS rank, + ts_headline( + 'english', + e.content, + sq.query, + 'MaxWords=50, MinWords=25, StartSel=, StopSel=' + ) AS headline + FROM knowledge_entries e, search_query sq + WHERE e.workspace_id = ${workspaceId}::uuid + ${statusFilter} + AND ( + to_tsvector('english', e.title) @@ sq.query + OR to_tsvector('english', e.content) @@ sq.query + ) + ORDER BY rank DESC, e.updated_at DESC + LIMIT ${limit} + OFFSET ${offset} + `; + + // Get total count for pagination + const countResult = await this.prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count + FROM knowledge_entries e + WHERE e.workspace_id = ${workspaceId}::uuid + ${statusFilter} + AND ( + to_tsvector('english', e.title) @@ plainto_tsquery('english', ${sanitizedQuery}) + OR to_tsvector('english', e.content) @@ plainto_tsquery('english', ${sanitizedQuery}) + ) + `; + + const total = Number(countResult[0].count); + + // Fetch tags for the results + const entryIds = searchResults.map((r) => r.id); + const tagsMap = await this.fetchTagsForEntries(entryIds); + + // Transform results to the expected format + const data: SearchResult[] = searchResults.map((row) => ({ + id: row.id, + workspaceId: row.workspace_id, + slug: row.slug, + title: row.title, + content: row.content, + contentHtml: row.content_html, + summary: row.summary, + status: row.status, + visibility: row.visibility as "PRIVATE" | "WORKSPACE" | "PUBLIC", + createdAt: row.created_at, + updatedAt: row.updated_at, + createdBy: row.created_by, + updatedBy: row.updated_by, + rank: row.rank, + headline: row.headline ?? undefined, + tags: tagsMap.get(row.id) || [], + })); + + return { + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + query, + }; + } + + /** + * Search entries by tags (entries must have ALL specified tags) + * + * @param tags - Array of tag slugs to filter by + * @param workspaceId - The workspace to search within + * @param options - Search options (status filter, pagination) + * @returns Paginated entries that have all specified tags + */ + async searchByTags( + tags: string[], + workspaceId: string, + options: SearchOptions = {} + ): Promise { + const page = options.page || 1; + const limit = options.limit || 20; + const skip = (page - 1) * limit; + + if (!tags || tags.length === 0) { + return { + data: [], + pagination: { + page, + limit, + total: 0, + totalPages: 0, + }, + }; + } + + // Build where clause for entries that have ALL specified tags + const where: Prisma.KnowledgeEntryWhereInput = { + workspaceId, + status: options.status || { not: EntryStatus.ARCHIVED }, + AND: tags.map((tagSlug) => ({ + tags: { + some: { + tag: { + slug: tagSlug, + }, + }, + }, + })), + }; + + // Get total count + const total = await this.prisma.knowledgeEntry.count({ where }); + + // Get entries + const entries = await this.prisma.knowledgeEntry.findMany({ + where, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + skip, + take: limit, + }); + + // Transform to response format + const data: KnowledgeEntryWithTags[] = entries.map((entry) => ({ + id: entry.id, + workspaceId: entry.workspaceId, + slug: entry.slug, + title: entry.title, + content: entry.content, + contentHtml: entry.contentHtml, + summary: entry.summary, + status: entry.status, + visibility: entry.visibility, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + createdBy: entry.createdBy, + updatedBy: entry.updatedBy, + tags: entry.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + })); + + return { + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + /** + * Get recently modified entries + * + * @param workspaceId - The workspace to query + * @param limit - Maximum number of entries to return (default: 10) + * @param status - Optional status filter + * @returns Array of recently modified entries + */ + async recentEntries( + workspaceId: string, + limit: number = 10, + status?: EntryStatus + ): Promise { + const where: Prisma.KnowledgeEntryWhereInput = { + workspaceId, + status: status || { not: EntryStatus.ARCHIVED }, + }; + + const entries = await this.prisma.knowledgeEntry.findMany({ + where, + include: { + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + updatedAt: "desc", + }, + take: limit, + }); + + return entries.map((entry) => ({ + id: entry.id, + workspaceId: entry.workspaceId, + slug: entry.slug, + title: entry.title, + content: entry.content, + contentHtml: entry.contentHtml, + summary: entry.summary, + status: entry.status, + visibility: entry.visibility, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + createdBy: entry.createdBy, + updatedBy: entry.updatedBy, + tags: entry.tags.map((et) => ({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + })), + })); + } + + /** + * Sanitize search query to prevent SQL injection and handle special characters + */ + private sanitizeSearchQuery(query: string): string { + if (!query || typeof query !== "string") { + return ""; + } + + // Trim and normalize whitespace + let sanitized = query.trim().replace(/\s+/g, " "); + + // Remove PostgreSQL full-text search operators that could cause issues + sanitized = sanitized.replace(/[&|!:*()]/g, " "); + + // Trim again after removing special chars + sanitized = sanitized.trim(); + + return sanitized; + } + + /** + * Fetch tags for a list of entry IDs + */ + private async fetchTagsForEntries( + entryIds: string[] + ): Promise< + Map< + string, + Array<{ id: string; name: string; slug: string; color: string | null }> + > + > { + if (entryIds.length === 0) { + return new Map(); + } + + const entryTags = await this.prisma.knowledgeEntryTag.findMany({ + where: { + entryId: { in: entryIds }, + }, + include: { + tag: true, + }, + }); + + const tagsMap = new Map< + string, + Array<{ id: string; name: string; slug: string; color: string | null }> + >(); + + for (const et of entryTags) { + const tags = tagsMap.get(et.entryId) || []; + tags.push({ + id: et.tag.id, + name: et.tag.name, + slug: et.tag.slug, + color: et.tag.color, + }); + tagsMap.set(et.entryId, tags); + } + + return tagsMap; + } +} From 856b7a20e934d2bb61c8f0d6acfdc6f4349f02ef Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Thu, 29 Jan 2026 20:58:33 -0600 Subject: [PATCH 3/3] fix: address code review feedback - Add explicit return types to all SearchController methods - Import necessary types (PaginatedSearchResults, PaginatedEntries) - Define RecentEntriesResponse interface for type safety - Ensures compliance with TypeScript strict typing standards --- apps/api/src/knowledge/search.controller.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/api/src/knowledge/search.controller.ts b/apps/api/src/knowledge/search.controller.ts index 5d3f7b0..41ba4e9 100644 --- a/apps/api/src/knowledge/search.controller.ts +++ b/apps/api/src/knowledge/search.controller.ts @@ -1,9 +1,21 @@ import { Controller, Get, Query, UseGuards } from "@nestjs/common"; -import { SearchService } from "./services/search.service"; +import { SearchService, PaginatedSearchResults } from "./services/search.service"; import { SearchQueryDto, TagSearchDto, RecentEntriesDto } from "./dto"; import { AuthGuard } from "../auth/guards/auth.guard"; import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { Workspace, Permission, RequirePermission } from "../common/decorators"; +import type { + PaginatedEntries, + KnowledgeEntryWithTags, +} from "./entities/knowledge-entry.entity"; + +/** + * Response for recent entries endpoint + */ +interface RecentEntriesResponse { + data: KnowledgeEntryWithTags[]; + count: number; +} /** * Controller for knowledge search endpoints @@ -30,7 +42,7 @@ export class SearchController { async search( @Workspace() workspaceId: string, @Query() query: SearchQueryDto - ) { + ): Promise { return this.searchService.search(query.q, workspaceId, { status: query.status, page: query.page, @@ -53,7 +65,7 @@ export class SearchController { async searchByTags( @Workspace() workspaceId: string, @Query() query: TagSearchDto - ) { + ): Promise { return this.searchService.searchByTags(query.tags, workspaceId, { status: query.status, page: query.page, @@ -74,7 +86,7 @@ export class SearchController { async recentEntries( @Workspace() workspaceId: string, @Query() query: RecentEntriesDto - ) { + ): Promise { const entries = await this.searchService.recentEntries( workspaceId, query.limit || 10,