Community
안전한 공개 feed와 hosted 고객 댓글, 독립 readback을 완료합니다.
고객이 탈퇴해도 작성한 게시물과 댓글은 남습니다. 계정 개인정보 삭제 시 복사된 작성자 이름은 [삭제된 사용자]로 바뀌고 핸들과 아바타는 지워집니다. 앱의 개인정보 처리방침에 글의 보존과 작성자 정보 삭제 방식을 안내하는 것을 권장합니다.
준비
community와 그 의존 기능인 customers를 켜세요. 공개 상태이고, 모더레이션에서 숨김·반려되지 않았고, 잠기지 않았으며, allowsComments가 켜진 글을 준비하세요. 모더레이션과 지원 담당자도 정하세요.
공개 조회에는 Publishable Key를 씁니다. 댓글, 반응, 신고 같은 참여에는 같은 워크스페이스의 고객 JWT를 쓰며, Secret Key로 고객을 대신할 수 없습니다. 호스티드 로그인(client·origin·redirect → state/PKCE callback → Secure HttpOnly 세션 → me() → 호스티드 로그아웃)을 위해 Customers의 파일을 모두 복사하세요. 그 가이드의 legacy 호환 절은 direct 로그인을 쓰는 기존 앱에만 사용하세요.
예제는 Next.js App Router 앱에서 요청마다 실행됩니다. 확인한 버전과 구성은 빌드 옵션에 있습니다.
공개 읽기와 고객 참여 구현
세 파일을 모두 복사하세요. 피드는 글을 고르기 전에 목록이 비었는지 확인하고, 상세 결과가 null인 경우를 따로 처리하며, 공개 필드만 표시합니다. Server Action은 댓글을 만들기 전에 로그인한 사용자를 확인하고, 만든 뒤에는 댓글을 따로 조회합니다. 새 댓글은 기본적으로 published, approved이며 사전 검토 대기열은 없습니다. 이후의 모더레이션 변경이나 첫 페이지 크기 때문에 새 댓글이 조회 결과에 없을 수 있으니, 조회가 실제로 돌려준 내용을 보여 주세요.
// app/community/page.tsx
import { createClient } from '@01.software/sdk'
export const dynamic = 'force-dynamic'
export default async function CommunityPage() {
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const feed = await client.community.listPosts({ limit: 10 })
if (feed.docs.length === 0) return <p>No public posts yet.</p>
const post = await client.community.getPost({
postId: String(feed.docs[0].id),
})
if (!post) return <p>This post is no longer available.</p>
const comments = await client.community.listComments({
postId: String(post.id),
limit: 10,
})
return (
<main>
<h1>{post.title}</h1>
{comments.docs.length === 0 ? (
<p>No public comments yet.</p>
) : (
<ul>
{comments.docs.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
)}
<a href={`/community/${encodeURIComponent(String(post.id))}`}>
Join the conversation
</a>
</main>
)
}// app/community/[postId]/actions.ts
'use server'
import { currentCustomer } from '@/lib/customer'
export async function addComment(postId: string, form: FormData) {
const { client, customer } = await currentCustomer()
if (!customer) return { status: 'sign_in_required' }
const body = String(form.get('body') ?? '').trim()
if (!body || body.length > 1000) return { status: 'invalid_comment' }
// Author/tenant/moderation fields are never supplied by the app.
const created = await client.community.createComment({ postId, body })
const comments = await client.community.listComments({
postId,
limit: 10,
sort: '-createdAt',
})
return {
status: comments.docs.some((comment) => comment.id === created.id)
? 'visible'
: 'submitted',
}
}// app/community/[postId]/page.tsx
import { createClient } from '@01.software/sdk'
import { notFound } from 'next/navigation'
import { revalidatePath } from 'next/cache'
import { addComment } from './actions'
export const dynamic = 'force-dynamic'
export default async function Conversation({
params,
}: {
params: Promise<{ postId: string }>
}) {
const { postId } = await params
const client = createClient({
publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
apiUrl: process.env.SOFTWARE_API_URL,
})
const post = await client.community.getPost({ postId })
if (!post) return notFound()
const comments = await client.community.listComments({
postId,
limit: 10,
sort: '-createdAt',
})
async function submit(form: FormData) {
'use server'
const result = await addComment(postId, form)
if (result.status === 'sign_in_required') throw new Error('Sign in first')
if (result.status === 'invalid_comment')
throw new Error('Write a comment of 1–1000 characters')
revalidatePath(`/community/${encodeURIComponent(postId)}`)
}
return (
<main>
<h1>{post.title}</h1>
{comments.docs.length === 0 ? (
<p>No public comments yet.</p>
) : (
<ul>
{comments.docs.map((comment) => (
<li key={comment.id}>{comment.body}</li>
))}
</ul>
)}
<a href="/account">Account</a>
<form action={submit}>
<textarea name="body" required maxLength={1000} aria-label="Comment" />
<button>Submit comment</button>
</form>
</main>
)
}정상·빈 결과·실패
글이 0개일 때와 공개 글이 하나 있을 때 각각 /community를 여세요. 피드와 상세 사이에 사라진 글은 '볼 수 없음' 상태로 표시합니다. 대화를 열고 /account에서 로그인한 뒤 댓글을 하나 올리고, listComments()와 새로고침한 페이지에서 찾아보세요. 댓글 목록이 비어 있는 것과 요청이 실패한 것은 다릅니다. 첫 페이지에 새 댓글이 없을 수 있고, submitted는 거절됐다는 뜻도 게시됐다는 뜻도 아닙니다. 다시 쓰기 전에 알맞은 페이지를 조회하거나 모더레이션 지원팀에 문의하세요.
| 결과 | 처리 |
|---|---|
| 없는 글, 모더레이션에서 숨김·반려된 글, 다른 워크스페이스의 글 | 상세는 not_found 응답일 때만 null을 반환합니다. |
| 그 밖의 404(예: 잘못된 배포 URL), 인증·워크스페이스·기능·네트워크 오류 | 그대로 전달하세요. 빈 피드로 바꾸지 마세요. |
읽기와 동작에 오류 화면을 두세요. 비로그인·다른 워크스페이스에서의 작성, 숨김·반려된 글, 차단된 고객, 잠긴 글, 댓글이 꺼진 글을 시험하세요. 거부되면 새 댓글이 생기지 않아야 합니다. 작성에 성공했다면 HTTP 상태만 믿지 말고 댓글을 조회하세요.
작성자, 프로필, 워크스페이스는 서버가 검증한 고객에게서 정합니다. 앱은 모더레이션, 작성자, 카운터, 출처 필드를 보내지 않습니다. 답글은 같은 글에 속해야 하고 중첩 한도를 지켜야 합니다. 원시 posts, comments, 반응, 신고, 차단은 서버 전용 컬렉션이며 브라우저에서 대신 쓸 수 없습니다. 다른 클라이언트를 써도 고객이 모더레이션 권한을 얻을 수 없습니다.
다음 액션
모더레이션과 지원은 콘텐츠 운영, import와 peer 의존성은 SDK, 브라우저 origin과 호스티드 세션 확인은 배포를 참고하세요. 로그아웃할 때 앱 쿠키를 지우는 것과 서버에서 호스티드 세션을 철회하는 것은 다릅니다. Customers를 참고하세요.