fix(tags): add FK constraint, token validation, input validation

- Add proper FK constraint on tags.groupId -> tag_groups.id (onDelete: set null)
- Validate auth token is non-empty before API requests in TagsClient
- Add @IsNotEmpty/@MinLength(1) on tag and tag group name DTOs
- Add @MaxLength on all query params in tag-links DTOs
- Add GetTagsForEntityDto for validated query params on tags-for-entity endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-26 21:49:21 +01:00
parent 91116bf0f1
commit 11ab265d55
6 changed files with 46 additions and 8 deletions

View file

@ -44,6 +44,10 @@ export class TagsClient {
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await this.getToken();
if (!token) {
throw new Error('No authentication token available');
}
const response = await fetch(`${this.authUrl}/api/v1${path}`, {
...options,
headers: {

View file

@ -8,6 +8,7 @@ import {
unique,
integer,
} from 'drizzle-orm/pg-core';
import { tagGroups } from './tag-groups.schema';
/**
* Central tags table for all Manacore applications.
@ -21,7 +22,7 @@ export const tags = pgTable(
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#3B82F6'),
icon: varchar('icon', { length: 50 }), // Optional: Phosphor Icon name
groupId: uuid('group_id'), // Reference to tag_groups (validated in service layer)
groupId: uuid('group_id').references(() => tagGroups.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),

View file

@ -1,7 +1,17 @@
import { IsString, IsOptional, IsInt, MaxLength, Matches } from 'class-validator';
import {
IsString,
IsOptional,
IsNotEmpty,
IsInt,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
export class CreateTagGroupDto {
@IsString()
@IsNotEmpty({ message: 'Group name must not be empty' })
@MinLength(1)
@MaxLength(100)
name: string;

View file

@ -1,19 +1,32 @@
import { IsString, IsOptional, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsUUID, MaxLength } from 'class-validator';
export class QueryTagLinksDto {
@IsOptional()
@IsString()
@MaxLength(50)
appId?: string;
@IsOptional()
@IsString()
@MaxLength(255)
entityId?: string;
@IsOptional()
@IsString()
@MaxLength(100)
entityType?: string;
@IsOptional()
@IsUUID()
tagId?: string;
}
export class GetTagsForEntityDto {
@IsString()
@MaxLength(50)
appId: string;
@IsString()
@MaxLength(255)
entityId: string;
}

View file

@ -20,7 +20,7 @@ import {
BulkCreateTagLinksDto,
SyncTagLinksDto,
} from './dto/create-tag-link.dto';
import { QueryTagLinksDto } from './dto/query-tag-links.dto';
import { QueryTagLinksDto, GetTagsForEntityDto } from './dto/query-tag-links.dto';
@Controller('tag-links')
@UseGuards(JwtAuthGuard)
@ -63,10 +63,9 @@ export class TagLinksController {
@Get('tags-for-entity')
async getTagsForEntity(
@CurrentUser() user: CurrentUserData,
@Query('appId') appId: string,
@Query('entityId') entityId: string
@Query() query: GetTagsForEntityDto
) {
return this.tagLinksService.getTagsForEntity(user.userId, appId, entityId);
return this.tagLinksService.getTagsForEntity(user.userId, query.appId, query.entityId);
}
/**

View file

@ -1,7 +1,18 @@
import { IsString, IsOptional, IsUUID, IsInt, MaxLength, Matches } from 'class-validator';
import {
IsString,
IsOptional,
IsNotEmpty,
IsUUID,
IsInt,
MinLength,
MaxLength,
Matches,
} from 'class-validator';
export class CreateTagDto {
@IsString()
@IsNotEmpty({ message: 'Tag name must not be empty' })
@MinLength(1)
@MaxLength(100)
name: string;