From 3ac6ef6f028f3a5ecc1c5ef38aa0f42203fc4a47 Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Wed, 4 Mar 2026 16:16:00 +0100 Subject: [PATCH] fix(post): show feedback when posts are held for moderation (#162) * feat(pages): migrate accessibility page to CMS - Update footer link from /accessibility to /p/accessibility - Remove hardcoded accessibility page and test * fix(ci): remove /accessibility from Lighthouse URLs The hardcoded /accessibility page is replaced by the CMS page at /p/accessibility which requires the API backend. Since the Lighthouse CI only runs the Next.js standalone server, CMS pages can't be tested. Accessibility coverage for CMS pages is handled by vitest-axe tests. * fix(post): show feedback when posts are held for moderation Previously, held posts appeared to succeed silently -- users saw "Reply posted" or got redirected but their content never appeared. Changes: - Parse API error responses as JSON, show human-readable messages instead of raw JSON strings (fixes #75) - Add CreateTopicResponse/CreateReplyResponse types with moderationStatus field (fixes #74) - Show "pending review" message when topic/reply is held - Detect "Onboarding required" errors and trigger the onboarding modal instead of showing a raw error (fixes #76) - Extract throwApiError() helper for consistent error handling - Update tests for new error message format Fixes barazo-forum/barazo-workspace#74 Fixes barazo-forum/barazo-workspace#75 Fixes barazo-forum/barazo-workspace#76 --- src/__tests__/auth/client-api.test.ts | 6 +-- src/app/new/page.tsx | 26 ++++++++++- src/app/u/[handle]/page.test.tsx | 2 +- src/components/reply-composer.test.tsx | 10 +++-- src/components/reply-composer.tsx | 25 ++++++++--- src/lib/api/client.ts | 61 +++++++++++++++++--------- src/lib/api/types.ts | 22 ++++++++++ 7 files changed, 117 insertions(+), 35 deletions(-) diff --git a/src/__tests__/auth/client-api.test.ts b/src/__tests__/auth/client-api.test.ts index 75f663e..3b3d9a3 100644 --- a/src/__tests__/auth/client-api.test.ts +++ b/src/__tests__/auth/client-api.test.ts @@ -49,7 +49,7 @@ describe('initiateLogin', () => { }) ) - await expect(initiateLogin('bad.handle')).rejects.toThrow('API 502') + await expect(initiateLogin('bad.handle')).rejects.toThrow('Failed to initiate login') }) it('throws on invalid handle', async () => { @@ -59,7 +59,7 @@ describe('initiateLogin', () => { }) ) - await expect(initiateLogin('')).rejects.toThrow('API 400') + await expect(initiateLogin('')).rejects.toThrow('Invalid handle') }) }) @@ -78,7 +78,7 @@ describe('refreshSession', () => { }) ) - await expect(refreshSession()).rejects.toThrow('API 401') + await expect(refreshSession()).rejects.toThrow('No refresh token') }) }) diff --git a/src/app/new/page.tsx b/src/app/new/page.tsx index 30809f5..014bb59 100644 --- a/src/app/new/page.tsx +++ b/src/app/new/page.tsx @@ -10,7 +10,7 @@ import { useState, useEffect } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import type { CreateTopicInput, PublicSettings } from '@/lib/api/types' -import { createTopic, getPublicSettings } from '@/lib/api/client' +import { ApiError, createTopic, getPublicSettings } from '@/lib/api/client' import { getTopicUrl } from '@/lib/format' import { ForumLayout } from '@/components/layout/forum-layout' import { Breadcrumbs } from '@/components/breadcrumbs' @@ -26,6 +26,7 @@ export default function NewTopicPage() { const { ensureOnboarded } = useOnboardingContext() const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const [heldMessage, setHeldMessage] = useState(null) const [publicSettings, setPublicSettings] = useState(null) useEffect(() => { @@ -43,8 +44,22 @@ export default function NewTopicPage() { try { const accessToken = getAccessToken() ?? '' const topic = await createTopic(values, accessToken) + + if (topic.moderationStatus === 'held') { + setHeldMessage( + 'Your topic has been submitted and is pending moderator review. It will appear once approved.' + ) + setSubmitting(false) + return + } + router.push(getTopicUrl(topic)) } catch (err) { + if (err instanceof ApiError && err.errorCode === 'Onboarding required') { + ensureOnboarded() + setSubmitting(false) + return + } setError(err instanceof Error ? err.message : 'Failed to create topic') setSubmitting(false) } @@ -66,6 +81,15 @@ export default function NewTopicPage() { )} + {heldMessage && ( +
+

{heldMessage}

+
+ )} + { it('shows error for unknown handle', async () => { render() await waitFor(() => { - expect(screen.getByText(/api 404/i)).toBeInTheDocument() + expect(screen.getByText(/user not found/i)).toBeInTheDocument() }) }) diff --git a/src/components/reply-composer.test.tsx b/src/components/reply-composer.test.tsx index 6f479ea..df1a9b5 100644 --- a/src/components/reply-composer.test.tsx +++ b/src/components/reply-composer.test.tsx @@ -41,9 +41,13 @@ vi.mock('@/hooks/use-toast', () => ({ }), })) -vi.mock('@/lib/api/client', () => ({ - createReply: (...args: unknown[]) => mockCreateReply(...args), -})) +vi.mock('@/lib/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createReply: (...args: unknown[]) => mockCreateReply(...args), + } +}) let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() diff --git a/src/components/reply-composer.tsx b/src/components/reply-composer.tsx index 0535e1e..4777e72 100644 --- a/src/components/reply-composer.tsx +++ b/src/components/reply-composer.tsx @@ -11,7 +11,7 @@ import { PaperPlaneRight, X, Lock } from '@phosphor-icons/react' import { useAuth } from '@/hooks/use-auth' import { useOnboardingContext } from '@/context/onboarding-context' import { useToast } from '@/hooks/use-toast' -import { createReply } from '@/lib/api/client' +import { ApiError, createReply } from '@/lib/api/client' import { MarkdownEditor } from '@/components/markdown-editor' import { cn } from '@/lib/utils' @@ -133,7 +133,7 @@ export const ReplyComposer = forwardRef setSubmitting(true) try { const accessToken = getAccessToken() ?? '' - await createReply( + const result = await createReply( topicUri, { content: trimmed, @@ -141,13 +141,26 @@ export const ReplyComposer = forwardRef }, accessToken ) + setContent('') setIsExpanded(false) - onReplyCreated() - toast({ title: 'Reply posted' }) + + if (result.moderationStatus === 'held') { + toast({ + title: 'Reply submitted', + description: 'Your reply is pending moderator review and will appear once approved.', + }) + } else { + onReplyCreated() + toast({ title: 'Reply posted' }) + } } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to post reply' - toast({ title: 'Error', description: message, variant: 'destructive' }) + if (err instanceof ApiError && err.errorCode === 'Onboarding required') { + ensureOnboarded() + } else { + const message = err instanceof Error ? err.message : 'Failed to post reply' + toast({ title: 'Error', description: message, variant: 'destructive' }) + } } finally { setSubmitting(false) } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index aaabe2f..dd7b91d 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -18,6 +18,7 @@ import type { CommunityPreferenceOverride, CreatePageInput, CreateTopicInput, + CreateTopicResponse, InitializeCommunityInput, InitializeResponse, Page, @@ -34,6 +35,7 @@ import type { Reply, RepliesResponse, CreateReplyInput, + CreateReplyResponse, UpdateReplyInput, SearchResponse, NotificationsResponse, @@ -84,13 +86,38 @@ interface FetchOptions { } class ApiError extends Error { + /** The parsed error code from the API response body (e.g., "Onboarding required") */ + public readonly errorCode: string | undefined + constructor( public readonly status: number, - message: string + message: string, + errorCode?: string ) { super(message) this.name = 'ApiError' + this.errorCode = errorCode + } +} + +async function throwApiError(response: Response): Promise { + const body = await response.text().catch(() => 'Unknown error') + let message = `Request failed (${response.status})` + let errorCode: string | undefined + + try { + const parsed = JSON.parse(body) as { error?: string } + if (parsed.error) { + message = parsed.error + errorCode = parsed.error + } + } catch { + if (body && body !== 'Unknown error') { + message = body + } } + + throw new ApiError(response.status, message, errorCode) } async function apiFetch(path: string, options: FetchOptions = {}): Promise { @@ -107,8 +134,7 @@ async function apiFetch(path: string, options: FetchOptions = {}): Promise }) if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } return response.json() as Promise @@ -156,8 +182,7 @@ export async function refreshSession(): Promise { }) if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } return response.json() as Promise @@ -172,8 +197,7 @@ export async function logout(accessToken: string): Promise { }) if (!response.ok && response.status !== 204) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } } @@ -224,8 +248,8 @@ export function createTopic( input: CreateTopicInput, accessToken: string, options?: FetchOptions -): Promise { - return apiFetch('/api/topics', { +): Promise { + return apiFetch('/api/topics', { ...options, method: 'POST', headers: { @@ -276,8 +300,8 @@ export function createReply( input: CreateReplyInput, accessToken: string, options?: FetchOptions -): Promise { - return apiFetch(`/api/topics/${encodeURIComponent(topicUri)}/replies`, { +): Promise { + return apiFetch(`/api/topics/${encodeURIComponent(topicUri)}/replies`, { ...options, method: 'POST', headers: { @@ -963,8 +987,7 @@ export async function uploadCommunityAvatar( body: form, }) if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } return response.json() as Promise } @@ -983,8 +1006,7 @@ export async function uploadCommunityBanner( body: form, }) if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } return response.json() as Promise } @@ -1004,8 +1026,7 @@ export async function uploadCommunityLogo( body: form, }) if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } return response.json() as Promise } @@ -1020,8 +1041,7 @@ export async function uploadHeaderLogo(file: File, accessToken: string): Promise body: form, }) if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } return response.json() as Promise } @@ -1039,8 +1059,7 @@ export async function uploadCommunityFavicon( body: form, }) if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error') - throw new ApiError(response.status, `API ${response.status}: ${body}`) + await throwApiError(response) } return response.json() as Promise } diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index e551e8d..0414a6e 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -149,6 +149,19 @@ export interface UpdateTopicInput { tags?: string[] } +export type ModerationStatus = 'approved' | 'held' + +/** Slim response from POST /api/topics (differs from full Topic) */ +export interface CreateTopicResponse { + uri: string + cid: string + rkey: string + title: string + category: string + moderationStatus: ModerationStatus + createdAt: string +} + // --- Replies --- export interface Reply { @@ -189,6 +202,15 @@ export interface UpdateReplyInput { labels?: string[] } +/** Slim response from POST /api/topics/:uri/replies (differs from full Reply) */ +export interface CreateReplyResponse { + uri: string + cid: string + rkey: string + moderationStatus: ModerationStatus + createdAt: string +} + // --- Reactions --- export interface Reaction { -- 2.51.2