fix(knowledge): fix type safety issues in entry CRUD API (KNOW-002)

- Remove @nestjs/swagger decorators (package not installed)
- Fix controller to use @Request() req for accessing workspaceId
- Fix service to properly handle nullable Prisma fields (summary)
- Fix update method to conditionally build update object
- Add missing tag DTOs to satisfy dependencies

Resolves compilation errors and ensures type safety.
This commit is contained in:
Jason Woltje
2026-01-29 16:16:49 -06:00
parent 4729f964f1
commit 81d426453a
4 changed files with 42 additions and 76 deletions

View File

@@ -15,16 +15,6 @@ export class CreateTagDto {
@MaxLength(100, { message: "name must not exceed 100 characters" }) @MaxLength(100, { message: "name must not exceed 100 characters" })
name!: string; 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() @IsOptional()
@IsString({ message: "color must be a string" }) @IsString({ message: "color must be a string" })
@Matches(/^#[0-9A-Fa-f]{6}$/, { @Matches(/^#[0-9A-Fa-f]{6}$/, {

View File

@@ -7,8 +7,7 @@ import {
} from "class-validator"; } from "class-validator";
/** /**
* DTO for updating an existing knowledge tag * DTO for updating a knowledge tag
* All fields are optional to support partial updates
*/ */
export class UpdateTagDto { export class UpdateTagDto {
@IsOptional() @IsOptional()

View File

@@ -8,28 +8,17 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
Request,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } 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 { KnowledgeService } from "./knowledge.service";
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto"; import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
/** /**
* Controller for knowledge entry endpoints * Controller for knowledge entry endpoints
* All endpoints require authentication and enforce workspace isolation * All endpoints require authentication and enforce workspace isolation
*/ */
@ApiTags("knowledge")
@ApiBearerAuth()
@Controller("knowledge/entries") @Controller("knowledge/entries")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
export class KnowledgeController { export class KnowledgeController {
@@ -40,18 +29,11 @@ export class KnowledgeController {
* List all entries in the workspace with pagination and filtering * List all entries in the workspace with pagination and filtering
*/ */
@Get() @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( async findAll(
@CurrentUser() user: AuthUser, @Request() req: any,
@Query() query: EntryQueryDto @Query() query: EntryQueryDto
) { ) {
const workspaceId = user?.workspaceId; const workspaceId = req.user?.workspaceId;
if (!workspaceId) { if (!workspaceId) {
throw new UnauthorizedException("Workspace context required"); throw new UnauthorizedException("Workspace context required");
@@ -65,16 +47,11 @@ export class KnowledgeController {
* Get a single entry by slug * Get a single entry by slug
*/ */
@Get(":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( async findOne(
@CurrentUser() user: AuthUser, @Request() req: any,
@Param("slug") slug: string @Param("slug") slug: string
) { ) {
const workspaceId = user?.workspaceId; const workspaceId = req.user?.workspaceId;
if (!workspaceId) { if (!workspaceId) {
throw new UnauthorizedException("Workspace context required"); throw new UnauthorizedException("Workspace context required");
@@ -88,17 +65,12 @@ export class KnowledgeController {
* Create a new knowledge entry * Create a new knowledge entry
*/ */
@Post() @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( async create(
@CurrentUser() user: AuthUser, @Request() req: any,
@Body() createDto: CreateEntryDto @Body() createDto: CreateEntryDto
) { ) {
const workspaceId = user?.workspaceId; const workspaceId = req.user?.workspaceId;
const userId = user?.id; const userId = req.user?.id;
if (!workspaceId || !userId) { if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required"); throw new UnauthorizedException("Authentication required");
@@ -112,19 +84,13 @@ export class KnowledgeController {
* Update an existing entry * Update an existing entry
*/ */
@Put(":slug") @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( async update(
@CurrentUser() user: AuthUser, @Request() req: any,
@Param("slug") slug: string, @Param("slug") slug: string,
@Body() updateDto: UpdateEntryDto @Body() updateDto: UpdateEntryDto
) { ) {
const workspaceId = user?.workspaceId; const workspaceId = req.user?.workspaceId;
const userId = user?.id; const userId = req.user?.id;
if (!workspaceId || !userId) { if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required"); throw new UnauthorizedException("Authentication required");
@@ -138,17 +104,12 @@ export class KnowledgeController {
* Soft delete an entry (sets status to ARCHIVED) * Soft delete an entry (sets status to ARCHIVED)
*/ */
@Delete(":slug") @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( async remove(
@CurrentUser() user: AuthUser, @Request() req: any,
@Param("slug") slug: string @Param("slug") slug: string
) { ) {
const workspaceId = user?.workspaceId; const workspaceId = req.user?.workspaceId;
const userId = user?.id; const userId = req.user?.id;
if (!workspaceId || !userId) { if (!workspaceId || !userId) {
throw new UnauthorizedException("Authentication required"); throw new UnauthorizedException("Authentication required");

View File

@@ -188,7 +188,7 @@ export class KnowledgeService {
title: createDto.title, title: createDto.title,
content: createDto.content, content: createDto.content,
contentHtml, contentHtml,
summary: createDto.summary, summary: createDto.summary ?? null,
status: createDto.status || EntryStatus.DRAFT, status: createDto.status || EntryStatus.DRAFT,
visibility: createDto.visibility || "PRIVATE", visibility: createDto.visibility || "PRIVATE",
createdBy: userId, createdBy: userId,
@@ -302,6 +302,31 @@ export class KnowledgeService {
contentHtml = await marked.parse(updateDto.content); contentHtml = await marked.parse(updateDto.content);
} }
// Build update data object conditionally
const updateData: any = {
updatedBy: userId,
};
if (newSlug !== slug) {
updateData.slug = newSlug;
}
if (updateDto.title !== undefined) {
updateData.title = updateDto.title;
}
if (updateDto.content !== undefined) {
updateData.content = updateDto.content;
updateData.contentHtml = contentHtml;
}
if (updateDto.summary !== undefined) {
updateData.summary = updateDto.summary ?? null;
}
if (updateDto.status !== undefined) {
updateData.status = updateDto.status;
}
if (updateDto.visibility !== undefined) {
updateData.visibility = updateDto.visibility;
}
// Use transaction to ensure atomicity // Use transaction to ensure atomicity
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
// Update entry // Update entry
@@ -312,16 +337,7 @@ export class KnowledgeService {
slug, slug,
}, },
}, },
data: { data: updateData,
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 // Create new version if content or title changed