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/platform-express": "^11.1.12",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@types/marked": "^6.0.0",
|
||||
"better-auth": "^1.4.17",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"marked": "^17.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"slugify": "^1.6.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@better-auth/cli": "^1.4.17",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DomainsModule } from "./domains/domains.module";
|
||||
import { IdeasModule } from "./ideas/ideas.module";
|
||||
import { WidgetsModule } from "./widgets/widgets.module";
|
||||
import { LayoutsModule } from "./layouts/layouts.module";
|
||||
import { KnowledgeModule } from "./knowledge/knowledge.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -26,6 +27,7 @@ import { LayoutsModule } from "./layouts/layouts.module";
|
||||
IdeasModule,
|
||||
WidgetsModule,
|
||||
LayoutsModule,
|
||||
KnowledgeModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user