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:
Jason Woltje
2026-01-29 16:13:40 -06:00
parent 244e50c806
commit f07f04404d
18 changed files with 3413 additions and 1 deletions

View File

@@ -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],

View 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;
}

View 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;
}

View 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;
}

View 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";

View 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;
}

View 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;
}

View 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;
};
}

View 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" };
}
}

View 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 {}

View 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,
},
})
)
);
}
}

View 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);
});
});
});

View 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);
}
}

View 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);
});
});
});

View 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;
}
}