diff --git a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts index 09c7c04af..d54487a84 100644 --- a/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts +++ b/apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest' import { markdownToTiptapJson, tiptapJsonToMarkdown, + contentJsonToMarkdown, commentMarkdownToTiptapJson, } from '../markdown-tiptap' @@ -177,6 +178,123 @@ describe('tiptapJsonToMarkdown', () => { }) }) +describe('contentJsonToMarkdown', () => { + const imageDoc = { + type: 'doc' as const, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Shipped a thing.' }] }, + { + type: 'image', + attrs: { src: 'https://cdn.example.com/shot.png', alt: 'Screenshot', title: null }, + }, + ], + } + + test('serializes image nodes the stored markdown dropped', () => { + // The reported bug: the API returned text-only markdown because the stored + // `content` column lost images. Deriving from contentJson restores them. + const result = contentJsonToMarkdown(imageDoc, 'Shipped a thing.') + expect(result).toContain('Shipped a thing.') + expect(result).toContain('![Screenshot](https://cdn.example.com/shot.png)') + }) + + test('serializes resizableImage nodes (the type the editor actually stores)', () => { + // UI uploads are stored as `resizableImage`, which @tiptap/markdown's Image + // extension does not know — they must be normalized to `image` first. + const resizableDoc = { + type: 'doc' as const, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Look:' }] }, + { + type: 'resizableImage', + attrs: { src: 'https://cdn.example.com/r.png', alt: 'Resized', title: null, width: 400 }, + }, + ], + } + const result = contentJsonToMarkdown(resizableDoc, 'Look:') + expect(result).toContain('![Resized](https://cdn.example.com/r.png)') + }) + + test('keeps mentions (as @label) when restoring an image', () => { + // The server manager has no mention extension, so re-serializing must not + // drop it; normalize it to the @label text instead. + const doc = { + type: 'doc' as const, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'cc ' }, + { type: 'mention', attrs: { id: 'p1', label: 'Alice' } }, + ], + }, + { type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } }, + ], + } + const result = contentJsonToMarkdown(doc, 'cc @Alice') + expect(result).toContain('@Alice') + expect(result).toContain('![S](https://cdn.example.com/s.png)') + }) + + test('keeps stored markdown when an image coexists with an unsupported node', () => { + // A youtube embed has no server renderer; re-serializing would drop it, so + // the whole document keeps its stored markdown (image not re-derived) rather + // than losing the embed. + const doc = { + type: 'doc' as const, + content: [ + { type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } }, + { type: 'youtube', attrs: { src: 'https://youtu.be/abc' } }, + ], + } + const stored = 'stored markdown with :::youtube::: and no image' + expect(contentJsonToMarkdown(doc, stored)).toBe(stored) + }) + + test('returns the stored markdown verbatim for image-free content', () => { + // No images means the stored column is already faithful; don't re-serialize + // (and risk reformatting) what was correct. + const noImageDoc = { + type: 'doc' as const, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Just text' }] }], + } + expect(contentJsonToMarkdown(noImageDoc, '_Just_ text')).toBe('_Just_ text') + }) + + test.each([null, undefined])( + 'falls back to stored markdown when contentJson is %s (legacy rows)', + (value) => { + expect(contentJsonToMarkdown(value, '# Legacy\n\nPlain markdown')).toBe( + '# Legacy\n\nPlain markdown' + ) + } + ) + + test('falls back when contentJson has no real content', () => { + expect(contentJsonToMarkdown({ type: 'doc', content: [] }, 'fallback text')).toBe( + 'fallback text' + ) + }) + + test('falls back instead of throwing on malformed contentJson', () => { + // A corrupt/unexpected shape must never 500 a read endpoint. + const malformed = { not: 'a real doc' } as unknown as Parameters< + typeof contentJsonToMarkdown + >[0] + expect(contentJsonToMarkdown(malformed, 'safe fallback')).toBe('safe fallback') + }) + + test.each([ + ['content is a string', { type: 'doc', content: 'oops' }], + ['content is an object', { type: 'doc', content: { bad: 1 } }], + ])('falls back when %s (image scan must not throw)', (_label, doc) => { + // The image scan runs before the serialize try/catch, so a row whose + // `content` is present but not an array must not throw a read into a 500. + const malformed = doc as unknown as Parameters[0] + expect(contentJsonToMarkdown(malformed, 'safe fallback')).toBe('safe fallback') + }) +}) + describe('commentMarkdownToTiptapJson', () => { test('plain text becomes a paragraph', () => { const result = commentMarkdownToTiptapJson('Hello world') diff --git a/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts b/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts index 260cf85a7..9a1d82afd 100644 --- a/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts +++ b/apps/web/src/lib/server/domains/changelog/__tests__/changelog-notified-at.test.ts @@ -232,6 +232,28 @@ describe('createChangelog wiring', () => { expect(dispatchChangelogPublished).not.toHaveBeenCalled() }) + + it('stores the markdown projection of contentJson so images reach the content column', async () => { + // Write-time regen: the stored `content` column must carry the image even + // when the caller-supplied markdown would have, so downstream consumers + // that read the column directly (webhooks, notifications) get it too. + const { createChangelog } = await import('../changelog.service') + + await createChangelog( + { + title: 'X', + content: '![Shot](https://cdn.example.com/shot.png)', + publishState: { type: 'draft' }, + }, + AUTHOR + ) + + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('![Shot](https://cdn.example.com/shot.png)'), + }) + ) + }) }) describe('updateChangelog wiring', () => { diff --git a/apps/web/src/lib/server/domains/changelog/changelog.service.ts b/apps/web/src/lib/server/domains/changelog/changelog.service.ts index e26384560..9115f1cfb 100644 --- a/apps/web/src/lib/server/domains/changelog/changelog.service.ts +++ b/apps/web/src/lib/server/domains/changelog/changelog.service.ts @@ -25,7 +25,7 @@ import { } from '@/lib/server/db' import type { ChangelogId, PrincipalId, PostId } from '@quackback/ids' import { NotFoundError, ValidationError } from '@/lib/shared/errors' -import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap' +import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { rehostExternalImages } from '@/lib/server/content/rehost-images' import { buildEventActor, @@ -105,7 +105,9 @@ export async function createChangelog( .insert(changelogEntries) .values({ title, - content, + // Store the markdown projection of the canonical contentJson so every + // consumer of the `content` column (webhooks, notifications) sees images. + content: contentJsonToMarkdown(contentJson, content), contentJson, principalId: author.principalId, publishedAt, @@ -180,13 +182,21 @@ export async function updateChangelog( } if (input.title !== undefined) updateData.title = input.title.trim() - if (input.content !== undefined) updateData.content = input.content.trim() if (input.contentJson !== undefined || input.content !== undefined) { const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim()) - updateData.contentJson = await rehostExternalImages(parsed, { + const contentJson = await rehostExternalImages(parsed, { contentType: 'changelog', principalId: existing.principalId ?? undefined, }) + updateData.contentJson = contentJson + // Every content edit carries `input.content` (the API accepts only markdown; + // the editor emits markdown alongside contentJson), so the fallback reflects + // the new doc. `existing.content` is only a defensive default for a + // contentJson-only edit, which no caller makes. + updateData.content = contentJsonToMarkdown( + contentJson, + (input.content ?? existing.content).trim() + ) } if (input.displayDate !== undefined) { diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts index 72ba867b9..d7707143e 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts @@ -122,6 +122,7 @@ vi.mock('@/lib/server/db', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({ type: 'doc', content: [] })), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) let getArticleById: typeof import('../help-center.article.service').getArticleById diff --git a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts index 2131d3b64..eea670df3 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center.article.service.ts @@ -14,7 +14,7 @@ import { import type { HelpCenterArticleId, HelpCenterCategoryId, PrincipalId } from '@quackback/ids' import { NotFoundError, ValidationError } from '@/lib/shared/errors' import { isTeamMember } from '@/lib/shared/roles' -import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap' +import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { rehostExternalImages } from '@/lib/server/content/rehost-images' import { slugify } from '@/lib/shared/utils' import { uniqueHelpCenterSlug } from './help-center.slug' @@ -182,7 +182,9 @@ export async function createArticle( .values({ categoryId: input.categoryId as HelpCenterCategoryId, title, - content, + // Store the markdown projection of the canonical contentJson so the + // article list endpoint (which omits contentJson) still serves images. + content: contentJsonToMarkdown(contentJson, content), contentJson, slug, principalId: effectivePrincipalId, @@ -209,13 +211,19 @@ export async function updateArticle( const updateData: Partial = { updatedAt: new Date() } if (input.title !== undefined) updateData.title = input.title.trim() if (input.content !== undefined || input.contentJson !== undefined) { - if (input.content !== undefined) { - updateData.content = input.content.trim() - } const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim()) - updateData.contentJson = await rehostExternalImages(parsed, { + const contentJson = await rehostExternalImages(parsed, { contentType: 'help-center', }) + updateData.contentJson = contentJson + if (input.content !== undefined) { + updateData.content = contentJsonToMarkdown(contentJson, input.content.trim()) + } else { + // contentJson-only edit (no markdown source): refresh the stored column + // only when the tree carries images, else leave it as-is. + const regenerated = contentJsonToMarkdown(contentJson, '') + if (regenerated) updateData.content = regenerated + } } if (input.categoryId !== undefined) updateData.categoryId = input.categoryId as HelpCenterCategoryId diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts index a24607e61..54f528326 100644 --- a/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts +++ b/apps/web/src/lib/server/domains/posts/__tests__/post-create-service.test.ts @@ -143,6 +143,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({})), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) vi.mock('@/lib/server/content/rehost-images', () => ({ diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts index 49a0b8301..6b4548138 100644 --- a/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts +++ b/apps/web/src/lib/server/domains/posts/__tests__/post-tier-limits.test.ts @@ -61,6 +61,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({})), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) vi.mock('@/lib/server/content/rehost-images', () => ({ diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts index 4ed730e01..e3c24c963 100644 --- a/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts +++ b/apps/web/src/lib/server/domains/posts/__tests__/post.service-mentions.test.ts @@ -195,6 +195,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({ vi.mock('@/lib/server/markdown-tiptap', () => ({ markdownToTiptapJson: vi.fn(() => ({ type: 'doc', content: [] })), + contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback, })) vi.mock('@/lib/server/content/rehost-images', () => ({ diff --git a/apps/web/src/lib/server/domains/posts/post.service.ts b/apps/web/src/lib/server/domains/posts/post.service.ts index b4cf47588..f668108cb 100644 --- a/apps/web/src/lib/server/domains/posts/post.service.ts +++ b/apps/web/src/lib/server/domains/posts/post.service.ts @@ -43,7 +43,7 @@ import { import { announcePublishedPost } from './post.announce' import { NotFoundError, ValidationError } from '@/lib/shared/errors' import { recordAuditEvent } from '@/lib/server/audit/log' -import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap' +import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { rehostExternalImages } from '@/lib/server/content/rehost-images' import { subscribeToPost } from '@/lib/server/domains/subscriptions/subscription.service' import type { CreatePostInput, UpdatePostInput, CreatePostResult } from './post.types' @@ -216,7 +216,9 @@ export async function createPost( .values({ boardId: input.boardId, title, - content, + // Store the markdown projection of the canonical contentJson so every + // consumer of the `content` column (webhooks, notifications) sees images. + content: contentJsonToMarkdown(contentJson, content), contentJson, statusId, principalId: author.principalId, @@ -375,13 +377,21 @@ export async function updatePost( // Build update data const updateData: Partial = {} if (input.title !== undefined) updateData.title = input.title.trim() - if (input.content !== undefined) updateData.content = input.content.trim() if (input.contentJson !== undefined || input.content !== undefined) { const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim()) - updateData.contentJson = await rehostExternalImages(parsed, { + const contentJson = await rehostExternalImages(parsed, { contentType: 'post', principalId: existingPost.principalId, }) + updateData.contentJson = contentJson + // Every content edit carries `input.content` (the API accepts only markdown; + // the editor emits markdown alongside contentJson), so the fallback reflects + // the new doc. `existingPost.content` is only a defensive default for a + // contentJson-only edit, which no caller makes. + updateData.content = contentJsonToMarkdown( + contentJson, + (input.content ?? existingPost.content).trim() + ) } if (input.statusId !== undefined) updateData.statusId = input.statusId if (input.ownerPrincipalId !== undefined) updateData.ownerPrincipalId = input.ownerPrincipalId diff --git a/apps/web/src/lib/server/markdown-tiptap.ts b/apps/web/src/lib/server/markdown-tiptap.ts index 9a7ff62a6..fdf42c4df 100644 --- a/apps/web/src/lib/server/markdown-tiptap.ts +++ b/apps/web/src/lib/server/markdown-tiptap.ts @@ -72,6 +72,114 @@ export function tiptapJsonToMarkdown(json: TiptapContent | JSONContent): string return manager.serialize(json as JSONContent) } +/** + * Image node types found in stored `contentJson`. The editor stores uploads as + * `resizableImage`; markdown parsed via {@link markdownToTiptapJson} yields the + * plain `image`. Mirrors `IMAGE_NODE_TYPES` in content/rehost-images.ts. + */ +const IMAGE_NODE_TYPES = new Set(['image', 'resizableImage']) + +/** + * Node types this module can faithfully turn into markdown: the server + * manager's own nodes (see SERVER_EXTENSIONS) plus the two we normalize below + * (`resizableImage` -> `image`, `mention` -> text). Anything else — `youtube`, + * `quackbackEmbed`, `emoji`, future custom nodes — would be silently dropped by + * the narrower server manager, so a document containing one keeps its stored + * markdown (which the client serialized with full coverage) instead. + */ +const RESERIALIZABLE_NODE_TYPES = new Set([ + 'doc', + 'paragraph', + 'text', + 'heading', + 'blockquote', + 'bulletList', + 'orderedList', + 'listItem', + 'codeBlock', + 'horizontalRule', + 'hardBreak', + 'taskList', + 'taskItem', + 'table', + 'tableRow', + 'tableCell', + 'tableHeader', + 'image', + 'resizableImage', + 'mention', +]) + +/** + * Render an entity's markdown for output (API / MCP responses), preferring the + * stored `content` column but restoring images from the canonical `contentJson`. + * + * The stored markdown is faithful for everything except images: the editor's + * resizable-image node has no markdown serializer, so client-computed markdown + * silently dropped them. `contentJson` keeps the images (with rehosted src), so + * only when it carries an image do we re-serialize it to put them back as + * `![alt](src)`. Image-free content returns the stored markdown verbatim — no + * reason to pay for, or risk reformatting from, a re-serialize. + * + * Re-serialization runs through the narrower server manager, so we only do it + * when every node is representable (see {@link RESERIALIZABLE_NODE_TYPES}); a + * document mixing an image with, say, a YouTube embed keeps its stored markdown + * rather than dropping the embed. Also falls back when `contentJson` is absent + * (legacy rows / list queries that omit it) or can't be serialized — a read + * path must never fail over content shape. + */ +export function contentJsonToMarkdown( + contentJson: TiptapContent | JSONContent | null | undefined, + fallback: string +): string { + if (!contentJson || !hasImageNode(contentJson) || !isReserializable(contentJson)) { + return fallback + } + try { + const markdown = tiptapJsonToMarkdown(normalizeForMarkdown(contentJson)) + return markdown.trim() ? markdown : fallback + } catch { + return fallback + } +} + +/** + * Depth-first scan for an image node (`image` or `resizableImage`) anywhere in a + * tree. Runs before the serialize try/catch, so it must stay total: a malformed + * row whose `content` is present but not an array must not throw. + */ +function hasImageNode(node: JSONContent): boolean { + if (typeof node.type === 'string' && IMAGE_NODE_TYPES.has(node.type)) return true + return Array.isArray(node.content) ? node.content.some(hasImageNode) : false +} + +/** + * True only when every node in the tree can be re-serialized without loss. A + * single unknown node type makes this false so the caller keeps stored markdown. + * Total by the same contract as {@link hasImageNode}. + */ +function isReserializable(node: JSONContent): boolean { + if (typeof node.type === 'string' && !RESERIALIZABLE_NODE_TYPES.has(node.type)) return false + return Array.isArray(node.content) ? node.content.every(isReserializable) : true +} + +/** + * Rewrite the editor's custom nodes into ones @tiptap/markdown can serialize: + * `resizableImage` -> `image` (shares src/alt but has no markdown spec) and + * `mention` -> the `@label` text the directive would otherwise hide. Only + * called once {@link isReserializable} has cleared the tree. + */ +function normalizeForMarkdown(node: JSONContent): JSONContent { + if (node.type === 'mention') { + const attrs = node.attrs ?? {} + const label = (attrs.label as string) || (attrs.id as string) || 'mention' + return { type: 'text', text: `@${label}` } + } + const next = node.type === 'resizableImage' ? { ...node, type: 'image' } : node + if (!Array.isArray(next.content)) return next + return { ...next, content: next.content.map(normalizeForMarkdown) } +} + /** * Slim extension set for comments — no images, no tables, no YouTube. * Comments are short, dense, and inline; we want the safe subset only. diff --git a/apps/web/src/lib/server/mcp/tools.ts b/apps/web/src/lib/server/mcp/tools.ts index 62cb7bf3e..10201807b 100644 --- a/apps/web/src/lib/server/mcp/tools.ts +++ b/apps/web/src/lib/server/mcp/tools.ts @@ -94,6 +94,8 @@ import { import { isFeatureEnabled } from '@/lib/server/domains/settings/settings.service' import { DomainException } from '@/lib/shared/errors' import { parseOptionalTypeId } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import { realEmail } from '@/lib/shared/anonymous-email' import type { McpAuthContext, McpScope } from './types' import type { @@ -216,6 +218,7 @@ function articleResult(article: { slug: string title: string content: string + contentJson: TiptapContent | null description: string | null position: number | null category: { id: string; slug: string; name: string } @@ -231,7 +234,7 @@ function articleResult(article: { id: article.id, slug: article.slug, title: article.title, - content: article.content, + content: contentJsonToMarkdown(article.contentJson, article.content), description: article.description, position: article.position, category: article.category, @@ -2481,7 +2484,7 @@ async function getPostDetails(postId: PostId): Promise { return jsonResult({ id: post.id, title: post.title, - content: post.content, + content: contentJsonToMarkdown(post.contentJson, post.content), voteCount: post.voteCount, commentCount: post.commentCount, boardId: post.boardId, @@ -2525,7 +2528,7 @@ async function getChangelogDetails(changelogId: ChangelogId): Promise ({ diff --git a/apps/web/src/routes/api/v1/apps/posts.ts b/apps/web/src/routes/api/v1/apps/posts.ts index 9c2fbc2c2..39f7e4be2 100644 --- a/apps/web/src/routes/api/v1/apps/posts.ts +++ b/apps/web/src/routes/api/v1/apps/posts.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' import { badRequestResponse, handleDomainError } from '@/lib/server/domains/api/responses' import { parseTypeId } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import type { BoardId, PostId } from '@quackback/ids' import { appJsonResponse, preflightResponse } from '@/lib/server/integrations/apps/cors' import { segmentIdsForPrincipal } from '@/lib/server/domains/segments/segment-membership.service' @@ -117,7 +118,7 @@ export const Route = createFileRoute('/api/v1/apps/posts')({ { id: result.id, title: result.title, - content: result.content, + content: contentJsonToMarkdown(result.contentJson, result.content), voteCount: result.voteCount, boardId: result.boardId, statusId: result.statusId, diff --git a/apps/web/src/routes/api/v1/changelog/$entryId.ts b/apps/web/src/routes/api/v1/changelog/$entryId.ts index 3b8240afb..98d256ac5 100644 --- a/apps/web/src/routes/api/v1/changelog/$entryId.ts +++ b/apps/web/src/routes/api/v1/changelog/$entryId.ts @@ -13,6 +13,8 @@ import { updateChangelog, deleteChangelog, } from '@/lib/server/domains/changelog/changelog.service' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import type { PublishState } from '@/lib/shared/schemas/changelog' import type { ChangelogId } from '@quackback/ids' @@ -28,6 +30,7 @@ function formatChangelogResponse(entry: { id: string title: string content: string + contentJson: TiptapContent | null publishedAt: Date | null displayDate: Date | null createdAt: Date @@ -36,7 +39,7 @@ function formatChangelogResponse(entry: { return { id: entry.id, title: entry.title, - content: entry.content, + content: contentJsonToMarkdown(entry.contentJson, entry.content), publishedAt: entry.publishedAt?.toISOString() || null, displayDate: entry.displayDate?.toISOString() || null, createdAt: entry.createdAt.toISOString(), diff --git a/apps/web/src/routes/api/v1/changelog/__tests__/$entryId.test.ts b/apps/web/src/routes/api/v1/changelog/__tests__/$entryId.test.ts new file mode 100644 index 000000000..a8a6ef90c --- /dev/null +++ b/apps/web/src/routes/api/v1/changelog/__tests__/$entryId.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChangelogId } from '@quackback/ids' + +const mockWithApiKeyAuth = vi.fn() +const mockGetChangelogById = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/api/auth', () => ({ + withApiKeyAuth: (...args: unknown[]) => mockWithApiKeyAuth(...args), +})) +vi.mock('@/lib/server/domains/changelog/changelog.service', () => ({ + getChangelogById: (...args: unknown[]) => mockGetChangelogById(...args), + updateChangelog: vi.fn(), + deleteChangelog: vi.fn(), +})) + +// markdown-tiptap is intentionally NOT mocked — the point of these tests is the +// real contentJson -> markdown serialization, including image nodes. + +import { Route } from '../$entryId' + +type RouteOpts = { + server: { handlers: { GET: (...args: unknown[]) => Promise } } +} +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +const ENTRY_ID = 'changelog_01h455vb4pex5vsknk084sn02q' as unknown as ChangelogId + +function baseEntry() { + return { + id: ENTRY_ID, + title: 'Dark mode', + content: 'We shipped dark mode.', + contentJson: null as unknown, + publishedAt: new Date('2026-01-01T00:00:00.000Z'), + displayDate: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } +} + +describe('GET /api/v1/changelog/:entryId — markdown image output', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWithApiKeyAuth.mockResolvedValue({ principalId: 'principal_x', role: 'team' }) + }) + + it('renders images from contentJson as markdown that the stored content dropped', async () => { + mockGetChangelogById.mockResolvedValue({ + ...baseEntry(), + // Stored markdown column lost the image (client serializer has no spec + // for the image node); contentJson is the source of truth. + content: 'We shipped dark mode.', + contentJson: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'We shipped dark mode.' }] }, + { + type: 'image', + attrs: { src: 'https://cdn.example.com/dark.png', alt: 'Dark mode', title: null }, + }, + ], + }, + }) + + const res = await GET({ request: new Request('http://t/'), params: { entryId: ENTRY_ID } }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.content).toContain('We shipped dark mode.') + expect(json.data.content).toContain('![Dark mode](https://cdn.example.com/dark.png)') + }) + + it('falls back to the stored content for legacy rows without contentJson', async () => { + mockGetChangelogById.mockResolvedValue({ + ...baseEntry(), + content: '# Legacy entry\n\nPlain text.', + contentJson: null, + }) + + const res = await GET({ request: new Request('http://t/'), params: { entryId: ENTRY_ID } }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.content).toBe('# Legacy entry\n\nPlain text.') + }) +}) diff --git a/apps/web/src/routes/api/v1/changelog/index.ts b/apps/web/src/routes/api/v1/changelog/index.ts index 5ce5a31fc..0e3d6812c 100644 --- a/apps/web/src/routes/api/v1/changelog/index.ts +++ b/apps/web/src/routes/api/v1/changelog/index.ts @@ -10,6 +10,7 @@ import { import { createChangelog } from '@/lib/server/domains/changelog/changelog.service' import { listChangelogs } from '@/lib/server/domains/changelog/changelog.query' import { publishedAtToPublishState } from '@/lib/shared/schemas/changelog' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import { db, principal, eq } from '@/lib/server/db' import type { PostId } from '@quackback/ids' @@ -52,7 +53,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({ result.items.map((entry) => ({ id: entry.id, title: entry.title, - content: entry.content, + content: contentJsonToMarkdown(entry.contentJson, entry.content), publishedAt: entry.publishedAt?.toISOString() || null, displayDate: entry.displayDate?.toISOString() || null, createdAt: entry.createdAt.toISOString(), @@ -113,7 +114,7 @@ export const Route = createFileRoute('/api/v1/changelog/')({ return createdResponse({ id: entry.id, title: entry.title, - content: entry.content, + content: contentJsonToMarkdown(entry.contentJson, entry.content), publishedAt: entry.publishedAt?.toISOString() || null, displayDate: entry.displayDate?.toISOString() || null, createdAt: entry.createdAt.toISOString(), diff --git a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts index fe3feebe5..88b862ab5 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts @@ -17,6 +17,8 @@ import { unpublishArticle, deleteArticle, } from '@/lib/server/domains/help-center/help-center.service' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import type { HelpCenterArticleId, PrincipalId } from '@quackback/ids' const updateArticleBody = z.object({ @@ -35,6 +37,7 @@ function formatArticle(article: { title: string description: string | null content: string + contentJson: TiptapContent | null publishedAt: Date | null viewCount: number helpfulCount: number @@ -49,7 +52,9 @@ function formatArticle(article: { slug: article.slug, title: article.title, description: article.description, - content: article.content, + // Render markdown from the canonical contentJson so images survive; falls + // back to the stored column for legacy rows. + content: contentJsonToMarkdown(article.contentJson, article.content), publishedAt: article.publishedAt?.toISOString() || null, viewCount: article.viewCount, helpfulCount: article.helpfulCount, diff --git a/apps/web/src/routes/api/v1/help-center/articles/index.ts b/apps/web/src/routes/api/v1/help-center/articles/index.ts index 89854c04f..fae05fa06 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/index.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/index.ts @@ -12,6 +12,8 @@ import { import { parseOptionalTypeId } from '@/lib/server/domains/api/validation' import { isFeatureEnabled } from '@/lib/server/domains/settings/settings.service' import { listArticles, createArticle } from '@/lib/server/domains/help-center/help-center.service' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' +import type { TiptapContent } from '@/lib/server/db' import type { PrincipalId } from '@quackback/ids' const createArticleBody = z.object({ @@ -29,6 +31,7 @@ function formatArticle(article: { title: string description: string | null content: string + contentJson: TiptapContent | null publishedAt: Date | null viewCount: number helpfulCount: number @@ -43,7 +46,10 @@ function formatArticle(article: { slug: article.slug, title: article.title, description: article.description, - content: article.content, + // Render markdown from the canonical contentJson so images survive; falls + // back to the stored column for legacy rows (and the list query, which + // omits contentJson for performance). + content: contentJsonToMarkdown(article.contentJson, article.content), publishedAt: article.publishedAt?.toISOString() || null, viewCount: article.viewCount, helpfulCount: article.helpfulCount, @@ -100,7 +106,11 @@ export const Route = createFileRoute('/api/v1/help-center/articles/')({ const { authorId, ...articleData } = parsed.data - const authorPrincipalId = parseOptionalTypeId(authorId, 'principal', 'author ID') + const authorPrincipalId = parseOptionalTypeId( + authorId, + 'principal', + 'author ID' + ) const article = await createArticle( articleData, diff --git a/apps/web/src/routes/api/v1/posts/$postId.ts b/apps/web/src/routes/api/v1/posts/$postId.ts index d5e6a3cd5..fb5b69a6c 100644 --- a/apps/web/src/routes/api/v1/posts/$postId.ts +++ b/apps/web/src/routes/api/v1/posts/$postId.ts @@ -13,6 +13,7 @@ import { parseOptionalTypeId, parseTypeIdArray, } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import type { PostId, StatusId, TagId, PrincipalId } from '@quackback/ids' import type { MergedPostSummary } from '@/lib/server/domains/posts/post.types' @@ -49,7 +50,7 @@ export const Route = createFileRoute('/api/v1/posts/$postId')({ return successResponse({ id: post.id, title: post.title, - content: post.content, + content: contentJsonToMarkdown(post.contentJson, post.content), contentJson: post.contentJson, voteCount: post.voteCount, commentCount: post.commentCount, @@ -141,7 +142,7 @@ export const Route = createFileRoute('/api/v1/posts/$postId')({ return successResponse({ id: result.id, title: result.title, - content: result.content, + content: contentJsonToMarkdown(result.contentJson, result.content), contentJson: result.contentJson, voteCount: result.voteCount, boardId: result.boardId, diff --git a/apps/web/src/routes/api/v1/posts/index.ts b/apps/web/src/routes/api/v1/posts/index.ts index c96e714a5..9e8bf5603 100644 --- a/apps/web/src/routes/api/v1/posts/index.ts +++ b/apps/web/src/routes/api/v1/posts/index.ts @@ -13,6 +13,7 @@ import { parseOptionalTypeId, parseTypeIdArray, } from '@/lib/server/domains/api/validation' +import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import type { BoardId, PrincipalId, StatusId, TagId } from '@quackback/ids' import { segmentIdsForPrincipal } from '@/lib/server/domains/segments/segment-membership.service' @@ -91,7 +92,7 @@ export const Route = createFileRoute('/api/v1/posts/')({ result.items.map((post) => ({ id: post.id, title: post.title, - content: post.content, + content: contentJsonToMarkdown(post.contentJson, post.content), voteCount: post.voteCount, commentCount: post.commentCount, boardId: post.boardId, @@ -239,7 +240,7 @@ export const Route = createFileRoute('/api/v1/posts/')({ return createdResponse({ id: result.id, title: result.title, - content: result.content, + content: contentJsonToMarkdown(result.contentJson, result.content), voteCount: result.voteCount, boardId: result.boardId, statusId: result.statusId,