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

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