Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Guido X Jansen and committed by
GitHub
3ac6ef6f c44540a3

+117 -35
+3 -3
src/__tests__/auth/client-api.test.ts
··· 49 49 }) 50 50 ) 51 51 52 - await expect(initiateLogin('bad.handle')).rejects.toThrow('API 502') 52 + await expect(initiateLogin('bad.handle')).rejects.toThrow('Failed to initiate login') 53 53 }) 54 54 55 55 it('throws on invalid handle', async () => { ··· 59 59 }) 60 60 ) 61 61 62 - await expect(initiateLogin('')).rejects.toThrow('API 400') 62 + await expect(initiateLogin('')).rejects.toThrow('Invalid handle') 63 63 }) 64 64 }) 65 65 ··· 78 78 }) 79 79 ) 80 80 81 - await expect(refreshSession()).rejects.toThrow('API 401') 81 + await expect(refreshSession()).rejects.toThrow('No refresh token') 82 82 }) 83 83 }) 84 84
+25 -1
src/app/new/page.tsx
··· 10 10 import { useState, useEffect } from 'react' 11 11 import { useRouter, useSearchParams } from 'next/navigation' 12 12 import type { CreateTopicInput, PublicSettings } from '@/lib/api/types' 13 - import { createTopic, getPublicSettings } from '@/lib/api/client' 13 + import { ApiError, createTopic, getPublicSettings } from '@/lib/api/client' 14 14 import { getTopicUrl } from '@/lib/format' 15 15 import { ForumLayout } from '@/components/layout/forum-layout' 16 16 import { Breadcrumbs } from '@/components/breadcrumbs' ··· 26 26 const { ensureOnboarded } = useOnboardingContext() 27 27 const [submitting, setSubmitting] = useState(false) 28 28 const [error, setError] = useState<string | null>(null) 29 + const [heldMessage, setHeldMessage] = useState<string | null>(null) 29 30 const [publicSettings, setPublicSettings] = useState<PublicSettings | null>(null) 30 31 31 32 useEffect(() => { ··· 43 44 try { 44 45 const accessToken = getAccessToken() ?? '' 45 46 const topic = await createTopic(values, accessToken) 47 + 48 + if (topic.moderationStatus === 'held') { 49 + setHeldMessage( 50 + 'Your topic has been submitted and is pending moderator review. It will appear once approved.' 51 + ) 52 + setSubmitting(false) 53 + return 54 + } 55 + 46 56 router.push(getTopicUrl(topic)) 47 57 } catch (err) { 58 + if (err instanceof ApiError && err.errorCode === 'Onboarding required') { 59 + ensureOnboarded() 60 + setSubmitting(false) 61 + return 62 + } 48 63 setError(err instanceof Error ? err.message : 'Failed to create topic') 49 64 setSubmitting(false) 50 65 } ··· 63 78 role="alert" 64 79 > 65 80 <p className="text-sm text-destructive">{error}</p> 81 + </div> 82 + )} 83 + 84 + {heldMessage && ( 85 + <div 86 + className="rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950" 87 + role="status" 88 + > 89 + <p className="text-sm text-blue-800 dark:text-blue-200">{heldMessage}</p> 66 90 </div> 67 91 )} 68 92
+1 -1
src/app/u/[handle]/page.test.tsx
··· 81 81 it('shows error for unknown handle', async () => { 82 82 render(<UserProfilePage params={{ handle: 'unknown.user.social' }} />) 83 83 await waitFor(() => { 84 - expect(screen.getByText(/api 404/i)).toBeInTheDocument() 84 + expect(screen.getByText(/user not found/i)).toBeInTheDocument() 85 85 }) 86 86 }) 87 87
+7 -3
src/components/reply-composer.test.tsx
··· 41 41 }), 42 42 })) 43 43 44 - vi.mock('@/lib/api/client', () => ({ 45 - createReply: (...args: unknown[]) => mockCreateReply(...args), 46 - })) 44 + vi.mock('@/lib/api/client', async (importOriginal) => { 45 + const actual = await importOriginal<typeof import('@/lib/api/client')>() 46 + return { 47 + ...actual, 48 + createReply: (...args: unknown[]) => mockCreateReply(...args), 49 + } 50 + }) 47 51 48 52 let mockOnboardingContext: OnboardingContextValue = createMockOnboardingContext() 49 53
+19 -6
src/components/reply-composer.tsx
··· 11 11 import { useAuth } from '@/hooks/use-auth' 12 12 import { useOnboardingContext } from '@/context/onboarding-context' 13 13 import { useToast } from '@/hooks/use-toast' 14 - import { createReply } from '@/lib/api/client' 14 + import { ApiError, createReply } from '@/lib/api/client' 15 15 import { MarkdownEditor } from '@/components/markdown-editor' 16 16 import { cn } from '@/lib/utils' 17 17 ··· 133 133 setSubmitting(true) 134 134 try { 135 135 const accessToken = getAccessToken() ?? '' 136 - await createReply( 136 + const result = await createReply( 137 137 topicUri, 138 138 { 139 139 content: trimmed, ··· 141 141 }, 142 142 accessToken 143 143 ) 144 + 144 145 setContent('') 145 146 setIsExpanded(false) 146 - onReplyCreated() 147 - toast({ title: 'Reply posted' }) 147 + 148 + if (result.moderationStatus === 'held') { 149 + toast({ 150 + title: 'Reply submitted', 151 + description: 'Your reply is pending moderator review and will appear once approved.', 152 + }) 153 + } else { 154 + onReplyCreated() 155 + toast({ title: 'Reply posted' }) 156 + } 148 157 } catch (err) { 149 - const message = err instanceof Error ? err.message : 'Failed to post reply' 150 - toast({ title: 'Error', description: message, variant: 'destructive' }) 158 + if (err instanceof ApiError && err.errorCode === 'Onboarding required') { 159 + ensureOnboarded() 160 + } else { 161 + const message = err instanceof Error ? err.message : 'Failed to post reply' 162 + toast({ title: 'Error', description: message, variant: 'destructive' }) 163 + } 151 164 } finally { 152 165 setSubmitting(false) 153 166 }
+40 -21
src/lib/api/client.ts
··· 18 18 CommunityPreferenceOverride, 19 19 CreatePageInput, 20 20 CreateTopicInput, 21 + CreateTopicResponse, 21 22 InitializeCommunityInput, 22 23 InitializeResponse, 23 24 Page, ··· 34 35 Reply, 35 36 RepliesResponse, 36 37 CreateReplyInput, 38 + CreateReplyResponse, 37 39 UpdateReplyInput, 38 40 SearchResponse, 39 41 NotificationsResponse, ··· 84 86 } 85 87 86 88 class ApiError extends Error { 89 + /** The parsed error code from the API response body (e.g., "Onboarding required") */ 90 + public readonly errorCode: string | undefined 91 + 87 92 constructor( 88 93 public readonly status: number, 89 - message: string 94 + message: string, 95 + errorCode?: string 90 96 ) { 91 97 super(message) 92 98 this.name = 'ApiError' 99 + this.errorCode = errorCode 93 100 } 94 101 } 95 102 103 + async function throwApiError(response: Response): Promise<never> { 104 + const body = await response.text().catch(() => 'Unknown error') 105 + let message = `Request failed (${response.status})` 106 + let errorCode: string | undefined 107 + 108 + try { 109 + const parsed = JSON.parse(body) as { error?: string } 110 + if (parsed.error) { 111 + message = parsed.error 112 + errorCode = parsed.error 113 + } 114 + } catch { 115 + if (body && body !== 'Unknown error') { 116 + message = body 117 + } 118 + } 119 + 120 + throw new ApiError(response.status, message, errorCode) 121 + } 122 + 96 123 async function apiFetch<T>(path: string, options: FetchOptions = {}): Promise<T> { 97 124 const url = `${API_URL}${path}` 98 125 const hasBody = options.body !== undefined ··· 107 134 }) 108 135 109 136 if (!response.ok) { 110 - const body = await response.text().catch(() => 'Unknown error') 111 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 137 + await throwApiError(response) 112 138 } 113 139 114 140 return response.json() as Promise<T> ··· 156 182 }) 157 183 158 184 if (!response.ok) { 159 - const body = await response.text().catch(() => 'Unknown error') 160 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 185 + await throwApiError(response) 161 186 } 162 187 163 188 return response.json() as Promise<AuthSession> ··· 172 197 }) 173 198 174 199 if (!response.ok && response.status !== 204) { 175 - const body = await response.text().catch(() => 'Unknown error') 176 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 200 + await throwApiError(response) 177 201 } 178 202 } 179 203 ··· 224 248 input: CreateTopicInput, 225 249 accessToken: string, 226 250 options?: FetchOptions 227 - ): Promise<Topic> { 228 - return apiFetch<Topic>('/api/topics', { 251 + ): Promise<CreateTopicResponse> { 252 + return apiFetch<CreateTopicResponse>('/api/topics', { 229 253 ...options, 230 254 method: 'POST', 231 255 headers: { ··· 276 300 input: CreateReplyInput, 277 301 accessToken: string, 278 302 options?: FetchOptions 279 - ): Promise<Reply> { 280 - return apiFetch<Reply>(`/api/topics/${encodeURIComponent(topicUri)}/replies`, { 303 + ): Promise<CreateReplyResponse> { 304 + return apiFetch<CreateReplyResponse>(`/api/topics/${encodeURIComponent(topicUri)}/replies`, { 281 305 ...options, 282 306 method: 'POST', 283 307 headers: { ··· 963 987 body: form, 964 988 }) 965 989 if (!response.ok) { 966 - const body = await response.text().catch(() => 'Unknown error') 967 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 990 + await throwApiError(response) 968 991 } 969 992 return response.json() as Promise<UploadResponse> 970 993 } ··· 983 1006 body: form, 984 1007 }) 985 1008 if (!response.ok) { 986 - const body = await response.text().catch(() => 'Unknown error') 987 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 1009 + await throwApiError(response) 988 1010 } 989 1011 return response.json() as Promise<UploadResponse> 990 1012 } ··· 1004 1026 body: form, 1005 1027 }) 1006 1028 if (!response.ok) { 1007 - const body = await response.text().catch(() => 'Unknown error') 1008 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 1029 + await throwApiError(response) 1009 1030 } 1010 1031 return response.json() as Promise<UploadResponse> 1011 1032 } ··· 1020 1041 body: form, 1021 1042 }) 1022 1043 if (!response.ok) { 1023 - const body = await response.text().catch(() => 'Unknown error') 1024 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 1044 + await throwApiError(response) 1025 1045 } 1026 1046 return response.json() as Promise<UploadResponse> 1027 1047 } ··· 1039 1059 body: form, 1040 1060 }) 1041 1061 if (!response.ok) { 1042 - const body = await response.text().catch(() => 'Unknown error') 1043 - throw new ApiError(response.status, `API ${response.status}: ${body}`) 1062 + await throwApiError(response) 1044 1063 } 1045 1064 return response.json() as Promise<UploadResponse> 1046 1065 }
+22
src/lib/api/types.ts
··· 149 149 tags?: string[] 150 150 } 151 151 152 + export type ModerationStatus = 'approved' | 'held' 153 + 154 + /** Slim response from POST /api/topics (differs from full Topic) */ 155 + export interface CreateTopicResponse { 156 + uri: string 157 + cid: string 158 + rkey: string 159 + title: string 160 + category: string 161 + moderationStatus: ModerationStatus 162 + createdAt: string 163 + } 164 + 152 165 // --- Replies --- 153 166 154 167 export interface Reply { ··· 187 200 export interface UpdateReplyInput { 188 201 content: string 189 202 labels?: string[] 203 + } 204 + 205 + /** Slim response from POST /api/topics/:uri/replies (differs from full Reply) */ 206 + export interface CreateReplyResponse { 207 + uri: string 208 + cid: string 209 + rkey: string 210 + moderationStatus: ModerationStatus 211 + createdAt: string 190 212 } 191 213 192 214 // --- Reactions ---