From 58e8cb2bbcdd3eeb9fed8611480cc927c7e4fcae Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 14 Jul 2026 12:38:06 -0400 Subject: [PATCH 1/6] fix(api): validate comment length against visible text, not raw Tiptap JSON --- .../comments/dto/create-comment.dto.spec.ts | 85 +++++++++++++ .../src/comments/dto/create-comment.dto.ts | 14 ++- .../src/comments/dto/update-comment.dto.ts | 14 ++- .../utils/extract-comment-plain-text.spec.ts | 116 ++++++++++++++++++ .../utils/extract-comment-plain-text.ts | 80 ++++++++++++ .../max-comment-text-length.validator.ts | 45 +++++++ 6 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/comments/dto/create-comment.dto.spec.ts create mode 100644 apps/api/src/comments/utils/extract-comment-plain-text.spec.ts create mode 100644 apps/api/src/comments/utils/extract-comment-plain-text.ts create mode 100644 apps/api/src/comments/validators/max-comment-text-length.validator.ts diff --git a/apps/api/src/comments/dto/create-comment.dto.spec.ts b/apps/api/src/comments/dto/create-comment.dto.spec.ts new file mode 100644 index 0000000000..268da07244 --- /dev/null +++ b/apps/api/src/comments/dto/create-comment.dto.spec.ts @@ -0,0 +1,85 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateCommentDto } from './create-comment.dto'; + +// create-comment.dto.ts imports the `CommentEntityType` enum from `@db`, +// which eagerly instantiates the Prisma client on import — mock it so this +// spec doesn't need a configured DB connection (mirrors comments.controller.spec.ts). +jest.mock('@db', () => ({ + db: {}, + CommentEntityType: { + task: 'task', + vendor: 'vendor', + risk: 'risk', + policy: 'policy', + finding: 'finding', + }, +})); + +function tiptapDoc(content: unknown[]): string { + return JSON.stringify({ type: 'doc', content }); +} + +function toDto(plain: Record): CreateCommentDto { + return plainToInstance(CreateCommentDto, plain, { + enableImplicitConversion: true, + }); +} + +const VALID_BASE = { + entityId: 'tsk_abc123', + entityType: 'task', +}; + +describe('CreateCommentDto', () => { + it('accepts a plain-text comment under the limit', async () => { + const dto = toDto({ ...VALID_BASE, content: 'Looks good to me' }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects a plain-text comment over 2000 visible characters', async () => { + const dto = toDto({ ...VALID_BASE, content: 'x'.repeat(2001) }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); + + it('accepts a formatted Tiptap comment whose raw JSON exceeds 2000 chars but whose visible text does not (regression for the reported bug)', async () => { + const words = Array.from({ length: 240 }, (_, i) => ({ + type: 'text', + text: 'word ', + ...(i % 2 === 0 ? { marks: [{ type: 'bold' }] } : {}), + })); + const content = tiptapDoc([{ type: 'paragraph', content: words }]); + expect(content.length).toBeGreaterThan(2000); + + const dto = toDto({ ...VALID_BASE, content }); + const errors = await validate(dto); + expect(errors).toHaveLength(0); + }); + + it('rejects a formatted Tiptap comment whose visible text exceeds 2000 characters', async () => { + const content = tiptapDoc([ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'a'.repeat(2001), + marks: [{ type: 'bold' }], + }, + ], + }, + ]); + + const dto = toDto({ ...VALID_BASE, content }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); + + it('rejects an empty comment', async () => { + const dto = toDto({ ...VALID_BASE, content: '' }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); +}); diff --git a/apps/api/src/comments/dto/create-comment.dto.ts b/apps/api/src/comments/dto/create-comment.dto.ts index f83266ae15..b469ed168b 100644 --- a/apps/api/src/comments/dto/create-comment.dto.ts +++ b/apps/api/src/comments/dto/create-comment.dto.ts @@ -11,16 +11,24 @@ import { ValidateNested, } from 'class-validator'; import { UploadAttachmentDto } from '../../attachments/upload-attachment.dto'; +import { MaxCommentTextLength } from '../validators/max-comment-text-length.validator'; + +// `content` is serialized Tiptap JSON (or plain text for API callers). The +// 2000-char limit applies to the visible text a user typed, not the raw +// JSON — see MaxCommentTextLength. This raw-string cap only bounds payload +// size against pathologically formatted input. +const RAW_CONTENT_MAX_LENGTH = 50_000; export class CreateCommentDto { @ApiProperty({ - description: 'Content of the comment', + description: + 'Content of the comment (plain text or serialized Tiptap JSON). Limited to 2000 characters of visible text.', example: 'This task needs to be completed by end of week', - maxLength: 2000, }) @IsString() @IsNotEmpty() - @MaxLength(2000) + @MaxLength(RAW_CONTENT_MAX_LENGTH) + @MaxCommentTextLength(2000) content: string; @ApiProperty({ diff --git a/apps/api/src/comments/dto/update-comment.dto.ts b/apps/api/src/comments/dto/update-comment.dto.ts index 00b24d3c9e..584886d8d3 100644 --- a/apps/api/src/comments/dto/update-comment.dto.ts +++ b/apps/api/src/comments/dto/update-comment.dto.ts @@ -1,15 +1,23 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { MaxCommentTextLength } from '../validators/max-comment-text-length.validator'; + +// `content` is serialized Tiptap JSON (or plain text for API callers). The +// 2000-char limit applies to the visible text a user typed, not the raw +// JSON — see MaxCommentTextLength. This raw-string cap only bounds payload +// size against pathologically formatted input. +const RAW_CONTENT_MAX_LENGTH = 50_000; export class UpdateCommentDto { @ApiProperty({ - description: 'Updated content of the comment', + description: + 'Updated content of the comment (plain text or serialized Tiptap JSON). Limited to 2000 characters of visible text.', example: 'This task needs to be completed by end of week (updated)', - maxLength: 2000, }) @IsString() @IsNotEmpty() - @MaxLength(2000) + @MaxLength(RAW_CONTENT_MAX_LENGTH) + @MaxCommentTextLength(2000) content: string; @ApiProperty({ diff --git a/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts new file mode 100644 index 0000000000..b6f762462f --- /dev/null +++ b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts @@ -0,0 +1,116 @@ +import { extractCommentPlainText } from './extract-comment-plain-text'; + +function tiptapDoc(content: unknown[]): string { + return JSON.stringify({ type: 'doc', content }); +} + +describe('extractCommentPlainText', () => { + it('returns plain text as-is when content is not JSON', () => { + expect(extractCommentPlainText('Just a plain comment')).toBe( + 'Just a plain comment', + ); + }); + + it('extracts text from a simple paragraph', () => { + const content = tiptapDoc([ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello world' }], + }, + ]); + expect(extractCommentPlainText(content)).toBe('Hello world'); + }); + + it('ignores formatting marks — bold text counts the same as plain text', () => { + const content = tiptapDoc([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'This word is ' }, + { type: 'text', text: 'bold', marks: [{ type: 'bold' }] }, + { type: 'text', text: '.' }, + ], + }, + ]); + expect(extractCommentPlainText(content)).toBe('This word is bold.'); + }); + + it('counts one character per hard break and per paragraph boundary', () => { + const content = tiptapDoc([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Line one' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Line two' }, + ], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Second paragraph' }], + }, + ]); + expect(extractCommentPlainText(content)).toBe( + 'Line one\nLine two\nSecond paragraph', + ); + }); + + it('renders a mention as @label', () => { + const content = tiptapDoc([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Hey ' }, + { type: 'mention', attrs: { id: 'usr_1', label: 'Jane Doe' } }, + ], + }, + ]); + expect(extractCommentPlainText(content)).toBe('Hey @Jane Doe'); + }); + + it('extracts text from a bullet list', () => { + const content = tiptapDoc([ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Item one' }], + }, + ], + }, + { + type: 'listItem', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Item two' }], + }, + ], + }, + ], + }, + ]); + expect(extractCommentPlainText(content)).toBe('Item one\nItem two'); + }); + + it('matches the reported bug: a ~1,200-char formatted comment exceeds 2000 raw chars but stays under the visible limit', () => { + // Alternating bold/plain 5-char words, like a comment with scattered + // emphasis — each bold run's marks array is pure JSON overhead. + const words = Array.from({ length: 240 }, (_, i) => ({ + type: 'text', + text: 'word ', + ...(i % 2 === 0 ? { marks: [{ type: 'bold' }] } : {}), + })); + const content = tiptapDoc([{ type: 'paragraph', content: words }]); + + // 240 * 5 = 1200 visible characters, well under the 2000 limit. + expect(extractCommentPlainText(content).length).toBe(1200); + // But the raw JSON (what the old @MaxLength(2000) validated) blows past + // it purely from marks/node overhead — this is the bug. + expect(content.length).toBeGreaterThan(2000); + }); +}); diff --git a/apps/api/src/comments/utils/extract-comment-plain-text.ts b/apps/api/src/comments/utils/extract-comment-plain-text.ts new file mode 100644 index 0000000000..8ef4dab9fb --- /dev/null +++ b/apps/api/src/comments/utils/extract-comment-plain-text.ts @@ -0,0 +1,80 @@ +/** + * Node types whose content represents a separate visual line. A trailing + * newline is appended after their text so line/paragraph breaks the user + * typed count toward the visible length, matching what they see on screen. + * Wrapper types (listItem, tableCell, ...) are deliberately excluded — their + * children are typically paragraphs that already contribute a newline, and + * including the wrapper too would double-count each line break. + */ +const BLOCK_NODE_TYPES = new Set([ + 'paragraph', + 'heading', + 'blockquote', + 'codeBlock', +]); + +interface TiptapNode { + type?: unknown; + text?: unknown; + attrs?: unknown; + content?: unknown; +} + +function mentionLabel(node: TiptapNode): string { + const attrs = node.attrs as { label?: unknown; id?: unknown } | undefined; + if (typeof attrs?.label === 'string' && attrs.label) return attrs.label; + if (typeof attrs?.id === 'string' && attrs.id) return attrs.id; + return ''; +} + +function nodeToText(node: unknown): string { + if (!node || typeof node !== 'object') return ''; + const n = node as TiptapNode; + + if (n.type === 'text') { + return typeof n.text === 'string' ? n.text : ''; + } + + if (n.type === 'hardBreak') { + return '\n'; + } + + if (n.type === 'mention') { + const label = mentionLabel(n); + return label ? `@${label}` : ''; + } + + if (Array.isArray(n.content)) { + const childText = n.content.map(nodeToText).join(''); + return BLOCK_NODE_TYPES.has(typeof n.type === 'string' ? n.type : '') + ? `${childText}\n` + : childText; + } + + return ''; +} + +/** + * Extracts the visible text a user typed from a comment's stored `content`. + * Comments accept either raw Tiptap/ProseMirror JSON (from the web editor) + * or plain text (from API/MCP callers) — formatting marks, node types, and + * attrs are structural overhead that inflates the raw string but adds no + * visible characters, so length checks must run against this instead of + * `content.length`. + */ +export function extractCommentPlainText(content: string): string { + if (typeof content !== 'string') return ''; + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return content; + } + + if (!parsed || typeof parsed !== 'object') { + return content; + } + + return nodeToText(parsed).replace(/\n+$/, ''); +} diff --git a/apps/api/src/comments/validators/max-comment-text-length.validator.ts b/apps/api/src/comments/validators/max-comment-text-length.validator.ts new file mode 100644 index 0000000000..0de478fc80 --- /dev/null +++ b/apps/api/src/comments/validators/max-comment-text-length.validator.ts @@ -0,0 +1,45 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { extractCommentPlainText } from '../utils/extract-comment-plain-text'; + +const DEFAULT_MAX_LENGTH = 2000; + +/** + * Validates comment `content` against the visible text length rather than + * the raw stored string — `content` is serialized Tiptap JSON, so formatting + * (marks, node types, attrs) would otherwise count toward the limit and + * reject short, plainly-visible comments once they include any formatting. + */ +@ValidatorConstraint({ name: 'maxCommentTextLength', async: false }) +export class MaxCommentTextLengthConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (typeof value !== 'string') return false; + const maxLength = (args.constraints[0] as number) ?? DEFAULT_MAX_LENGTH; + return extractCommentPlainText(value).length <= maxLength; + } + + defaultMessage(args: ValidationArguments): string { + const maxLength = (args.constraints[0] as number) ?? DEFAULT_MAX_LENGTH; + return `content must not exceed ${maxLength} characters`; + } +} + +export function MaxCommentTextLength( + maxLength: number = DEFAULT_MAX_LENGTH, + validationOptions?: ValidationOptions, +) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [maxLength], + validator: MaxCommentTextLengthConstraint, + }); + }; +} From b21ea142c3c1064385a8d52c8a3f8b19714e31a2 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 14 Jul 2026 13:00:10 -0400 Subject: [PATCH 2/6] fix(api): only treat content as Tiptap JSON when it has a doc shape --- .../src/comments/dto/create-comment.dto.spec.ts | 7 +++++++ .../utils/extract-comment-plain-text.spec.ts | 11 +++++++++++ .../comments/utils/extract-comment-plain-text.ts | 14 +++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/api/src/comments/dto/create-comment.dto.spec.ts b/apps/api/src/comments/dto/create-comment.dto.spec.ts index 268da07244..621064e7fc 100644 --- a/apps/api/src/comments/dto/create-comment.dto.spec.ts +++ b/apps/api/src/comments/dto/create-comment.dto.spec.ts @@ -82,4 +82,11 @@ describe('CreateCommentDto', () => { const errors = await validate(dto); expect(errors.some((e) => e.property === 'content')).toBe(true); }); + + it('rejects a non-doc JSON payload over the limit instead of treating it as empty (bypass regression)', async () => { + const content = `{"foo": "${'x'.repeat(2001)}"}`; + const dto = toDto({ ...VALID_BASE, content }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); }); diff --git a/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts index b6f762462f..736016df9f 100644 --- a/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts +++ b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts @@ -11,6 +11,17 @@ describe('extractCommentPlainText', () => { ); }); + it('returns plain text as-is when it happens to be valid JSON but not a Tiptap doc (bypass regression)', () => { + const longPlainText = 'x'.repeat(3000); + const jsonLookingText = `{"foo": "${longPlainText}"}`; + expect(extractCommentPlainText(jsonLookingText)).toBe(jsonLookingText); + + const jsonArrayLookingText = `["${longPlainText}"]`; + expect(extractCommentPlainText(jsonArrayLookingText)).toBe( + jsonArrayLookingText, + ); + }); + it('extracts text from a simple paragraph', () => { const content = tiptapDoc([ { diff --git a/apps/api/src/comments/utils/extract-comment-plain-text.ts b/apps/api/src/comments/utils/extract-comment-plain-text.ts index 8ef4dab9fb..a797ab27ab 100644 --- a/apps/api/src/comments/utils/extract-comment-plain-text.ts +++ b/apps/api/src/comments/utils/extract-comment-plain-text.ts @@ -54,6 +54,12 @@ function nodeToText(node: unknown): string { return ''; } +function isTiptapDoc(value: unknown): value is TiptapNode { + if (!value || typeof value !== 'object') return false; + const n = value as TiptapNode; + return n.type === 'doc' && Array.isArray(n.content); +} + /** * Extracts the visible text a user typed from a comment's stored `content`. * Comments accept either raw Tiptap/ProseMirror JSON (from the web editor) @@ -61,6 +67,12 @@ function nodeToText(node: unknown): string { * attrs are structural overhead that inflates the raw string but adds no * visible characters, so length checks must run against this instead of * `content.length`. + * + * Only parses `content` as Tiptap when it has the expected `{ type: 'doc', + * content: [...] }` shape. A plain-text comment that happens to be valid + * JSON (e.g. `{"foo": "..."}`) would otherwise be walked as a node tree, + * match no known type, and silently extract to `''` — bypassing the length + * check entirely instead of falling back to the raw string. */ export function extractCommentPlainText(content: string): string { if (typeof content !== 'string') return ''; @@ -72,7 +84,7 @@ export function extractCommentPlainText(content: string): string { return content; } - if (!parsed || typeof parsed !== 'object') { + if (!isTiptapDoc(parsed)) { return content; } From 35554515b0285a9431f08ecd2fd17aa46d469f0e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 14 Jul 2026 13:04:16 -0400 Subject: [PATCH 3/6] fix(api): reject empty Tiptap documents in comment content validation --- apps/api/src/comments/dto/create-comment.dto.spec.ts | 7 +++++++ .../comments/utils/extract-comment-plain-text.spec.ts | 4 ++++ .../validators/max-comment-text-length.validator.ts | 9 +++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/api/src/comments/dto/create-comment.dto.spec.ts b/apps/api/src/comments/dto/create-comment.dto.spec.ts index 621064e7fc..3b9a775b73 100644 --- a/apps/api/src/comments/dto/create-comment.dto.spec.ts +++ b/apps/api/src/comments/dto/create-comment.dto.spec.ts @@ -89,4 +89,11 @@ describe('CreateCommentDto', () => { const errors = await validate(dto); expect(errors.some((e) => e.property === 'content')).toBe(true); }); + + it('rejects an empty Tiptap document — non-empty JSON string but zero visible text (regression)', async () => { + const content = tiptapDoc([]); + const dto = toDto({ ...VALID_BASE, content }); + const errors = await validate(dto); + expect(errors.some((e) => e.property === 'content')).toBe(true); + }); }); diff --git a/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts index 736016df9f..4abcfa5e62 100644 --- a/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts +++ b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts @@ -11,6 +11,10 @@ describe('extractCommentPlainText', () => { ); }); + it('extracts an empty string from an empty Tiptap document', () => { + expect(extractCommentPlainText(tiptapDoc([]))).toBe(''); + }); + it('returns plain text as-is when it happens to be valid JSON but not a Tiptap doc (bypass regression)', () => { const longPlainText = 'x'.repeat(3000); const jsonLookingText = `{"foo": "${longPlainText}"}`; diff --git a/apps/api/src/comments/validators/max-comment-text-length.validator.ts b/apps/api/src/comments/validators/max-comment-text-length.validator.ts index 0de478fc80..ffa050c569 100644 --- a/apps/api/src/comments/validators/max-comment-text-length.validator.ts +++ b/apps/api/src/comments/validators/max-comment-text-length.validator.ts @@ -14,18 +14,23 @@ const DEFAULT_MAX_LENGTH = 2000; * the raw stored string — `content` is serialized Tiptap JSON, so formatting * (marks, node types, attrs) would otherwise count toward the limit and * reject short, plainly-visible comments once they include any formatting. + * + * Also rejects zero visible text: `@IsNotEmpty()` only sees the raw string, + * so an empty document (e.g. `{"type":"doc","content":[]}`) is a non-empty + * JSON string that would otherwise sail through as a "valid" empty comment. */ @ValidatorConstraint({ name: 'maxCommentTextLength', async: false }) export class MaxCommentTextLengthConstraint implements ValidatorConstraintInterface { validate(value: unknown, args: ValidationArguments): boolean { if (typeof value !== 'string') return false; const maxLength = (args.constraints[0] as number) ?? DEFAULT_MAX_LENGTH; - return extractCommentPlainText(value).length <= maxLength; + const length = extractCommentPlainText(value).length; + return length > 0 && length <= maxLength; } defaultMessage(args: ValidationArguments): string { const maxLength = (args.constraints[0] as number) ?? DEFAULT_MAX_LENGTH; - return `content must not exceed ${maxLength} characters`; + return `content must not be empty and must not exceed ${maxLength} characters`; } } From 95739b1ac8fcf578b31c1f71fbc88dc5cfa07486 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 14 Jul 2026 14:01:51 -0400 Subject: [PATCH 4/6] fix(api): stop double-counting line breaks inside blockquotes --- .../utils/extract-comment-plain-text.spec.ts | 21 +++++++++++++++++++ .../utils/extract-comment-plain-text.ts | 16 ++++++-------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts index 4abcfa5e62..cb9dd1d9e1 100644 --- a/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts +++ b/apps/api/src/comments/utils/extract-comment-plain-text.spec.ts @@ -112,6 +112,27 @@ describe('extractCommentPlainText', () => { expect(extractCommentPlainText(content)).toBe('Item one\nItem two'); }); + it('does not double-count the line break for a blockquoted paragraph', () => { + const content = tiptapDoc([ + { + type: 'blockquote', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Quoted line' }], + }, + ], + }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Next line' }], + }, + ]); + // If blockquote also appended its own newline on top of the paragraph's, + // this would be 'Quoted line\n\nNext line' instead. + expect(extractCommentPlainText(content)).toBe('Quoted line\nNext line'); + }); + it('matches the reported bug: a ~1,200-char formatted comment exceeds 2000 raw chars but stays under the visible limit', () => { // Alternating bold/plain 5-char words, like a comment with scattered // emphasis — each bold run's marks array is pure JSON overhead. diff --git a/apps/api/src/comments/utils/extract-comment-plain-text.ts b/apps/api/src/comments/utils/extract-comment-plain-text.ts index a797ab27ab..438273e5b2 100644 --- a/apps/api/src/comments/utils/extract-comment-plain-text.ts +++ b/apps/api/src/comments/utils/extract-comment-plain-text.ts @@ -2,16 +2,12 @@ * Node types whose content represents a separate visual line. A trailing * newline is appended after their text so line/paragraph breaks the user * typed count toward the visible length, matching what they see on screen. - * Wrapper types (listItem, tableCell, ...) are deliberately excluded — their - * children are typically paragraphs that already contribute a newline, and - * including the wrapper too would double-count each line break. + * Wrapper types (listItem, tableCell, blockquote, ...) are deliberately + * excluded — their children are typically paragraphs that already + * contribute a newline, and including the wrapper too would double-count + * each line break. */ -const BLOCK_NODE_TYPES = new Set([ - 'paragraph', - 'heading', - 'blockquote', - 'codeBlock', -]); +const BLOCK_NODE_TYPES = new Set(['paragraph', 'heading', 'codeBlock']); interface TiptapNode { type?: unknown; @@ -88,5 +84,5 @@ export function extractCommentPlainText(content: string): string { return content; } - return nodeToText(parsed).replace(/\n+$/, ''); + return nodeToText(parsed).replace(/\n$/, ''); } From 117f7dbf3ed45313509f2649bd862b2504492c51 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 14 Jul 2026 14:04:36 -0400 Subject: [PATCH 5/6] fix(api): document raw payload maxLength on comment content in OpenAPI schema --- apps/api/src/comments/dto/create-comment.dto.ts | 3 ++- apps/api/src/comments/dto/update-comment.dto.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/api/src/comments/dto/create-comment.dto.ts b/apps/api/src/comments/dto/create-comment.dto.ts index b469ed168b..a2edabf69a 100644 --- a/apps/api/src/comments/dto/create-comment.dto.ts +++ b/apps/api/src/comments/dto/create-comment.dto.ts @@ -22,8 +22,9 @@ const RAW_CONTENT_MAX_LENGTH = 50_000; export class CreateCommentDto { @ApiProperty({ description: - 'Content of the comment (plain text or serialized Tiptap JSON). Limited to 2000 characters of visible text.', + 'Content of the comment (plain text or serialized Tiptap JSON). Limited to 2000 characters of visible text; maxLength bounds the serialized payload size, not the visible text.', example: 'This task needs to be completed by end of week', + maxLength: RAW_CONTENT_MAX_LENGTH, }) @IsString() @IsNotEmpty() diff --git a/apps/api/src/comments/dto/update-comment.dto.ts b/apps/api/src/comments/dto/update-comment.dto.ts index 584886d8d3..6215232362 100644 --- a/apps/api/src/comments/dto/update-comment.dto.ts +++ b/apps/api/src/comments/dto/update-comment.dto.ts @@ -11,8 +11,9 @@ const RAW_CONTENT_MAX_LENGTH = 50_000; export class UpdateCommentDto { @ApiProperty({ description: - 'Updated content of the comment (plain text or serialized Tiptap JSON). Limited to 2000 characters of visible text.', + 'Updated content of the comment (plain text or serialized Tiptap JSON). Limited to 2000 characters of visible text; maxLength bounds the serialized payload size, not the visible text.', example: 'This task needs to be completed by end of week (updated)', + maxLength: RAW_CONTENT_MAX_LENGTH, }) @IsString() @IsNotEmpty() From cb69fcae2837363c8b30993d9c31d8bf16f872ab Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Tue, 14 Jul 2026 15:07:55 -0400 Subject: [PATCH 6/6] fix(api): count Unicode code points to keep the limit aligned with visible text --- .../comments/validators/max-comment-text-length.validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/comments/validators/max-comment-text-length.validator.ts b/apps/api/src/comments/validators/max-comment-text-length.validator.ts index ffa050c569..343b6431bf 100644 --- a/apps/api/src/comments/validators/max-comment-text-length.validator.ts +++ b/apps/api/src/comments/validators/max-comment-text-length.validator.ts @@ -24,7 +24,7 @@ export class MaxCommentTextLengthConstraint implements ValidatorConstraintInterf validate(value: unknown, args: ValidationArguments): boolean { if (typeof value !== 'string') return false; const maxLength = (args.constraints[0] as number) ?? DEFAULT_MAX_LENGTH; - const length = extractCommentPlainText(value).length; + const length = [...extractCommentPlainText(value)].length; return length > 0 && length <= maxLength; }