feat(knowledge): add tag management API (KNOW-003)
- Add Tag DTOs (CreateTagDto, UpdateTagDto) with validation - Implement TagsService with CRUD operations - Add TagsController with authenticated endpoints - Support automatic slug generation from tag names - Add workspace isolation for tags - Include entry count in tag responses - Add findOrCreateTags method for entry creation/update - Implement comprehensive test coverage (29 tests passing) Endpoints: - GET /api/knowledge/tags - List workspace tags - POST /api/knowledge/tags - Create tag - GET /api/knowledge/tags/:slug - Get tag by slug - PUT /api/knowledge/tags/:slug - Update tag - DELETE /api/knowledge/tags/:slug - Delete tag - GET /api/knowledge/tags/:slug/entries - List entries with tag Related: KNOW-003
This commit is contained in:
@@ -32,11 +32,14 @@
|
|||||||
"@nestjs/core": "^11.1.12",
|
"@nestjs/core": "^11.1.12",
|
||||||
"@nestjs/platform-express": "^11.1.12",
|
"@nestjs/platform-express": "^11.1.12",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
|
"@types/marked": "^6.0.0",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"slugify": "^1.6.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@better-auth/cli": "^1.4.17",
|
"@better-auth/cli": "^1.4.17",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DomainsModule } from "./domains/domains.module";
|
|||||||
import { IdeasModule } from "./ideas/ideas.module";
|
import { IdeasModule } from "./ideas/ideas.module";
|
||||||
import { WidgetsModule } from "./widgets/widgets.module";
|
import { WidgetsModule } from "./widgets/widgets.module";
|
||||||
import { LayoutsModule } from "./layouts/layouts.module";
|
import { LayoutsModule } from "./layouts/layouts.module";
|
||||||
|
import { KnowledgeModule } from "./knowledge/knowledge.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,6 +27,7 @@ import { LayoutsModule } from "./layouts/layouts.module";
|
|||||||
IdeasModule,
|
IdeasModule,
|
||||||
WidgetsModule,
|
WidgetsModule,
|
||||||
LayoutsModule,
|
LayoutsModule,
|
||||||
|
KnowledgeModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
46
apps/api/src/knowledge/dto/create-entry.dto.ts
Normal file
46
apps/api/src/knowledge/dto/create-entry.dto.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { EntryStatus, Visibility } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new knowledge entry
|
||||||
|
*/
|
||||||
|
export class CreateEntryDto {
|
||||||
|
@IsString({ message: "title must be a string" })
|
||||||
|
@MinLength(1, { message: "title must not be empty" })
|
||||||
|
@MaxLength(500, { message: "title must not exceed 500 characters" })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "content must be a string" })
|
||||||
|
@MinLength(1, { message: "content must not be empty" })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "summary must be a string" })
|
||||||
|
@MaxLength(1000, { message: "summary must not exceed 1000 characters" })
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
|
status?: EntryStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
||||||
|
visibility?: Visibility;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "tags must be an array" })
|
||||||
|
@IsString({ each: true, message: "each tag must be a string" })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "changeNote must be a string" })
|
||||||
|
@MaxLength(500, { message: "changeNote must not exceed 500 characters" })
|
||||||
|
changeNote?: string;
|
||||||
|
}
|
||||||
39
apps/api/src/knowledge/dto/create-tag.dto.ts
Normal file
39
apps/api/src/knowledge/dto/create-tag.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for creating a new knowledge tag
|
||||||
|
*/
|
||||||
|
export class CreateTagDto {
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(100, { message: "name must not exceed 100 characters" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "slug must be a string" })
|
||||||
|
@MinLength(1, { message: "slug must not be empty" })
|
||||||
|
@MaxLength(100, { message: "slug must not exceed 100 characters" })
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message:
|
||||||
|
"slug must be lowercase, alphanumeric, and may contain hyphens (e.g., 'my-tag-name')",
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "color must be a string" })
|
||||||
|
@Matches(/^#[0-9A-Fa-f]{6}$/, {
|
||||||
|
message: "color must be a valid hex color (e.g., #FF5733)",
|
||||||
|
})
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "description must be a string" })
|
||||||
|
@MaxLength(500, { message: "description must not exceed 500 characters" })
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
29
apps/api/src/knowledge/dto/entry-query.dto.ts
Normal file
29
apps/api/src/knowledge/dto/entry-query.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { EntryStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for querying knowledge entries (list endpoint)
|
||||||
|
*/
|
||||||
|
export class EntryQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
|
status?: EntryStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "tag must be a string" })
|
||||||
|
tag?: string;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
5
apps/api/src/knowledge/dto/index.ts
Normal file
5
apps/api/src/knowledge/dto/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { CreateEntryDto } from "./create-entry.dto";
|
||||||
|
export { UpdateEntryDto } from "./update-entry.dto";
|
||||||
|
export { EntryQueryDto } from "./entry-query.dto";
|
||||||
|
export { CreateTagDto } from "./create-tag.dto";
|
||||||
|
export { UpdateTagDto } from "./update-tag.dto";
|
||||||
48
apps/api/src/knowledge/dto/update-entry.dto.ts
Normal file
48
apps/api/src/knowledge/dto/update-entry.dto.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsEnum,
|
||||||
|
IsArray,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { EntryStatus, Visibility } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating a knowledge entry
|
||||||
|
*/
|
||||||
|
export class UpdateEntryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "title must be a string" })
|
||||||
|
@MinLength(1, { message: "title must not be empty" })
|
||||||
|
@MaxLength(500, { message: "title must not exceed 500 characters" })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "content must be a string" })
|
||||||
|
@MinLength(1, { message: "content must not be empty" })
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "summary must be a string" })
|
||||||
|
@MaxLength(1000, { message: "summary must not exceed 1000 characters" })
|
||||||
|
summary?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
|
status?: EntryStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
||||||
|
visibility?: Visibility;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray({ message: "tags must be an array" })
|
||||||
|
@IsString({ each: true, message: "each tag must be a string" })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "changeNote must be a string" })
|
||||||
|
@MaxLength(500, { message: "changeNote must not exceed 500 characters" })
|
||||||
|
changeNote?: string;
|
||||||
|
}
|
||||||
31
apps/api/src/knowledge/dto/update-tag.dto.ts
Normal file
31
apps/api/src/knowledge/dto/update-tag.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
Matches,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating an existing knowledge tag
|
||||||
|
* All fields are optional to support partial updates
|
||||||
|
*/
|
||||||
|
export class UpdateTagDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(100, { message: "name must not exceed 100 characters" })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "color must be a string" })
|
||||||
|
@Matches(/^#[0-9A-Fa-f]{6}$/, {
|
||||||
|
message: "color must be a valid hex color (e.g., #FF5733)",
|
||||||
|
})
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "description must be a string" })
|
||||||
|
@MaxLength(500, { message: "description must not exceed 500 characters" })
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
46
apps/api/src/knowledge/entities/knowledge-entry.entity.ts
Normal file
46
apps/api/src/knowledge/entities/knowledge-entry.entity.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { EntryStatus, Visibility } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knowledge Entry entity
|
||||||
|
* Represents a knowledge base document/page
|
||||||
|
*/
|
||||||
|
export interface KnowledgeEntryEntity {
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
contentHtml: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
status: EntryStatus;
|
||||||
|
visibility: Visibility;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended knowledge entry with tag information
|
||||||
|
*/
|
||||||
|
export interface KnowledgeEntryWithTags extends KnowledgeEntryEntity {
|
||||||
|
tags: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginated list response
|
||||||
|
*/
|
||||||
|
export interface PaginatedEntries {
|
||||||
|
data: KnowledgeEntryWithTags[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
160
apps/api/src/knowledge/knowledge.controller.ts
Normal file
160
apps/api/src/knowledge/knowledge.controller.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import type { AuthUser } from "@mosaic/shared";
|
||||||
|
import { KnowledgeService } from "./knowledge.service";
|
||||||
|
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for knowledge entry endpoints
|
||||||
|
* All endpoints require authentication and enforce workspace isolation
|
||||||
|
*/
|
||||||
|
@ApiTags("knowledge")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("knowledge/entries")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class KnowledgeController {
|
||||||
|
constructor(private readonly knowledgeService: KnowledgeService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge/entries
|
||||||
|
* List all entries in the workspace with pagination and filtering
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: "List knowledge entries" })
|
||||||
|
@ApiQuery({ name: "status", required: false, enum: ["DRAFT", "PUBLISHED", "ARCHIVED"] })
|
||||||
|
@ApiQuery({ name: "tag", required: false, type: String })
|
||||||
|
@ApiQuery({ name: "page", required: false, type: Number })
|
||||||
|
@ApiQuery({ name: "limit", required: false, type: Number })
|
||||||
|
@ApiResponse({ status: 200, description: "Returns paginated list of entries" })
|
||||||
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query() query: EntryQueryDto
|
||||||
|
) {
|
||||||
|
const workspaceId = user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace context required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.knowledgeService.findAll(workspaceId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge/entries/:slug
|
||||||
|
* Get a single entry by slug
|
||||||
|
*/
|
||||||
|
@Get(":slug")
|
||||||
|
@ApiOperation({ summary: "Get knowledge entry by slug" })
|
||||||
|
@ApiParam({ name: "slug", type: String })
|
||||||
|
@ApiResponse({ status: 200, description: "Returns the entry" })
|
||||||
|
@ApiResponse({ status: 404, description: "Entry not found" })
|
||||||
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
|
async findOne(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param("slug") slug: string
|
||||||
|
) {
|
||||||
|
const workspaceId = user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Workspace context required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.knowledgeService.findOne(workspaceId, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/knowledge/entries
|
||||||
|
* Create a new knowledge entry
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: "Create a new knowledge entry" })
|
||||||
|
@ApiResponse({ status: 201, description: "Entry created successfully" })
|
||||||
|
@ApiResponse({ status: 400, description: "Validation error" })
|
||||||
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
|
@ApiResponse({ status: 409, description: "Slug conflict" })
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() createDto: CreateEntryDto
|
||||||
|
) {
|
||||||
|
const workspaceId = user?.workspaceId;
|
||||||
|
const userId = user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.knowledgeService.create(workspaceId, userId, createDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/knowledge/entries/:slug
|
||||||
|
* Update an existing entry
|
||||||
|
*/
|
||||||
|
@Put(":slug")
|
||||||
|
@ApiOperation({ summary: "Update a knowledge entry" })
|
||||||
|
@ApiParam({ name: "slug", type: String })
|
||||||
|
@ApiResponse({ status: 200, description: "Entry updated successfully" })
|
||||||
|
@ApiResponse({ status: 400, description: "Validation error" })
|
||||||
|
@ApiResponse({ status: 404, description: "Entry not found" })
|
||||||
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
|
async update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Body() updateDto: UpdateEntryDto
|
||||||
|
) {
|
||||||
|
const workspaceId = user?.workspaceId;
|
||||||
|
const userId = user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.knowledgeService.update(workspaceId, slug, userId, updateDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/knowledge/entries/:slug
|
||||||
|
* Soft delete an entry (sets status to ARCHIVED)
|
||||||
|
*/
|
||||||
|
@Delete(":slug")
|
||||||
|
@ApiOperation({ summary: "Delete a knowledge entry (soft delete)" })
|
||||||
|
@ApiParam({ name: "slug", type: String })
|
||||||
|
@ApiResponse({ status: 204, description: "Entry archived successfully" })
|
||||||
|
@ApiResponse({ status: 404, description: "Entry not found" })
|
||||||
|
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param("slug") slug: string
|
||||||
|
) {
|
||||||
|
const workspaceId = user?.workspaceId;
|
||||||
|
const userId = user?.id;
|
||||||
|
|
||||||
|
if (!workspaceId || !userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.knowledgeService.remove(workspaceId, slug, userId);
|
||||||
|
return { message: "Entry archived successfully" };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/knowledge/knowledge.module.ts
Normal file
13
apps/api/src/knowledge/knowledge.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { KnowledgeService } from "./knowledge.service";
|
||||||
|
import { KnowledgeController } from "./knowledge.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [KnowledgeController],
|
||||||
|
providers: [KnowledgeService],
|
||||||
|
exports: [KnowledgeService],
|
||||||
|
})
|
||||||
|
export class KnowledgeModule {}
|
||||||
540
apps/api/src/knowledge/knowledge.service.ts
Normal file
540
apps/api/src/knowledge/knowledge.service.ts
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { EntryStatus } from "@prisma/client";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import slugify from "slugify";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
||||||
|
import type {
|
||||||
|
KnowledgeEntryWithTags,
|
||||||
|
PaginatedEntries,
|
||||||
|
} from "./entities/knowledge-entry.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing knowledge entries
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class KnowledgeService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {
|
||||||
|
// Configure marked for security and consistency
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true, // GitHub Flavored Markdown
|
||||||
|
breaks: false,
|
||||||
|
pedantic: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries for a workspace (paginated and filterable)
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
workspaceId: string,
|
||||||
|
query: EntryQueryDto
|
||||||
|
): Promise<PaginatedEntries> {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const limit = query.limit || 20;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: any = {
|
||||||
|
workspaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
where.status = query.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.tag) {
|
||||||
|
where.tags = {
|
||||||
|
some: {
|
||||||
|
tag: {
|
||||||
|
slug: query.tag,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 a single entry by slug
|
||||||
|
*/
|
||||||
|
async findOne(
|
||||||
|
workspaceId: string,
|
||||||
|
slug: string
|
||||||
|
): Promise<KnowledgeEntryWithTags> {
|
||||||
|
const entry = await this.prisma.knowledgeEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Knowledge entry with slug "${slug}" not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new entry
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
createDto: CreateEntryDto
|
||||||
|
): Promise<KnowledgeEntryWithTags> {
|
||||||
|
// Generate slug from title
|
||||||
|
const baseSlug = this.generateSlug(createDto.title);
|
||||||
|
const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);
|
||||||
|
|
||||||
|
// Render markdown to HTML
|
||||||
|
const contentHtml = await marked.parse(createDto.content);
|
||||||
|
|
||||||
|
// Use transaction to ensure atomicity
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Create entry
|
||||||
|
const entry = await tx.knowledgeEntry.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
title: createDto.title,
|
||||||
|
content: createDto.content,
|
||||||
|
contentHtml,
|
||||||
|
summary: createDto.summary,
|
||||||
|
status: createDto.status || EntryStatus.DRAFT,
|
||||||
|
visibility: createDto.visibility || "PRIVATE",
|
||||||
|
createdBy: userId,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create initial version
|
||||||
|
await tx.knowledgeEntryVersion.create({
|
||||||
|
data: {
|
||||||
|
entryId: entry.id,
|
||||||
|
version: 1,
|
||||||
|
title: entry.title,
|
||||||
|
content: entry.content,
|
||||||
|
summary: entry.summary,
|
||||||
|
createdBy: userId,
|
||||||
|
changeNote: createDto.changeNote || "Initial version",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle tags if provided
|
||||||
|
if (createDto.tags && createDto.tags.length > 0) {
|
||||||
|
await this.syncTags(tx, workspaceId, entry.id, createDto.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch with tags
|
||||||
|
return tx.knowledgeEntry.findUnique({
|
||||||
|
where: { id: entry.id },
|
||||||
|
include: {
|
||||||
|
tags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Failed to create entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
workspaceId: result.workspaceId,
|
||||||
|
slug: result.slug,
|
||||||
|
title: result.title,
|
||||||
|
content: result.content,
|
||||||
|
contentHtml: result.contentHtml,
|
||||||
|
summary: result.summary,
|
||||||
|
status: result.status,
|
||||||
|
visibility: result.visibility,
|
||||||
|
createdAt: result.createdAt,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
createdBy: result.createdBy,
|
||||||
|
updatedBy: result.updatedBy,
|
||||||
|
tags: result.tags.map((et) => ({
|
||||||
|
id: et.tag.id,
|
||||||
|
name: et.tag.name,
|
||||||
|
slug: et.tag.slug,
|
||||||
|
color: et.tag.color,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an entry
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
workspaceId: string,
|
||||||
|
slug: string,
|
||||||
|
userId: string,
|
||||||
|
updateDto: UpdateEntryDto
|
||||||
|
): Promise<KnowledgeEntryWithTags> {
|
||||||
|
// Find existing entry
|
||||||
|
const existing = await this.prisma.knowledgeEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
orderBy: {
|
||||||
|
version: "desc",
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Knowledge entry with slug "${slug}" not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If title is being updated, generate new slug if needed
|
||||||
|
let newSlug = slug;
|
||||||
|
if (updateDto.title && updateDto.title !== existing.title) {
|
||||||
|
const baseSlug = this.generateSlug(updateDto.title);
|
||||||
|
if (baseSlug !== slug) {
|
||||||
|
newSlug = await this.ensureUniqueSlug(workspaceId, baseSlug, slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render markdown if content is updated
|
||||||
|
let contentHtml = existing.contentHtml;
|
||||||
|
if (updateDto.content) {
|
||||||
|
contentHtml = await marked.parse(updateDto.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use transaction to ensure atomicity
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// Update entry
|
||||||
|
const entry = await tx.knowledgeEntry.update({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
slug: newSlug,
|
||||||
|
title: updateDto.title,
|
||||||
|
content: updateDto.content,
|
||||||
|
contentHtml,
|
||||||
|
summary: updateDto.summary,
|
||||||
|
status: updateDto.status,
|
||||||
|
visibility: updateDto.visibility,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new version if content or title changed
|
||||||
|
if (updateDto.title || updateDto.content) {
|
||||||
|
const latestVersion = existing.versions[0];
|
||||||
|
const nextVersion = latestVersion ? latestVersion.version + 1 : 1;
|
||||||
|
|
||||||
|
await tx.knowledgeEntryVersion.create({
|
||||||
|
data: {
|
||||||
|
entryId: entry.id,
|
||||||
|
version: nextVersion,
|
||||||
|
title: entry.title,
|
||||||
|
content: entry.content,
|
||||||
|
summary: entry.summary,
|
||||||
|
createdBy: userId,
|
||||||
|
changeNote: updateDto.changeNote || `Update version ${nextVersion}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tags if provided
|
||||||
|
if (updateDto.tags !== undefined) {
|
||||||
|
await this.syncTags(tx, workspaceId, entry.id, updateDto.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch with tags
|
||||||
|
return tx.knowledgeEntry.findUnique({
|
||||||
|
where: { id: entry.id },
|
||||||
|
include: {
|
||||||
|
tags: {
|
||||||
|
include: {
|
||||||
|
tag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error("Failed to update entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result.id,
|
||||||
|
workspaceId: result.workspaceId,
|
||||||
|
slug: result.slug,
|
||||||
|
title: result.title,
|
||||||
|
content: result.content,
|
||||||
|
contentHtml: result.contentHtml,
|
||||||
|
summary: result.summary,
|
||||||
|
status: result.status,
|
||||||
|
visibility: result.visibility,
|
||||||
|
createdAt: result.createdAt,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
createdBy: result.createdBy,
|
||||||
|
updatedBy: result.updatedBy,
|
||||||
|
tags: result.tags.map((et) => ({
|
||||||
|
id: et.tag.id,
|
||||||
|
name: et.tag.name,
|
||||||
|
slug: et.tag.slug,
|
||||||
|
color: et.tag.color,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entry (soft delete by setting status to ARCHIVED)
|
||||||
|
*/
|
||||||
|
async remove(workspaceId: string, slug: string, userId: string): Promise<void> {
|
||||||
|
const entry = await this.prisma.knowledgeEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Knowledge entry with slug "${slug}" not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.knowledgeEntry.update({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: EntryStatus.ARCHIVED,
|
||||||
|
updatedBy: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a URL-friendly slug from a title
|
||||||
|
*/
|
||||||
|
private generateSlug(title: string): string {
|
||||||
|
return slugify(title, {
|
||||||
|
lower: true,
|
||||||
|
strict: true,
|
||||||
|
trim: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure slug is unique by appending a number if needed
|
||||||
|
*/
|
||||||
|
private async ensureUniqueSlug(
|
||||||
|
workspaceId: string,
|
||||||
|
baseSlug: string,
|
||||||
|
currentSlug?: string
|
||||||
|
): Promise<string> {
|
||||||
|
let slug = baseSlug;
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Check if slug exists (excluding current entry if updating)
|
||||||
|
const existing = await this.prisma.knowledgeEntry.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Slug is available
|
||||||
|
if (!existing) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the current entry being updated, keep the slug
|
||||||
|
if (currentSlug && existing.slug === currentSlug) {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try next variation
|
||||||
|
slug = `${baseSlug}-${counter}`;
|
||||||
|
counter++;
|
||||||
|
|
||||||
|
// Safety limit to prevent infinite loops
|
||||||
|
if (counter > 1000) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"Unable to generate unique slug after 1000 attempts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync tags for an entry (create missing tags, update associations)
|
||||||
|
*/
|
||||||
|
private async syncTags(
|
||||||
|
tx: any,
|
||||||
|
workspaceId: string,
|
||||||
|
entryId: string,
|
||||||
|
tagNames: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
// Remove all existing tag associations
|
||||||
|
await tx.knowledgeEntryTag.deleteMany({
|
||||||
|
where: { entryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no tags provided, we're done
|
||||||
|
if (tagNames.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create tags
|
||||||
|
const tags = await Promise.all(
|
||||||
|
tagNames.map(async (name) => {
|
||||||
|
const tagSlug = this.generateSlug(name);
|
||||||
|
|
||||||
|
// Try to find existing tag
|
||||||
|
let tag = await tx.knowledgeTag.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug: tagSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create if doesn't exist
|
||||||
|
if (!tag) {
|
||||||
|
tag = await tx.knowledgeTag.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
name,
|
||||||
|
slug: tagSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create tag associations
|
||||||
|
await Promise.all(
|
||||||
|
tags.map((tag) =>
|
||||||
|
tx.knowledgeEntryTag.create({
|
||||||
|
data: {
|
||||||
|
entryId,
|
||||||
|
tagId: tag.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
261
apps/api/src/knowledge/tags.controller.spec.ts
Normal file
261
apps/api/src/knowledge/tags.controller.spec.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { TagsController } from "./tags.controller";
|
||||||
|
import { TagsService } from "./tags.service";
|
||||||
|
import { UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import type { CreateTagDto, UpdateTagDto } from "./dto";
|
||||||
|
|
||||||
|
describe("TagsController", () => {
|
||||||
|
let controller: TagsController;
|
||||||
|
let service: TagsService;
|
||||||
|
|
||||||
|
const workspaceId = "workspace-123";
|
||||||
|
const userId = "user-123";
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
user: {
|
||||||
|
id: userId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTag = {
|
||||||
|
id: "tag-123",
|
||||||
|
workspaceId,
|
||||||
|
name: "Architecture",
|
||||||
|
slug: "architecture",
|
||||||
|
color: "#FF5733",
|
||||||
|
description: "Architecture related topics",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTagsService = {
|
||||||
|
create: vi.fn(),
|
||||||
|
findAll: vi.fn(),
|
||||||
|
findOne: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
getEntriesWithTag: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthGuard = {
|
||||||
|
canActivate: vi.fn().mockReturnValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [TagsController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TagsService,
|
||||||
|
useValue: mockTagsService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(mockAuthGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<TagsController>(TagsController);
|
||||||
|
service = module.get<TagsService>(TagsService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create a tag", async () => {
|
||||||
|
const createDto: CreateTagDto = {
|
||||||
|
name: "Architecture",
|
||||||
|
color: "#FF5733",
|
||||||
|
description: "Architecture related topics",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTagsService.create.mockResolvedValue(mockTag);
|
||||||
|
|
||||||
|
const result = await controller.create(createDto, mockRequest);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTag);
|
||||||
|
expect(mockTagsService.create).toHaveBeenCalledWith(
|
||||||
|
workspaceId,
|
||||||
|
createDto
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if no workspaceId", async () => {
|
||||||
|
const createDto: CreateTagDto = {
|
||||||
|
name: "Architecture",
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestWithoutWorkspace = {
|
||||||
|
user: { id: userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.create(createDto, requestWithoutWorkspace)
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should return all tags", async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ ...mockTag, _count: { entries: 5 } },
|
||||||
|
{
|
||||||
|
id: "tag-456",
|
||||||
|
workspaceId,
|
||||||
|
name: "Design",
|
||||||
|
slug: "design",
|
||||||
|
color: "#00FF00",
|
||||||
|
description: null,
|
||||||
|
_count: { entries: 3 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockTagsService.findAll.mockResolvedValue(mockTags);
|
||||||
|
|
||||||
|
const result = await controller.findAll(mockRequest);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTags);
|
||||||
|
expect(mockTagsService.findAll).toHaveBeenCalledWith(workspaceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if no workspaceId", async () => {
|
||||||
|
const requestWithoutWorkspace = {
|
||||||
|
user: { id: userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.findAll(requestWithoutWorkspace)
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOne", () => {
|
||||||
|
it("should return a tag by slug", async () => {
|
||||||
|
const mockTagWithCount = { ...mockTag, _count: { entries: 5 } };
|
||||||
|
mockTagsService.findOne.mockResolvedValue(mockTagWithCount);
|
||||||
|
|
||||||
|
const result = await controller.findOne("architecture", mockRequest);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTagWithCount);
|
||||||
|
expect(mockTagsService.findOne).toHaveBeenCalledWith(
|
||||||
|
"architecture",
|
||||||
|
workspaceId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if no workspaceId", async () => {
|
||||||
|
const requestWithoutWorkspace = {
|
||||||
|
user: { id: userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.findOne("architecture", requestWithoutWorkspace)
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should update a tag", async () => {
|
||||||
|
const updateDto: UpdateTagDto = {
|
||||||
|
name: "Updated Architecture",
|
||||||
|
color: "#0000FF",
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedTag = {
|
||||||
|
...mockTag,
|
||||||
|
name: "Updated Architecture",
|
||||||
|
color: "#0000FF",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTagsService.update.mockResolvedValue(updatedTag);
|
||||||
|
|
||||||
|
const result = await controller.update(
|
||||||
|
"architecture",
|
||||||
|
updateDto,
|
||||||
|
mockRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedTag);
|
||||||
|
expect(mockTagsService.update).toHaveBeenCalledWith(
|
||||||
|
"architecture",
|
||||||
|
workspaceId,
|
||||||
|
updateDto
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if no workspaceId", async () => {
|
||||||
|
const updateDto: UpdateTagDto = {
|
||||||
|
name: "Updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestWithoutWorkspace = {
|
||||||
|
user: { id: userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.update("architecture", updateDto, requestWithoutWorkspace)
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should delete a tag", async () => {
|
||||||
|
mockTagsService.remove.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await controller.remove("architecture", mockRequest);
|
||||||
|
|
||||||
|
expect(mockTagsService.remove).toHaveBeenCalledWith(
|
||||||
|
"architecture",
|
||||||
|
workspaceId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if no workspaceId", async () => {
|
||||||
|
const requestWithoutWorkspace = {
|
||||||
|
user: { id: userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.remove("architecture", requestWithoutWorkspace)
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEntries", () => {
|
||||||
|
it("should return entries with the tag", async () => {
|
||||||
|
const mockEntries = [
|
||||||
|
{
|
||||||
|
id: "entry-1",
|
||||||
|
slug: "entry-one",
|
||||||
|
title: "Entry One",
|
||||||
|
summary: "Summary",
|
||||||
|
status: "PUBLISHED",
|
||||||
|
visibility: "WORKSPACE",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockTagsService.getEntriesWithTag.mockResolvedValue(mockEntries);
|
||||||
|
|
||||||
|
const result = await controller.getEntries("architecture", mockRequest);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockEntries);
|
||||||
|
expect(mockTagsService.getEntriesWithTag).toHaveBeenCalledWith(
|
||||||
|
"architecture",
|
||||||
|
workspaceId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw UnauthorizedException if no workspaceId", async () => {
|
||||||
|
const requestWithoutWorkspace = {
|
||||||
|
user: { id: userId },
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.getEntries("architecture", requestWithoutWorkspace)
|
||||||
|
).rejects.toThrow(UnauthorizedException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
181
apps/api/src/knowledge/tags.controller.ts
Normal file
181
apps/api/src/knowledge/tags.controller.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
UnauthorizedException,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { TagsService } from "./tags.service";
|
||||||
|
import { CreateTagDto, UpdateTagDto } from "./dto";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for knowledge tag endpoints
|
||||||
|
* All endpoints require authentication and operate within workspace context
|
||||||
|
*/
|
||||||
|
@Controller("knowledge/tags")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class TagsController {
|
||||||
|
constructor(private readonly tagsService: TagsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/knowledge/tags
|
||||||
|
* Create a new tag
|
||||||
|
*/
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Body() createTagDto: CreateTagDto,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}> {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagsService.create(workspaceId, createTagDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge/tags
|
||||||
|
* List all tags in the workspace
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async findAll(@Request() req: any): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
_count: {
|
||||||
|
entries: number;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagsService.findAll(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge/tags/:slug
|
||||||
|
* Get a single tag by slug
|
||||||
|
*/
|
||||||
|
@Get(":slug")
|
||||||
|
async findOne(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
_count: {
|
||||||
|
entries: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagsService.findOne(slug, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/knowledge/tags/:slug
|
||||||
|
* Update a tag
|
||||||
|
*/
|
||||||
|
@Put(":slug")
|
||||||
|
async update(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Body() updateTagDto: UpdateTagDto,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}> {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagsService.update(slug, workspaceId, updateTagDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/knowledge/tags/:slug
|
||||||
|
* Delete a tag
|
||||||
|
*/
|
||||||
|
@Delete(":slug")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<void> {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tagsService.remove(slug, workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/knowledge/tags/:slug/entries
|
||||||
|
* Get all entries with this tag
|
||||||
|
*/
|
||||||
|
@Get(":slug/entries")
|
||||||
|
async getEntries(
|
||||||
|
@Param("slug") slug: string,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
status: string;
|
||||||
|
visibility: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const workspaceId = req.user?.workspaceId;
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.tagsService.getEntriesWithTag(slug, workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
406
apps/api/src/knowledge/tags.service.spec.ts
Normal file
406
apps/api/src/knowledge/tags.service.spec.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { TagsService } from "./tags.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import {
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { CreateTagDto, UpdateTagDto } from "./dto";
|
||||||
|
|
||||||
|
describe("TagsService", () => {
|
||||||
|
let service: TagsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const workspaceId = "workspace-123";
|
||||||
|
const userId = "user-123";
|
||||||
|
|
||||||
|
const mockTag = {
|
||||||
|
id: "tag-123",
|
||||||
|
workspaceId,
|
||||||
|
name: "Architecture",
|
||||||
|
slug: "architecture",
|
||||||
|
color: "#FF5733",
|
||||||
|
description: "Architecture related topics",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
knowledgeTag: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
knowledgeEntry: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TagsService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<TagsService>(TagsService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create a tag with auto-generated slug", async () => {
|
||||||
|
const createDto: CreateTagDto = {
|
||||||
|
name: "Architecture",
|
||||||
|
color: "#FF5733",
|
||||||
|
description: "Architecture related topics",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.knowledgeTag.create.mockResolvedValue(mockTag);
|
||||||
|
|
||||||
|
const result = await service.create(workspaceId, createDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTag);
|
||||||
|
expect(mockPrismaService.knowledgeTag.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
name: "Architecture",
|
||||||
|
slug: "architecture",
|
||||||
|
color: "#FF5733",
|
||||||
|
description: "Architecture related topics",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
workspaceId: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
color: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a tag with provided slug", async () => {
|
||||||
|
const createDto: CreateTagDto = {
|
||||||
|
name: "Architecture",
|
||||||
|
slug: "arch",
|
||||||
|
color: "#FF5733",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.knowledgeTag.create.mockResolvedValue({
|
||||||
|
...mockTag,
|
||||||
|
slug: "arch",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.create(workspaceId, createDto);
|
||||||
|
|
||||||
|
expect(result.slug).toBe("arch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ConflictException if slug already exists", async () => {
|
||||||
|
const createDto: CreateTagDto = {
|
||||||
|
name: "Architecture",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(mockTag);
|
||||||
|
|
||||||
|
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
|
||||||
|
ConflictException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException for invalid slug format", async () => {
|
||||||
|
const createDto: CreateTagDto = {
|
||||||
|
name: "Architecture",
|
||||||
|
slug: "Invalid_Slug!",
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.create(workspaceId, createDto)).rejects.toThrow(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate slug from name with spaces and special chars", async () => {
|
||||||
|
const createDto: CreateTagDto = {
|
||||||
|
name: "My Tag Name!",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.knowledgeTag.create.mockImplementation(
|
||||||
|
async ({ data }: any) => ({
|
||||||
|
...mockTag,
|
||||||
|
slug: data.slug,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.create(workspaceId, createDto);
|
||||||
|
|
||||||
|
expect(result.slug).toBe("my-tag-name");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should return all tags for workspace", async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ ...mockTag, _count: { entries: 5 } },
|
||||||
|
{
|
||||||
|
id: "tag-456",
|
||||||
|
workspaceId,
|
||||||
|
name: "Design",
|
||||||
|
slug: "design",
|
||||||
|
color: "#00FF00",
|
||||||
|
description: null,
|
||||||
|
_count: { entries: 3 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findMany.mockResolvedValue(mockTags);
|
||||||
|
|
||||||
|
const result = await service.findAll(workspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTags);
|
||||||
|
expect(mockPrismaService.knowledgeTag.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { workspaceId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { entries: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOne", () => {
|
||||||
|
it("should return a tag by slug", async () => {
|
||||||
|
const mockTagWithCount = { ...mockTag, _count: { entries: 5 } };
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(
|
||||||
|
mockTagWithCount
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.findOne("architecture", workspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTagWithCount);
|
||||||
|
expect(mockPrismaService.knowledgeTag.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug: "architecture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { entries: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if tag not found", async () => {
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.findOne("nonexistent", workspaceId)
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
it("should update a tag", async () => {
|
||||||
|
const updateDto: UpdateTagDto = {
|
||||||
|
name: "Updated Architecture",
|
||||||
|
color: "#0000FF",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique
|
||||||
|
.mockResolvedValueOnce({ ...mockTag, _count: { entries: 5 } })
|
||||||
|
.mockResolvedValueOnce(null); // Check for slug conflict
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.update.mockResolvedValue({
|
||||||
|
...mockTag,
|
||||||
|
name: "Updated Architecture",
|
||||||
|
slug: "updated-architecture",
|
||||||
|
color: "#0000FF",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.update("architecture", workspaceId, updateDto);
|
||||||
|
|
||||||
|
expect(result.name).toBe("Updated Architecture");
|
||||||
|
expect(result.slug).toBe("updated-architecture");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if tag not found", async () => {
|
||||||
|
const updateDto: UpdateTagDto = {
|
||||||
|
name: "Updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update("nonexistent", workspaceId, updateDto)
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ConflictException if new slug conflicts", async () => {
|
||||||
|
const updateDto: UpdateTagDto = {
|
||||||
|
name: "Design", // Will generate "design" slug
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique
|
||||||
|
.mockResolvedValueOnce({ ...mockTag, _count: { entries: 5 } })
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
// Conflict check
|
||||||
|
id: "other-tag",
|
||||||
|
slug: "design",
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update("architecture", workspaceId, updateDto)
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should delete a tag", async () => {
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue({
|
||||||
|
...mockTag,
|
||||||
|
_count: { entries: 5 },
|
||||||
|
});
|
||||||
|
mockPrismaService.knowledgeTag.delete.mockResolvedValue(mockTag);
|
||||||
|
|
||||||
|
await service.remove("architecture", workspaceId);
|
||||||
|
|
||||||
|
expect(mockPrismaService.knowledgeTag.delete).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug: "architecture",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if tag not found", async () => {
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.remove("nonexistent", workspaceId)
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEntriesWithTag", () => {
|
||||||
|
it("should return entries with the tag", async () => {
|
||||||
|
const mockEntries = [
|
||||||
|
{
|
||||||
|
id: "entry-1",
|
||||||
|
slug: "entry-one",
|
||||||
|
title: "Entry One",
|
||||||
|
summary: "Summary",
|
||||||
|
status: "PUBLISHED",
|
||||||
|
visibility: "WORKSPACE",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue({
|
||||||
|
...mockTag,
|
||||||
|
_count: { entries: 1 },
|
||||||
|
});
|
||||||
|
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue(mockEntries);
|
||||||
|
|
||||||
|
const result = await service.getEntriesWithTag("architecture", workspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockEntries);
|
||||||
|
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
tags: {
|
||||||
|
some: {
|
||||||
|
tagId: mockTag.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
summary: true,
|
||||||
|
status: true,
|
||||||
|
visibility: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findOrCreateTags", () => {
|
||||||
|
it("should find existing tags", async () => {
|
||||||
|
const slugs = ["architecture", "design"];
|
||||||
|
const mockTags = [
|
||||||
|
{ id: "tag-1", slug: "architecture", name: "Architecture" },
|
||||||
|
{ id: "tag-2", slug: "design", name: "Design" },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique
|
||||||
|
.mockResolvedValueOnce(mockTags[0])
|
||||||
|
.mockResolvedValueOnce(mockTags[1]);
|
||||||
|
|
||||||
|
const result = await service.findOrCreateTags(workspaceId, slugs, false);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTags);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should auto-create missing tags when autoCreate is true", async () => {
|
||||||
|
const slugs = ["architecture", "new-tag"];
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "tag-1",
|
||||||
|
slug: "architecture",
|
||||||
|
name: "Architecture",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(null) // new-tag doesn't exist
|
||||||
|
.mockResolvedValueOnce(null); // Check for conflict during creation
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.create.mockResolvedValue({
|
||||||
|
id: "tag-2",
|
||||||
|
workspaceId,
|
||||||
|
slug: "new-tag",
|
||||||
|
name: "New Tag",
|
||||||
|
color: null,
|
||||||
|
description: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.findOrCreateTags(workspaceId, slugs, true);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[1].slug).toBe("new-tag");
|
||||||
|
expect(result[1].name).toBe("New Tag");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when tag not found and autoCreate is false", async () => {
|
||||||
|
const slugs = ["nonexistent"];
|
||||||
|
|
||||||
|
mockPrismaService.knowledgeTag.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.findOrCreateTags(workspaceId, slugs, false)
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
390
apps/api/src/knowledge/tags.service.ts
Normal file
390
apps/api/src/knowledge/tags.service.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
BadRequestException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { CreateTagDto, UpdateTagDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing knowledge tags
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TagsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a URL-friendly slug from a tag name
|
||||||
|
*/
|
||||||
|
private generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
|
||||||
|
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tag
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
workspaceId: string,
|
||||||
|
createTagDto: CreateTagDto
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}> {
|
||||||
|
// Generate slug if not provided
|
||||||
|
const slug = createTagDto.slug || this.generateSlug(createTagDto.name);
|
||||||
|
|
||||||
|
// Validate slug format if provided
|
||||||
|
if (createTagDto.slug) {
|
||||||
|
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
if (!slugPattern.test(slug)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Invalid slug format. Must be lowercase, alphanumeric, and may contain hyphens."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if slug already exists in workspace
|
||||||
|
const existingTag = await this.prisma.knowledgeTag.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTag) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Tag with slug '${slug}' already exists in this workspace`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tag
|
||||||
|
const tag = await this.prisma.knowledgeTag.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
name: createTagDto.name,
|
||||||
|
slug,
|
||||||
|
color: createTagDto.color || null,
|
||||||
|
description: createTagDto.description || null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
workspaceId: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
color: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all tags for a workspace
|
||||||
|
*/
|
||||||
|
async findAll(workspaceId: string): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
_count: {
|
||||||
|
entries: number;
|
||||||
|
};
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const tags = await this.prisma.knowledgeTag.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
entries: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
name: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single tag by slug
|
||||||
|
*/
|
||||||
|
async findOne(
|
||||||
|
slug: string,
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
_count: {
|
||||||
|
entries: number;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const tag = await this.prisma.knowledgeTag.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
entries: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Tag with slug '${slug}' not found in this workspace`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a tag
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
slug: string,
|
||||||
|
workspaceId: string,
|
||||||
|
updateTagDto: UpdateTagDto
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
workspaceId: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}> {
|
||||||
|
// Verify tag exists
|
||||||
|
await this.findOne(slug, workspaceId);
|
||||||
|
|
||||||
|
// If name is being updated, regenerate slug
|
||||||
|
let newSlug = slug;
|
||||||
|
if (updateTagDto.name) {
|
||||||
|
newSlug = this.generateSlug(updateTagDto.name);
|
||||||
|
|
||||||
|
// If slug changed, check for conflicts
|
||||||
|
if (newSlug !== slug) {
|
||||||
|
const existingTag = await this.prisma.knowledgeTag.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug: newSlug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTag) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Tag with slug '${newSlug}' already exists in this workspace`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tag
|
||||||
|
const tag = await this.prisma.knowledgeTag.update({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
name: updateTagDto.name,
|
||||||
|
slug: newSlug,
|
||||||
|
color: updateTagDto.color,
|
||||||
|
description: updateTagDto.description,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
workspaceId: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
color: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a tag
|
||||||
|
*/
|
||||||
|
async remove(slug: string, workspaceId: string): Promise<void> {
|
||||||
|
// Verify tag exists
|
||||||
|
await this.findOne(slug, workspaceId);
|
||||||
|
|
||||||
|
// Delete tag (cascade will remove entry associations)
|
||||||
|
await this.prisma.knowledgeTag.delete({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all entries with a specific tag
|
||||||
|
*/
|
||||||
|
async getEntriesWithTag(
|
||||||
|
slug: string,
|
||||||
|
workspaceId: string
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
summary: string | null;
|
||||||
|
status: string;
|
||||||
|
visibility: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
// Verify tag exists
|
||||||
|
const tag = await this.findOne(slug, workspaceId);
|
||||||
|
|
||||||
|
// Get entries with this tag
|
||||||
|
const entries = await this.prisma.knowledgeEntry.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
tags: {
|
||||||
|
some: {
|
||||||
|
tagId: tag.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
summary: true,
|
||||||
|
status: true,
|
||||||
|
visibility: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find or create tags by slugs (for entry creation/update)
|
||||||
|
* Returns existing tags and creates new ones if autoCreate is true
|
||||||
|
*/
|
||||||
|
async findOrCreateTags(
|
||||||
|
workspaceId: string,
|
||||||
|
tagSlugs: string[],
|
||||||
|
autoCreate: boolean = false
|
||||||
|
): Promise<Array<{ id: string; slug: string; name: string }>> {
|
||||||
|
const uniqueSlugs = [...new Set(tagSlugs)];
|
||||||
|
const tags: Array<{ id: string; slug: string; name: string }> = [];
|
||||||
|
|
||||||
|
for (const slug of uniqueSlugs) {
|
||||||
|
try {
|
||||||
|
const tag = await this.prisma.knowledgeTag.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
tags.push(tag);
|
||||||
|
} else if (autoCreate) {
|
||||||
|
// Auto-create tag from slug
|
||||||
|
const name = slug
|
||||||
|
.split("-")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const newTag = await this.create(workspaceId, {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
tags.push({
|
||||||
|
id: newTag.id,
|
||||||
|
slug: newTag.slug,
|
||||||
|
name: newTag.name,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`Tag with slug '${slug}' not found in this workspace`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If it's a conflict error during auto-create, try to fetch again
|
||||||
|
if (
|
||||||
|
autoCreate &&
|
||||||
|
error instanceof ConflictException
|
||||||
|
) {
|
||||||
|
const tag = await this.prisma.knowledgeTag.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_slug: {
|
||||||
|
workspaceId,
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
tags.push(tag);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
1182
docs/design/agent-orchestration.md
Normal file
1182
docs/design/agent-orchestration.md
Normal file
File diff suppressed because it is too large
Load Diff
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -56,6 +56,9 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.19.2
|
specifier: ^6.19.2
|
||||||
version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
|
'@types/marked':
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.4.17
|
specifier: ^1.4.17
|
||||||
version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0))
|
version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.0.18(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0))
|
||||||
@@ -65,12 +68,18 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.3
|
specifier: ^0.14.3
|
||||||
version: 0.14.3
|
version: 0.14.3
|
||||||
|
marked:
|
||||||
|
specifier: ^17.0.1
|
||||||
|
version: 17.0.1
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
slugify:
|
||||||
|
specifier: ^1.6.6
|
||||||
|
version: 1.6.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@better-auth/cli':
|
'@better-auth/cli':
|
||||||
specifier: ^1.4.17
|
specifier: ^1.4.17
|
||||||
@@ -1584,6 +1593,10 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/marked@6.0.0':
|
||||||
|
resolution: {integrity: sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==}
|
||||||
|
deprecated: This is a stub types definition. marked provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/node@22.19.7':
|
'@types/node@22.19.7':
|
||||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
||||||
|
|
||||||
@@ -3042,6 +3055,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
marked@17.0.1:
|
||||||
|
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3642,6 +3660,10 @@ packages:
|
|||||||
sisteransi@1.0.5:
|
sisteransi@1.0.5:
|
||||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
|
slugify@1.6.6:
|
||||||
|
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -5518,6 +5540,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/marked@6.0.0':
|
||||||
|
dependencies:
|
||||||
|
marked: 17.0.1
|
||||||
|
|
||||||
'@types/node@22.19.7':
|
'@types/node@22.19.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -7011,6 +7037,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
|
|
||||||
|
marked@17.0.1: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
@@ -7668,6 +7696,8 @@ snapshots:
|
|||||||
|
|
||||||
sisteransi@1.0.5: {}
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
|
slugify@1.6.6: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
|
|||||||
Reference in New Issue
Block a user