From 4981efc735985389afce19cb1623d090aece8c84 Mon Sep 17 00:00:00 2001 From: shamblashini Date: Fri, 28 Aug 2026 01:54:37 +0200 Subject: [PATCH] feat: board-level author-only reply policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board's access block gains an optional replyPolicy key ('anyone' | 'author-only'; absent = 'anyone', so no migration). On an author-only board only each post's own author and team members can comment, while everyone the view tier admits still reads every thread — each thread stays a publicly readable conversation between its author and the team. Enforced centrally in canCreateComment (covers portal, widget, REST v1, and MCP), surfaced per-post to the portal and widget composers via a new canCommentOnPost capability, and configured from the board's Access settings tab. Portal/widget show an explanatory notice in place of the composer; strings added to all nine locale catalogs. --- .../__tests__/board-access-form.test.tsx | 97 +++++++++++++++++ .../settings/boards/board-access-form.tsx | 76 ++++++++++++- .../public/auth-comments-section.tsx | 8 ++ .../src/components/public/comment-thread.tsx | 30 +++++- .../public/post-detail/comments-section.tsx | 6 ++ .../components/widget/widget-post-detail.tsx | 19 +++- .../src/lib/client/queries/portal-detail.ts | 9 +- .../lib/server/domains/posts/post.access.ts | 21 ++++ .../__tests__/board-access-schema.test.ts | 33 ++++++ apps/web/src/lib/server/functions/portal.ts | 65 ++++++++--- .../__tests__/board-capabilities.test.ts | 64 ++++++++++- .../lib/server/policy/__tests__/posts.test.ts | 101 ++++++++++++++++++ apps/web/src/lib/server/policy/posts.ts | 84 +++++++++++++-- apps/web/src/lib/shared/db-types.ts | 8 +- .../shared/schemas/__tests__/boards.test.ts | 18 ++++ apps/web/src/lib/shared/schemas/boards.ts | 14 +++ apps/web/src/locales/ar.json | 2 + apps/web/src/locales/de.json | 2 + apps/web/src/locales/en.json | 2 + apps/web/src/locales/es.json | 2 + apps/web/src/locales/fr.json | 2 + apps/web/src/locales/pt-br.json | 2 + apps/web/src/locales/ru.json | 2 + apps/web/src/locales/zh-cn.json | 2 + apps/web/src/locales/zh-tw.json | 2 + packages/db/src/types.ts | 26 +++++ 26 files changed, 659 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/admin/settings/boards/__tests__/board-access-form.test.tsx b/apps/web/src/components/admin/settings/boards/__tests__/board-access-form.test.tsx index 9af88edd3b..84d326b850 100644 --- a/apps/web/src/components/admin/settings/boards/__tests__/board-access-form.test.tsx +++ b/apps/web/src/components/admin/settings/boards/__tests__/board-access-form.test.tsx @@ -13,6 +13,8 @@ * - Auto-bump when workspace flips off while a cell sits on Anonymous * - Save payload preserves `moderation` round-trip (passthrough only — * editing moderation lives in ``) + * - Replies switch reads/writes `access.replyPolicy` and round-trips + * every other access key * * The mutation, segments, and portalConfig queries are mocked. The * portalConfig mock is mutable so tests can flip workspace flags between @@ -500,6 +502,101 @@ describe(' save', () => { }) }) +// --------------------------------------------------------------------------- +// Replies (access.replyPolicy) +// --------------------------------------------------------------------------- + +describe(' reply policy', () => { + const REPLY_LABEL = 'Only the post author and team members can reply' + + function replySwitch() { + return screen.getByRole('switch', { name: REPLY_LABEL }) + } + + it('renders the Replies switch off when access.replyPolicy is absent', () => { + renderForm(PUBLIC_ACCESS) + expect(replySwitch()).toHaveAttribute('data-state', 'unchecked') + }) + + it("renders the Replies switch off for an explicit replyPolicy: 'anyone'", () => { + renderForm({ ...PUBLIC_ACCESS, replyPolicy: 'anyone' }) + expect(replySwitch()).toHaveAttribute('data-state', 'unchecked') + }) + + it("renders the Replies switch on for replyPolicy: 'author-only'", () => { + renderForm({ ...PUBLIC_ACCESS, replyPolicy: 'author-only' }) + expect(replySwitch()).toHaveAttribute('data-state', 'checked') + }) + + it('toggling the switch marks the form dirty and surfaces the save dock', () => { + renderForm(PUBLIC_ACCESS) + expect( + screen.getByRole('region', { name: /save changes/i }).getAttribute('data-dirty') + ).toBeNull() + fireEvent.click(replySwitch()) + expect(screen.getByRole('region', { name: /save changes/i }).getAttribute('data-dirty')).toBe( + 'true' + ) + expect(replySwitch()).toHaveAttribute('data-state', 'checked') + }) + + it("saves replyPolicy: 'author-only' while every other access key is unchanged", async () => { + const access: BoardAccess = { + view: 'anonymous', + vote: 'authenticated', + comment: 'authenticated', + submit: 'segments', + segments: { view: [], vote: [], comment: [], submit: ['seg_alpha'] }, + moderation: { anonPosts: 'on', signedPosts: 'inherit', comments: 'off' }, + } + renderForm(access) + fireEvent.click(replySwitch()) + fireEvent.click(screen.getByRole('button', { name: /save changes/i })) + await waitFor(() => + expect(mutate).toHaveBeenCalledWith({ + boardId: BOARD_ID, + access: { ...access, replyPolicy: 'author-only' }, + }) + ) + }) + + it("switching off writes an explicit replyPolicy: 'anyone'", async () => { + const access: BoardAccess = { ...PUBLIC_ACCESS, replyPolicy: 'author-only' } + renderForm(access) + fireEvent.click(replySwitch()) + expect(replySwitch()).toHaveAttribute('data-state', 'unchecked') + fireEvent.click(screen.getByRole('button', { name: /save changes/i })) + await waitFor(() => + expect(mutate).toHaveBeenCalledWith({ + boardId: BOARD_ID, + access: { ...access, replyPolicy: 'anyone' }, + }) + ) + }) + + it('saving a tier change preserves an existing author-only replyPolicy', async () => { + const access: BoardAccess = { ...PUBLIC_ACCESS, replyPolicy: 'author-only' } + renderForm(access) + // Edit the matrix only — the reply switch is untouched. + clickTierCell('Comment', 'Team only') + fireEvent.click(screen.getByRole('button', { name: /save changes/i })) + await waitFor(() => + expect(mutate).toHaveBeenCalledWith({ + boardId: BOARD_ID, + access: { ...access, comment: 'team' }, + }) + ) + }) + + it('Discard restores the original reply policy', () => { + renderForm({ ...PUBLIC_ACCESS, replyPolicy: 'author-only' }) + fireEvent.click(replySwitch()) + expect(replySwitch()).toHaveAttribute('data-state', 'unchecked') + fireEvent.click(screen.getByRole('button', { name: /discard/i })) + expect(replySwitch()).toHaveAttribute('data-state', 'checked') + }) +}) + describe('PRESET_META ↔ accessForPreset agreement (round-trip guard)', () => { // The UI preset tiles (PRESET_META, which drives deriveActivePreset) and // the server/optimistic source of truth (accessForPreset) must encode the diff --git a/apps/web/src/components/admin/settings/boards/board-access-form.tsx b/apps/web/src/components/admin/settings/boards/board-access-form.tsx index 5c0b52724e..f0e9cdee46 100644 --- a/apps/web/src/components/admin/settings/boards/board-access-form.tsx +++ b/apps/web/src/components/admin/settings/boards/board-access-form.tsx @@ -12,6 +12,7 @@ import { useForm } from 'react-hook-form' import { Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { + ChatBubbleLeftEllipsisIcon, ChatBubbleLeftIcon, CheckIcon, ChevronDownIcon, @@ -29,6 +30,7 @@ import { UsersIcon, } from '@heroicons/react/24/solid' import { Checkbox } from '@/components/ui/checkbox' +import { Switch } from '@/components/ui/switch' import { BoardSettingsSaveDock } from './board-settings-save-dock' import { FormError } from '@/components/shared/form-error' import { useUpdateBoardAccess } from '@/lib/client/mutations' @@ -41,6 +43,7 @@ import { type AccessTier, type BoardAccess, DEFAULT_BOARD_ACCESS, + resolveReplyPolicy, } from '@/lib/shared/db-types' import { accessForPreset } from '@/lib/shared/schemas/boards' @@ -61,6 +64,9 @@ import { accessForPreset } from '@/lib/shared/schemas/boards' * ceiling: when off, the `anonymous` cell on vote/comment/submit is * disabled (striped + globe icon) and an effect auto-bumps any cell * currently on `anonymous` up to `authenticated`. + * - A "Replies" switch below the matrix edits `access.replyPolicy` + * (absent/`anyone` vs `author-only`). It shares this form's dirty + * state and save dock — the Access tab has exactly one of each. * * The persisted shape is `BoardAccess` (see @/lib/shared/db-types). */ @@ -358,12 +364,25 @@ export function BoardAccessForm({ board }: BoardAccessFormProps) { [form] ) + // `replyPolicy` is an optional key on BoardAccess (absent == 'anyone'), so + // it may be missing from the form's defaults. Writing it explicitly on + // toggle keeps the saved payload unambiguous in both directions. + const handleReplyPolicyChange = useCallback( + (authorOnly: boolean) => { + form.setValue('replyPolicy', authorOnly ? 'author-only' : 'anyone', { shouldDirty: true }) + }, + [form] + ) + const onSubmit = useCallback( (next: FormShape) => { if (segsError) return - mutation.mutate({ boardId: board.id, access: next }) + // Spread the server-side access under the form values so any key this + // form doesn't edit (moderation, and anything added later) round-trips + // verbatim instead of being dropped by a partial save. + mutation.mutate({ boardId: board.id, access: { ...board.access, ...next } }) }, - [board.id, mutation, segsError] + [board.id, board.access, mutation, segsError] ) const handleDiscard = useCallback(() => { @@ -437,6 +456,14 @@ export function BoardAccessForm({ board }: BoardAccessFormProps) { )} +
+ Replies + +
+

Team members and admins always have full access — they bypass these rules. @@ -453,6 +480,51 @@ export function BoardAccessForm({ board }: BoardAccessFormProps) { ) } +// ─── Replies (author-only) row ─────────────────────────────────────── + +interface ReplyPolicyRowProps { + authorOnly: boolean + onChange: (authorOnly: boolean) => void +} + +const REPLY_POLICY_LABEL = 'Only the post author and team members can reply' + +/** + * `access.replyPolicy` toggle. It sits beside the matrix rather than in it + * because it is not a tier: the Comment row still decides who may reply at + * all, and this narrows that set per post. Rendered inside the access form so + * it shares one dirty state and one save dock with the matrix. + */ +function ReplyPolicyRow({ authorOnly, onChange }: ReplyPolicyRowProps) { + return ( +

+ + + +
+
+ {REPLY_POLICY_LABEL} + {authorOnly && ( + + On + + )} +
+
+ Anyone the access tiers allow can still view and open posts, but each post's thread + stays between its author and your team. +
+
+ +
+ ) +} + // ─── Preset cards row ──────────────────────────────────────────────── interface PresetGridProps { diff --git a/apps/web/src/components/public/auth-comments-section.tsx b/apps/web/src/components/public/auth-comments-section.tsx index 8e41ffeaf8..b39c94ce82 100644 --- a/apps/web/src/components/public/auth-comments-section.tsx +++ b/apps/web/src/components/public/auth-comments-section.tsx @@ -7,6 +7,7 @@ import { useAuthBroadcast } from '@/lib/client/hooks/use-auth-broadcast' import { useEnsureAnonSession } from '@/lib/client/hooks/use-ensure-anon-session' import { useCreateComment } from '@/lib/client/mutations' import type { PublicCommentView } from '@/lib/client/queries/portal-detail' +import type { ReplyPolicy } from '@/lib/shared/db-types' import type { PostCommentId, PostId, PrincipalId } from '@quackback/ids' import { resolveCommentingState } from '@/components/public/comment-permission' @@ -15,6 +16,11 @@ interface AuthCommentsSectionProps { comments: PublicCommentView[] /** Server-determined: user is authenticated member who can comment */ allowCommenting?: boolean + /** + * Server-reported board reply rule. Only used to explain a denial; the + * decision itself already lives in `allowCommenting`. Undefined = 'anyone'. + */ + replyPolicy?: ReplyPolicy user?: { name: string | null; email: string; principalId?: PrincipalId } /** Message to show when comments are locked (overrides "Sign in to comment") */ lockedMessage?: string @@ -65,6 +71,7 @@ export function AuthCommentsSection({ postId, comments, allowCommenting: serverAllowCommenting = false, + replyPolicy, user: serverUser, lockedMessage, pinnedCommentId, @@ -156,6 +163,7 @@ export function AuthCommentsSection({ comments={comments} allowCommenting={allowCommenting} noAccess={noAccess} + replyPolicy={replyPolicy} user={userData} teamBadgeLogoUrl={settings?.brandingData?.logoUrl ?? undefined} teamBadgeLabel={settings?.brandingData?.name ?? settings?.name ?? undefined} diff --git a/apps/web/src/components/public/comment-thread.tsx b/apps/web/src/components/public/comment-thread.tsx index 09db2a9c70..d2d62fc4b9 100644 --- a/apps/web/src/components/public/comment-thread.tsx +++ b/apps/web/src/components/public/comment-thread.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { useIntl } from 'react-intl' +import { FormattedMessage, useIntl } from 'react-intl' import { ArrowRightIcon, ArrowUturnLeftIcon, @@ -33,7 +33,7 @@ import { CommentForm, type CreateCommentMutation } from './comment-form' import { RichTextEditor } from '@/components/ui/rich-text-editor' import { COMMENT_EDITOR_FEATURES } from './comment-editor-features' import { commentMarkdownToTiptapJson } from '@/lib/server/markdown-tiptap' -import type { TiptapContent } from '@/lib/shared/db-types' +import type { ReplyPolicy, TiptapContent } from '@/lib/shared/db-types' import type { PostCommentId, PostId, PrincipalId } from '@quackback/ids' import { InlineModerationActions } from '@/components/shared/inline-moderation-actions' import { useApproveComment, useRejectComment } from '@/lib/client/mutations/moderation' @@ -113,6 +113,12 @@ interface CommentThreadProps { * authn): show "You don't have access" instead of a sign-in prompt. */ noAccess?: boolean + /** + * The board's reply rule, as reported by the server. `'author-only'` narrows + * the `noAccess` notice from the generic tier denial to "this thread belongs + * to its author". Undefined (admin mode, legacy payloads) means `'anyone'`. + */ + replyPolicy?: ReplyPolicy user?: { name: string | null; email: string; principalId?: PrincipalId } /** Logo URL for the team badge (from branding settings) */ teamBadgeLogoUrl?: string @@ -166,6 +172,7 @@ export function CommentThread({ comments, allowCommenting = true, noAccess = false, + replyPolicy, user, teamBadgeLogoUrl, teamBadgeLabel, @@ -226,6 +233,25 @@ export function CommentThread({ ) } + // Signed in but denied because the board only lets each post's own author + // (and the team) reply. Name that rule instead of the generic tier denial — + // the viewer's account is fine, this thread just isn't theirs. Signed-out + // viewers fall through to the sign-in CTA below: they may yet sign in as + // the author. + if (noAccess && replyPolicy === 'author-only') { + return ( +
+ +

+ +

+
+ ) + } + // Signed in but denied by the board's comment tier (segments/team) — an // authorization failure, not authentication. State it; no sign-in affordance. if (noAccess) { diff --git a/apps/web/src/components/public/post-detail/comments-section.tsx b/apps/web/src/components/public/post-detail/comments-section.tsx index cd886d73b1..94c1749cbb 100644 --- a/apps/web/src/components/public/post-detail/comments-section.tsx +++ b/apps/web/src/components/public/post-detail/comments-section.tsx @@ -149,6 +149,11 @@ export function CommentsSection({ // Use server-detected team membership when not in explicit admin mode const effectiveIsTeamMember = isTeamMember ?? data?.isTeamMember ?? false + // Why the composer is closed on an author-only board. Undefined in admin mode + // (the query is disabled there), which the thread reads as 'anyone' — admin + // commenting is unaffected by the board's reply rule. + const replyPolicy = data?.replyPolicy + return (
{/* Identified viewer denied by the board's comment tier (segments/team) - — authorization, not auth: state it, no form or login prompt. */} + — authorization, not auth: state it, no form or login prompt. An + author-only board names that rule instead: the account is fine, + this thread just isn't the viewer's. */} {!post.isCommentsLocked && commentNoAccess && (

- + {post.replyPolicy === 'author-only' ? ( + + ) : ( + + )}

)} diff --git a/apps/web/src/lib/client/queries/portal-detail.ts b/apps/web/src/lib/client/queries/portal-detail.ts index 1f8d6400a3..81c2d38f27 100644 --- a/apps/web/src/lib/client/queries/portal-detail.ts +++ b/apps/web/src/lib/client/queries/portal-detail.ts @@ -7,7 +7,7 @@ import { } from '@/lib/server/functions/portal' import { getVoteSidebarDataFn, getVotedPostsFn } from '@/lib/server/functions/public-posts' import type { CommentReactionCount, CommentStatusChange } from '@/lib/shared' -import type { TiptapContent } from '@/lib/shared/db-types' +import type { ReplyPolicy, TiptapContent } from '@/lib/shared/db-types' /** * Comment type for client components (Date fields may be strings after serialization) @@ -91,6 +91,13 @@ export interface PublicPostDetailView { */ canVote?: boolean canComment?: boolean + /** + * The board's reply rule. `'author-only'` means only the post's own author + * and the team may reply — `canComment` already accounts for it; this field + * exists so a denied viewer can be told which rule closed the composer. + * Undefined on legacy/cached payloads — treat undefined as `'anyone'`. + */ + replyPolicy?: ReplyPolicy /** Merge/deduplication: info about canonical post if this is a merged duplicate */ mergeInfo?: { canonicalPostId: string diff --git a/apps/web/src/lib/server/domains/posts/post.access.ts b/apps/web/src/lib/server/domains/posts/post.access.ts index 575550c143..db2483a38e 100644 --- a/apps/web/src/lib/server/domains/posts/post.access.ts +++ b/apps/web/src/lib/server/domains/posts/post.access.ts @@ -36,6 +36,27 @@ export async function loadBoardAccessForPost(postId: PostId) { return rows[0]?.access ?? null } +/** + * Same resolution as {@link loadBoardAccessForPost}, plus the post's own + * author and moderation state — the inputs a PER-POST comment capability + * needs (`canCommentOnPost`): an author-only board decides the reply right + * against the real post's author, which the board matrix alone can't answer. + * Returns null on the same soft-delete/missing conditions. + */ +export async function loadCommentContextForPost(postId: PostId) { + const rows = await db + .select({ + access: boards.access, + moderationState: posts.moderationState, + principalId: posts.principalId, + }) + .from(posts) + .innerJoin(boards, eq(posts.boardId, boards.id)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt), isNull(boards.deletedAt))) + .limit(1) + return rows[0] ?? null +} + export async function assertPostViewable(postId: PostId, actor: Actor): Promise { // Fetch only the fields the policy needs. Soft-deleted post or board // is treated as "doesn't exist" — the join uses INNER + isNull diff --git a/apps/web/src/lib/server/functions/__tests__/board-access-schema.test.ts b/apps/web/src/lib/server/functions/__tests__/board-access-schema.test.ts index ff2898118c..447fb38af7 100644 --- a/apps/web/src/lib/server/functions/__tests__/board-access-schema.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/board-access-schema.test.ts @@ -191,6 +191,39 @@ describe('boardAccessSchema — vote action invariants', () => { }) }) +describe('boardAccessSchema — replyPolicy', () => { + it('accepts a payload with no replyPolicy key and leaves it absent (absent = anyone)', () => { + const parsed = boardAccessSchema.parse(baseValid) + expect('replyPolicy' in parsed).toBe(false) + }) + + it("accepts and round-trips replyPolicy='author-only'", () => { + const parsed = boardAccessSchema.parse({ ...baseValid, replyPolicy: 'author-only' }) + expect(parsed.replyPolicy).toBe('author-only') + }) + + it("accepts an explicit replyPolicy='anyone'", () => { + const parsed = boardAccessSchema.parse({ ...baseValid, replyPolicy: 'anyone' }) + expect(parsed.replyPolicy).toBe('anyone') + }) + + it('rejects an unknown reply policy', () => { + expect(() => + boardAccessSchema.parse({ ...baseValid, replyPolicy: 'authors-only' as never }) + ).toThrow() + }) + + it('rejects null (omitting the key is how a board says "no restriction")', () => { + expect(() => boardAccessSchema.parse({ ...baseValid, replyPolicy: null as never })).toThrow() + }) + + it('carries no tier-rank invariant — author-only rides any comment tier', () => { + expect(() => + boardAccessSchema.parse({ ...baseValid, comment: 'team', replyPolicy: 'author-only' }) + ).not.toThrow() + }) +}) + describe('boardAccessSchema — tier enum invariants', () => { it('rejects unknown tier name', () => { expect(() => boardAccessSchema.parse({ ...baseValid, view: 'admin' as never })).toThrow() diff --git a/apps/web/src/lib/server/functions/portal.ts b/apps/web/src/lib/server/functions/portal.ts index 43f486876e..efa92bf5d0 100644 --- a/apps/web/src/lib/server/functions/portal.ts +++ b/apps/web/src/lib/server/functions/portal.ts @@ -11,6 +11,9 @@ import { type UserId, } from '@quackback/ids' import type { BoardSettings, BoardAccess } from '@/lib/server/db' +// Pure helper + its type, imported through the client-safe re-export so suites +// that mock '@/lib/server/db' don't have to stub them. +import { resolveReplyPolicy, type ReplyPolicy } from '@/lib/shared/db-types' import type { Actor } from '@/lib/server/policy' import { getOptionalAuth, @@ -379,9 +382,17 @@ export const fetchPublicPostDetail = createServerFn({ method: 'GET' }) // per-board tier + the workspace anonymous ceiling (non-user actors only), // so the UI never advertises a vote/comment CTA the board's tier rejects // (#191). canSubmit is unused on the detail view. - const { boardCapabilitiesForActor } = await import('@/lib/server/policy') - const { canVote, canComment } = boardCapabilitiesForActor( + const { boardCapabilitiesForActor, canCommentOnPost } = await import('@/lib/server/policy') + const { canVote } = boardCapabilitiesForActor(actor, result.boardAccess, allowAnonymous) + // canComment is per-POST: on an author-only board the reply right turns on + // this post's own author, which the board-level capability can't see. View + // is already proven (getPublicPostDetail returned a row for this actor), so + // moderationState='published' keeps the inner view check a no-op and the + // decision reflects the comment gates — same convention as the vote gate in + // public-posts.ts. + const canComment = canCommentOnPost( actor, + { moderationState: 'published', principalId: result.principalId }, result.boardAccess, allowAnonymous ) @@ -404,6 +415,11 @@ export const fetchPublicPostDetail = createServerFn({ method: 'GET' }) mergedPostCount: mergedPostsList.length > 0 ? mergedPostsList.length : undefined, canVote, canComment, + // The board's reply rule, so a denied viewer can be told WHY the composer + // is closed ("only the author and the team can reply") instead of getting + // the generic no-access notice. Safe to expose: it is a public property of + // the board, unlike the access matrix stripped above. + replyPolicy: resolveReplyPolicy(result.boardAccess), } }) @@ -653,7 +669,15 @@ export const getCommentsSectionDataFn = createServerFn({ method: 'GET' }) .validator(getCommentsSectionDataSchema) .handler(async ({ data }) => { log.debug({ post_id: data.postId }, 'get comments section data') - const denied = { isMember: false, isTeamMember: false, canComment: false, user: undefined } + // replyPolicy rides the denied shape too, so the response type stays one + // object rather than a union the client has to narrow before reading it. + const denied = { + isMember: false, + isTeamMember: false, + canComment: false, + user: undefined, + replyPolicy: 'anyone' as ReplyPolicy, + } const postId = data.postId as PostId // Portal-visibility gate: a caller who can't see the portal must not @@ -674,16 +698,17 @@ export const getCommentsSectionDataFn = createServerFn({ method: 'GET' }) throw err } - // Per-board comment capability for the real actor, composed with the - // workspace anonymous ceiling. boardCapabilitiesForActor is the single - // source of truth the portal + widget UIs share, so the CTA can't desync - // from the server-side canCreateComment gate (it passes a published, - // unlocked post internally — assertPostViewable already proved view, and - // comments-locked is handled by the component's lockedMessage). - const { loadBoardAccessForPost } = await import('@/lib/server/domains/posts/post.access') - const { boardCapabilitiesForActor } = await import('@/lib/server/policy') - const boardAccess = await loadBoardAccessForPost(postId) - if (!boardAccess) return denied + // Per-POST comment capability for the real actor, composed with the + // workspace anonymous ceiling. canCommentOnPost is the single source of + // truth the portal + widget UIs share, so the CTA can't desync from the + // server-side canCreateComment gate. It needs the post's own author (an + // author-only board decides the reply right against it) alongside the + // board matrix, so the row carries all three; comments-locked stays out + // and is handled by the component's lockedMessage. + const { loadCommentContextForPost } = await import('@/lib/server/domains/posts/post.access') + const { canCommentOnPost } = await import('@/lib/server/policy') + const commentContext = await loadCommentContextForPost(postId) + if (!commentContext) return denied // The workspace anonymous ceiling only applies to non-user actors, so // only real anonymous / no-session viewers need the (uncached) config @@ -694,7 +719,15 @@ export const getCommentsSectionDataFn = createServerFn({ method: 'GET' }) if (actor.principalType !== 'user') { allowAnonymous = await loadAllowAnonymous() } - const canComment = boardCapabilitiesForActor(actor, boardAccess, allowAnonymous).canComment + const canComment = canCommentOnPost( + actor, + { + moderationState: commentContext.moderationState, + principalId: commentContext.principalId, + }, + commentContext.access, + allowAnonymous + ) const isMember = !!(ctx?.user && ctx?.principal) const isTeamMember = @@ -707,6 +740,10 @@ export const getCommentsSectionDataFn = createServerFn({ method: 'GET' }) user: isMember ? { name: ctx.user.name, email: ctx.user.email, principalId: ctx.principal.id } : undefined, + // Why the composer is closed, when it is: an author-only board tells the + // signed-in non-author that the thread is the author's, instead of the + // generic "you can't comment here" notice. + replyPolicy: resolveReplyPolicy(commentContext.access), } }) diff --git a/apps/web/src/lib/server/policy/__tests__/board-capabilities.test.ts b/apps/web/src/lib/server/policy/__tests__/board-capabilities.test.ts index c2b7b6756c..8e97b50851 100644 --- a/apps/web/src/lib/server/policy/__tests__/board-capabilities.test.ts +++ b/apps/web/src/lib/server/policy/__tests__/board-capabilities.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { boardCapabilitiesForActor, type Actor } from '@/lib/server/policy' +import { boardCapabilitiesForActor, canCommentOnPost, type Actor } from '@/lib/server/policy' import type { BoardAccess } from '@/lib/server/db' // Per-board submit/vote/comment capability for the current viewer, composed @@ -97,6 +97,21 @@ describe('boardCapabilitiesForActor', () => { expect(caps).toEqual({ canSubmit: true, canVote: true, canComment: true }) }) + it('ignores replyPolicy — an author-only board keeps the tier-based capability', () => { + // replyPolicy is a per-post concern (a non-team user CAN reply on their + // OWN post), so it must not change the board-level answer. canCommentOnPost + // below is where it lands. + const authorOnly = makeAccess({ replyPolicy: 'author-only' }) + expect(boardCapabilitiesForActor(USER, authorOnly, true)).toEqual( + boardCapabilitiesForActor(USER, makeAccess(), true) + ) + expect(boardCapabilitiesForActor(ANON, authorOnly, true)).toEqual({ + canSubmit: true, + canVote: true, + canComment: true, + }) + }) + it('gates submit, vote and comment independently per tier', () => { // Vote open to anon, comment requires sign-in, submit requires sign-in. const access = makeAccess({ @@ -111,3 +126,50 @@ describe('boardCapabilitiesForActor', () => { }) }) }) + +// The per-POST capability: same composition as the board-level canComment +// (tier + workspace anonymous ceiling) plus the post's own author, which is +// what an author-only board decides on. + +const OTHER_AUTHOR = 'principal_other' as Actor['principalId'] + +const publishedBy = (principalId: Actor['principalId']) => ({ + moderationState: 'published' as const, + principalId, +}) + +describe('canCommentOnPost', () => { + it('matches the board capability on a board with no reply policy', () => { + expect(canCommentOnPost(USER, publishedBy(OTHER_AUTHOR), makeAccess(), true)).toBe( + boardCapabilitiesForActor(USER, makeAccess(), true).canComment + ) + }) + + it('author-only: the post author may reply, another signed-in user may not', () => { + const access = makeAccess({ replyPolicy: 'author-only' }) + expect(canCommentOnPost(USER, publishedBy(USER.principalId), access, true)).toBe(true) + expect(canCommentOnPost(USER, publishedBy(OTHER_AUTHOR), access, true)).toBe(false) + }) + + it('author-only never blocks a team member', () => { + const access = makeAccess({ replyPolicy: 'author-only' }) + expect(canCommentOnPost(TEAM, publishedBy(OTHER_AUTHOR), access, false)).toBe(true) + }) + + it('author-only denies an anonymous viewer even on an author-less post', () => { + const access = makeAccess({ replyPolicy: 'author-only' }) + expect(canCommentOnPost(ANON, publishedBy(null), access, true)).toBe(false) + }) + + it('still applies the workspace anonymous ceiling to non-user actors', () => { + expect(canCommentOnPost(ANON, publishedBy(null), makeAccess(), false)).toBe(false) + expect(canCommentOnPost(ANON, publishedBy(null), makeAccess(), true)).toBe(true) + }) + + it('carries the real moderation state: the author may reply on their own pending post', () => { + const ownPending = { moderationState: 'pending' as const, principalId: USER.principalId } + expect(canCommentOnPost(USER, ownPending, makeAccess(), true)).toBe(true) + const othersPending = { moderationState: 'pending' as const, principalId: OTHER_AUTHOR } + expect(canCommentOnPost(USER, othersPending, makeAccess(), true)).toBe(false) + }) +}) diff --git a/apps/web/src/lib/server/policy/__tests__/posts.test.ts b/apps/web/src/lib/server/policy/__tests__/posts.test.ts index 0e09b4bbbe..d779be49cf 100644 --- a/apps/web/src/lib/server/policy/__tests__/posts.test.ts +++ b/apps/web/src/lib/server/policy/__tests__/posts.test.ts @@ -607,6 +607,107 @@ describe('canCreateComment — isCommentsLocked gate', () => { }) }) +describe('canCreateComment — author-only reply policy', () => { + // An author-only board keeps every thread readable by whoever the view tier + // admits, but only the post's own author (and the team) may answer. + const AUTHOR_ONLY_DENY = 'Only the post author and team members can reply on this board' + const OTHER = 'p_other' as PrincipalId + + const authorOnly = (overrides: Partial = {}): { access: BoardAccess } => ({ + access: { ...mkAccess('anonymous'), replyPolicy: 'author-only', ...overrides }, + }) + const post = (principalId: PrincipalId | null, isCommentsLocked = false) => ({ + moderationState: 'published' as ModerationState, + principalId, + isCommentsLocked, + }) + + it('the post author CAN reply on their own thread', () => { + expect(canCreateComment(portal, post(portal.principalId), authorOnly(), 'none')).toEqual({ + allowed: true, + requiresApproval: false, + }) + }) + + it('another signed-in user is denied with the author-only reason', () => { + const d = canCreateComment(portal, post(OTHER), authorOnly(), 'none') + expect(d.allowed).toBe(false) + if (!d.allowed) expect(d.reason).toBe(AUTHOR_ONLY_DENY) + }) + + it('admin and member reply on anyone’s thread (same bypass as the comments lock)', () => { + expect(canCreateComment(admin, post(OTHER), authorOnly(), 'none').allowed).toBe(true) + expect(canCreateComment(member, post(OTHER), authorOnly(), 'none').allowed).toBe(true) + }) + + it('an anonymous viewer is denied even on an anonymously-authored post (null !== null)', () => { + // Critical, and the same guard canViewPost's own-pending hatch needs: a + // falsy-equal author check would hand every principal-less viewer the + // author's reply right on every anonymous post. + const d = canCreateComment(anon, post(null), authorOnly(), 'none') + expect(d.allowed).toBe(false) + if (!d.allowed) expect(d.reason).toBe(AUTHOR_ONLY_DENY) + }) + + it('a service principal is denied on someone else’s thread but may reply on its own', () => { + expect(canCreateComment(service, post(OTHER), authorOnly(), 'none').allowed).toBe(false) + expect(canCreateComment(service, post(service.principalId), authorOnly(), 'none').allowed).toBe( + true + ) + }) + + it("an absent replyPolicy key behaves as 'anyone' (today's boards are unchanged)", () => { + expect(canCreateComment(portal, post(OTHER), publicBoard, 'none').allowed).toBe(true) + }) + + it("an explicit 'anyone' behaves exactly like the absent key", () => { + const anyone = authorOnly({ replyPolicy: 'anyone' }) + expect(canCreateComment(portal, post(OTHER), anyone, 'none').allowed).toBe(true) + }) + + it('the comment tier still denies first — the policy narrows, it never widens', () => { + const d = canCreateComment( + portal, + post(portal.principalId), + authorOnly({ comment: 'team' }), + 'none' + ) + expect(d.allowed).toBe(false) + if (!d.allowed) expect(d.reason).toBe('Only team members can comment on this board') + }) + + it('lock beats author: the author is denied on their own LOCKED post', () => { + const d = canCreateComment(portal, post(portal.principalId, true), authorOnly(), 'none') + expect(d.allowed).toBe(false) + if (!d.allowed) expect(d.reason).toMatch(/locked/i) + }) + + it('a non-author on a locked author-only board gets the reply-policy reason (checked first)', () => { + const d = canCreateComment(portal, post(OTHER, true), authorOnly(), 'none') + expect(d.allowed).toBe(false) + if (!d.allowed) expect(d.reason).toBe(AUTHOR_ONLY_DENY) + }) + + it("the author's own reply is still held when moderation.comments='on'", () => { + const held = authorOnly({ + moderation: { anonPosts: 'inherit', signedPosts: 'inherit', comments: 'on' }, + }) + expect(canCreateComment(portal, post(portal.principalId), held, 'none')).toEqual({ + allowed: true, + requiresApproval: true, + }) + }) + + it('the author can reply on their own PENDING post (view hatch composes)', () => { + const ownPending = { + moderationState: 'pending' as ModerationState, + principalId: portal.principalId, + isCommentsLocked: false, + } + expect(canCreateComment(portal, ownPending, authorOnly(), 'none').allowed).toBe(true) + }) +}) + describe('canCreateComment — board.access.comment tier gates commenting independent of view', () => { const publishedPost = { moderationState: 'published' as ModerationState, diff --git a/apps/web/src/lib/server/policy/posts.ts b/apps/web/src/lib/server/policy/posts.ts index c91a37c0e4..040fad1518 100644 --- a/apps/web/src/lib/server/policy/posts.ts +++ b/apps/web/src/lib/server/policy/posts.ts @@ -12,6 +12,10 @@ import { type ModerationRuleValue, type ModerationState, } from '@/lib/server/db' +// Imported through the client-safe re-export, not '@/lib/server/db': this is a +// pure helper, and pulling it from the db barrel would make every suite that +// mocks that barrel have to stub it. +import { resolveReplyPolicy } from '@/lib/shared/db-types' import type { PrincipalId } from '@quackback/ids' import { allowDecision, denyDecision, isTeamActor, type Actor, type Decision } from './types' import { can } from './authorize' @@ -108,8 +112,7 @@ export function postViewFilter(actor: Actor): SQL { } export type CommentCreateDecision = - | { allowed: true; requiresApproval: boolean } - | { allowed: false; reason: string } + { allowed: true; requiresApproval: boolean } | { allowed: false; reason: string } /** Action-specific copy for the (unreachable) anonymous deny branch. */ const ANON_DENY_MESSAGE: Record<'comment' | 'vote' | 'submit', string> = { @@ -144,7 +147,9 @@ function tierDenyMessage(action: 'comment' | 'vote' | 'submit', tier: AccessTier * 1. The actor must be able to view the post (board view tier + moderation state). * 2. The actor must satisfy the board's comment tier — independent of view * (a board can be public-to-view but team-only-to-comment). - * 3. If comments are locked, only team members may bypass. + * 3. On an `author-only` board, only the post's own author and team members + * may reply — everyone else reads the thread without being able to answer. + * 4. If comments are locked, only team members may bypass. * * On the allowed branch, `requiresApproval` is true when the actor is not * a team member AND the board's `moderation.comments` rule (resolved @@ -163,6 +168,21 @@ export function canCreateComment( if (!tierAllows(actor, access.comment, access.segments.comment)) { return { allowed: false, reason: tierDenyMessage('comment', access.comment) } } + // Author-only board: the thread belongs to its author, so only they and the + // team may reply. Authorship is principalId VALUE equality guarded on a + // non-null actor principal — the same guard canViewPost's own-pending hatch + // uses, and for the same reason: without it an anonymous viewer (null) would + // match every anonymously-authored post (null) and inherit the author's + // reply right. An actor with no principal therefore always lands on the deny. + if (resolveReplyPolicy(access) === 'author-only' && !isTeam(actor)) { + const isAuthor = !!actor.principalId && actor.principalId === post.principalId + if (!isAuthor) { + return { + allowed: false, + reason: 'Only the post author and team members can reply on this board', + } + } + } if (post.isCommentsLocked && !isTeam(actor)) { return { allowed: false, reason: 'Comments are locked on this post' } } @@ -200,8 +220,7 @@ export function canVotePost(actor: Actor, post: PostShape, board: BoardShape): V } export type CreateDecision = - | { allowed: true; requiresApproval: boolean } - | { allowed: false; reason: string } + { allowed: true; requiresApproval: boolean } | { allowed: false; reason: string } export function canCreatePost( actor: Actor, @@ -250,6 +269,16 @@ export interface BoardCapabilities { canComment: boolean } +/** + * Whether the workspace anonymous master switch applies to this actor. Team + * actors are never gated by it (tierAllows already bypasses for them), so the + * !isTeam guard is part of the question — a hypothetical non-user team actor + * (e.g. a service principal carrying a team role) must stay ungated. + */ +function isAnonCeilinged(actor: Actor): boolean { + return !isTeam(actor) && actor.principalType !== 'user' +} + /** * Per-board submit/vote/comment capability for a viewer, composed with the * workspace anonymous master switch. This is the single source of truth the @@ -279,17 +308,19 @@ export function boardCapabilitiesForActor( { moderationState: 'published', principalId: null }, board ).allowed + // `replyPolicy` is dropped for the same reason isCommentsLocked stays false: + // it is a per-post concern, not a board capability. On an author-only board a + // non-team user CAN still reply — on their own post — so the board-level + // answer stays tier-based and the per-post truth comes from canCommentOnPost. + const { replyPolicy: _replyPolicy, ...commentAccess } = board.access const canComment = canCreateComment( actor, { moderationState: 'published', principalId: null, isCommentsLocked: false }, - board, + { access: commentAccess }, undefined ).allowed - // Compose the workspace anonymous ceiling for non-user actors only. Team - // actors are never gated by the anon ceiling (tierAllows already bypasses - // for them), so guard on !isTeam too — a hypothetical non-user team actor - // (e.g. a service principal carrying a team role) must stay ungated. - if (!isTeam(actor) && actor.principalType !== 'user') { + // Compose the workspace anonymous ceiling for non-user actors only. + if (isAnonCeilinged(actor)) { return { canSubmit: canSubmit && allowAnonymous, canVote: canVote && allowAnonymous, @@ -298,3 +329,34 @@ export function boardCapabilitiesForActor( } return { canSubmit, canVote, canComment } } + +/** + * Per-POST comment capability for a viewer — what the portal/widget composer + * is actually gated on. Same composition as `boardCapabilitiesForActor`'s + * `canComment` (board comment tier + the workspace anonymous ceiling), plus + * the one input a board-level answer structurally cannot have: the post's own + * author, which an `author-only` board's reply policy turns on. + * + * `isCommentsLocked` stays false here deliberately. The lock is surfaced by + * its own UI affordance (the "comments are locked" notice), not by collapsing + * the viewer's permission state — the write path re-checks it via + * `canCreateComment` with the real flag. + * + * Callers pass a post they have ALREADY proved viewable for this actor + * (assertPostViewable / getPublicPostDetail); the inner view check is then a + * no-op and the decision reflects the comment gates specifically. + */ +export function canCommentOnPost( + actor: Actor, + post: PostShape, + access: BoardAccess, + allowAnonymous: boolean +): boolean { + const allowed = canCreateComment( + actor, + { ...post, isCommentsLocked: false }, + { access: normalizeBoardAccess(access) }, + undefined + ).allowed + return isAnonCeilinged(actor) ? allowed && allowAnonymous : allowed +} diff --git a/apps/web/src/lib/shared/db-types.ts b/apps/web/src/lib/shared/db-types.ts index 8ff1093990..23a63dd173 100644 --- a/apps/web/src/lib/shared/db-types.ts +++ b/apps/web/src/lib/shared/db-types.ts @@ -24,13 +24,16 @@ import { // Re-export types only to keep this module client-safe. export type * from '@quackback/db/types' -// Plain-data constants from @quackback/db/types are also safe (no runtime side -// effects) and let client code stay aligned with the schema defaults. +// Plain-data constants (and the pure resolvers beside them) from +// @quackback/db/types are also safe (no runtime side effects) and let client +// code stay aligned with the schema defaults. export { ACCESS_TIERS, ACCESS_TIER_RANK, DEFAULT_BOARD_ACCESS, MODERATION_RULE_VALUES, + REPLY_POLICIES, + resolveReplyPolicy, CONVERSATION_STATUSES, CONVERSATION_END_REASONS, CONVERSATION_SPAM_FILED_BY, @@ -49,6 +52,7 @@ export type { AccessTier, BoardAccess, ModerationRuleValue, + ReplyPolicy, ConversationEndReason, ConversationSpamFiledBy, TeamAssignmentMethod, diff --git a/apps/web/src/lib/shared/schemas/__tests__/boards.test.ts b/apps/web/src/lib/shared/schemas/__tests__/boards.test.ts index 911be2ce99..c74c99664d 100644 --- a/apps/web/src/lib/shared/schemas/__tests__/boards.test.ts +++ b/apps/web/src/lib/shared/schemas/__tests__/boards.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { accessForPreset, normalizeBoardAccess } from '../boards' +import { DEFAULT_BOARD_ACCESS } from '@/lib/shared/db-types' describe('accessForPreset', () => { it('public preset: view=anonymous, vote/comment/submit=authenticated, segments empty, moderation all inherit', () => { @@ -43,4 +44,21 @@ describe('normalizeBoardAccess', () => { comments: 'inherit', }) }) + + it('preserves an explicit replyPolicy', () => { + const a = normalizeBoardAccess({ view: 'anonymous', replyPolicy: 'author-only' }) + expect(a.replyPolicy).toBe('author-only') + }) + + it('never INJECTS replyPolicy — an absent key already means anyone', () => { + // The key must stay absent so a normalized legacy row still deep-equals + // DEFAULT_BOARD_ACCESS, whose literal is byte-pinned to migration 0083. + const a = normalizeBoardAccess({ view: 'anonymous', submit: 'authenticated' }) + expect('replyPolicy' in a).toBe(false) + }) + + it('normalizing the column default returns the column default unchanged', () => { + expect(normalizeBoardAccess(DEFAULT_BOARD_ACCESS)).toEqual(DEFAULT_BOARD_ACCESS) + expect('replyPolicy' in normalizeBoardAccess(DEFAULT_BOARD_ACCESS)).toBe(false) + }) }) diff --git a/apps/web/src/lib/shared/schemas/boards.ts b/apps/web/src/lib/shared/schemas/boards.ts index 138b07f637..e057db30c3 100644 --- a/apps/web/src/lib/shared/schemas/boards.ts +++ b/apps/web/src/lib/shared/schemas/boards.ts @@ -3,6 +3,7 @@ import { ACCESS_TIERS, ACCESS_TIER_RANK, MODERATION_RULE_VALUES, + REPLY_POLICIES, type BoardAccess, } from '@/lib/shared/db-types' @@ -32,6 +33,11 @@ const INHERIT_MODERATION = { /** * Fill leftover board.access rows that predate vote/comment/moderation. * Missing moderation is inherit (workspace default), not a crash. + * + * `replyPolicy` is PRESERVED when present and never injected when absent: + * absent is already the permissive default (resolveReplyPolicy), and adding + * the key here would make a normalized legacy row stop deep-equalling + * DEFAULT_BOARD_ACCESS. */ export function normalizeBoardAccess(access: Partial | null | undefined): BoardAccess { const view = access?.view ?? 'team' @@ -56,6 +62,7 @@ export function normalizeBoardAccess(access: Partial | null | undef signedPosts: moderation?.signedPosts ?? INHERIT_MODERATION.signedPosts, comments: moderation?.comments ?? INHERIT_MODERATION.comments, }, + ...(access?.replyPolicy ? { replyPolicy: access.replyPolicy } : {}), } } @@ -111,6 +118,7 @@ export type DeleteBoardInput = z.infer const tierSchema = z.enum(ACCESS_TIERS) const moderationRuleSchema = z.enum(MODERATION_RULE_VALUES) +const replyPolicySchema = z.enum(REPLY_POLICIES) /** * Validation for the per-action `BoardAccess` payload @@ -131,6 +139,11 @@ const moderationRuleSchema = z.enum(MODERATION_RULE_VALUES) * Moderation rules are tri-state (`inherit | on | off`) — see * resolveModerationRule in policy/posts.ts for how `inherit` resolves * against the workspace requireApproval default. + * + * `replyPolicy` is optional: an omitted key is the permissive default + * (`'anyone'`), so a board that never touches the setting keeps writing the + * exact shape it always did. It carries no tier-rank invariant — it narrows + * WHO may reply within the comment tier, per post, and never widens it. */ export const boardAccessSchema = z .object({ @@ -149,6 +162,7 @@ export const boardAccessSchema = z signedPosts: moderationRuleSchema, comments: moderationRuleSchema, }), + replyPolicy: replyPolicySchema.optional(), }) .superRefine((val, ctx) => { if (ACCESS_TIER_RANK[val.vote] < ACCESS_TIER_RANK[val.view]) { diff --git a/apps/web/src/locales/ar.json b/apps/web/src/locales/ar.json index 5dc3cfc744..09f55448cd 100644 --- a/apps/web/src/locales/ar.json +++ b/apps/web/src/locales/ar.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "سجّل الدخول للانضمام إلى المحادثة", "widget.postDetail.teamAuthorFallback": "الفريق", "widget.postDetail.commentNoAccess": "ليست لديك صلاحية التعليق في هذه الفئة", + "widget.postDetail.authorOnlyReplies": "لا يمكن الرد في هذه الفئة إلا لكاتب المنشور وأعضاء الفريق", "widget.commentList.empty": "لا توجد تعليقات بعد. كن أول من يشارك رأيه!", "widget.commentList.deleted": "[محذوف]", "widget.commentList.removed": "[مُزال]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "تعديل", "portal.commentThread.edited": "(معدّل)", "portal.commentThread.noAccess": "ليست لديك صلاحية التعليق في هذه الفئة", + "portal.commentThread.authorOnlyReplies": "لا يمكن الرد في هذه الفئة إلا لكاتب المنشور وأعضاء الفريق", "portal.commentThread.save": "حفظ", "portal.commentThread.saving": "جارٍ الحفظ…", "portal.commentThread.teamBadgeAria": "عضو في {name}", diff --git a/apps/web/src/locales/de.json b/apps/web/src/locales/de.json index 0f45ac31db..70e6a6688a 100644 --- a/apps/web/src/locales/de.json +++ b/apps/web/src/locales/de.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "Melden Sie sich an, um an der Diskussion teilzunehmen", "widget.postDetail.teamAuthorFallback": "Team", "widget.postDetail.commentNoAccess": "Sie haben keine Berechtigung, in dieser Kategorie zu kommentieren", + "widget.postDetail.authorOnlyReplies": "Nur der/die Autor(in) des Beitrags und Teammitglieder können in dieser Kategorie antworten", "widget.commentList.empty": "Noch keine Kommentare. Teilen Sie als Erste(r) Ihre Gedanken!", "widget.commentList.deleted": "[gelöscht]", "widget.commentList.removed": "[entfernt]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "Bearbeiten", "portal.commentThread.edited": "(bearbeitet)", "portal.commentThread.noAccess": "Sie haben keine Berechtigung, in dieser Kategorie zu kommentieren", + "portal.commentThread.authorOnlyReplies": "Nur der/die Autor(in) des Beitrags und Teammitglieder können in dieser Kategorie antworten", "portal.commentThread.save": "Speichern", "portal.commentThread.saving": "Wird gespeichert…", "portal.commentThread.teamBadgeAria": "Mitglied von {name}", diff --git a/apps/web/src/locales/en.json b/apps/web/src/locales/en.json index 02fc6163dd..74da2b0b30 100644 --- a/apps/web/src/locales/en.json +++ b/apps/web/src/locales/en.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "Log in to join the conversation", "widget.postDetail.teamAuthorFallback": "Team", "widget.postDetail.commentNoAccess": "You don't have access to comment on this board", + "widget.postDetail.authorOnlyReplies": "Only the post author and team members can reply on this board", "widget.commentList.empty": "No comments yet. Be the first to share your thoughts!", "widget.commentList.deleted": "[deleted]", "widget.commentList.removed": "[removed]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "Edit", "portal.commentThread.edited": "(edited)", "portal.commentThread.noAccess": "You don't have access to comment on this board", + "portal.commentThread.authorOnlyReplies": "Only the post author and team members can reply on this board", "portal.commentThread.save": "Save", "portal.commentThread.saving": "Saving…", "portal.commentThread.teamBadgeAria": "{name} Member", diff --git a/apps/web/src/locales/es.json b/apps/web/src/locales/es.json index f4b079be1d..4804342dfe 100644 --- a/apps/web/src/locales/es.json +++ b/apps/web/src/locales/es.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "Inicia sesión para unirte a la conversación", "widget.postDetail.teamAuthorFallback": "Equipo", "widget.postDetail.commentNoAccess": "No tienes permiso para comentar en esta categoría", + "widget.postDetail.authorOnlyReplies": "Solo el autor/a de la publicación y los miembros del equipo pueden responder en esta categoría", "widget.commentList.empty": "Aún no hay comentarios. ¡Sé el primero en compartir tus ideas!", "widget.commentList.deleted": "[eliminado]", "widget.commentList.removed": "[retirado]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "Editar", "portal.commentThread.edited": "(editado)", "portal.commentThread.noAccess": "No tienes permiso para comentar en esta categoría", + "portal.commentThread.authorOnlyReplies": "Solo el autor/a de la publicación y los miembros del equipo pueden responder en esta categoría", "portal.commentThread.save": "Guardar", "portal.commentThread.saving": "Guardando…", "portal.commentThread.teamBadgeAria": "Miembro de {name}", diff --git a/apps/web/src/locales/fr.json b/apps/web/src/locales/fr.json index a012b76ae1..297432e9f7 100644 --- a/apps/web/src/locales/fr.json +++ b/apps/web/src/locales/fr.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "Connectez-vous pour participer à la conversation", "widget.postDetail.teamAuthorFallback": "Équipe", "widget.postDetail.commentNoAccess": "Vous n'avez pas l'autorisation de commenter dans cette catégorie", + "widget.postDetail.authorOnlyReplies": "Seuls l'auteur de la publication et les membres de l'équipe peuvent répondre dans cette catégorie", "widget.commentList.empty": "Aucun commentaire pour le moment. Soyez le premier à partager vos réflexions !", "widget.commentList.deleted": "[supprimé]", "widget.commentList.removed": "[retiré]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "Modifier", "portal.commentThread.edited": "(modifié)", "portal.commentThread.noAccess": "Vous n'avez pas l'autorisation de commenter dans cette catégorie", + "portal.commentThread.authorOnlyReplies": "Seuls l'auteur de la publication et les membres de l'équipe peuvent répondre dans cette catégorie", "portal.commentThread.save": "Enregistrer", "portal.commentThread.saving": "Enregistrement…", "portal.commentThread.teamBadgeAria": "Membre de {name}", diff --git a/apps/web/src/locales/pt-br.json b/apps/web/src/locales/pt-br.json index 1f2d4ca8e4..9d9ae0489d 100644 --- a/apps/web/src/locales/pt-br.json +++ b/apps/web/src/locales/pt-br.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "Entre para participar da conversa", "widget.postDetail.teamAuthorFallback": "Equipe", "widget.postDetail.commentNoAccess": "Você não tem acesso para comentar neste quadro", + "widget.postDetail.authorOnlyReplies": "Somente o autor do post e os membros da equipe podem responder neste quadro", "widget.commentList.empty": "Ainda não há comentários. Seja o primeiro a compartilhar sua opinião!", "widget.commentList.deleted": "[excluído]", "widget.commentList.removed": "[removido]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "Editar", "portal.commentThread.edited": "(editado)", "portal.commentThread.noAccess": "Você não tem acesso para comentar neste quadro", + "portal.commentThread.authorOnlyReplies": "Somente o autor do post e os membros da equipe podem responder neste quadro", "portal.commentThread.save": "Salvar", "portal.commentThread.saving": "Salvando…", "portal.commentThread.teamBadgeAria": "Membro de {name}", diff --git a/apps/web/src/locales/ru.json b/apps/web/src/locales/ru.json index 5668f543a3..617da97cf0 100644 --- a/apps/web/src/locales/ru.json +++ b/apps/web/src/locales/ru.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "Войдите, чтобы присоединиться к обсуждению", "widget.postDetail.teamAuthorFallback": "Команда", "widget.postDetail.commentNoAccess": "У вас нет доступа, чтобы комментировать в этой категории", + "widget.postDetail.authorOnlyReplies": "Отвечать в этой категории могут только автор предложения и участники команды", "widget.commentList.empty": "Комментариев пока нет. Оставьте первый.", "widget.commentList.deleted": "[удалено]", "widget.commentList.removed": "[скрыто]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "Редактировать", "portal.commentThread.edited": "(изменено)", "portal.commentThread.noAccess": "У вас нет доступа, чтобы комментировать в этой категории", + "portal.commentThread.authorOnlyReplies": "Отвечать в этой категории могут только автор предложения и участники команды", "portal.commentThread.save": "Сохранить", "portal.commentThread.saving": "Сохранение…", "portal.commentThread.teamBadgeAria": "Участник {name}", diff --git a/apps/web/src/locales/zh-cn.json b/apps/web/src/locales/zh-cn.json index fed8b97931..40f8013cbf 100644 --- a/apps/web/src/locales/zh-cn.json +++ b/apps/web/src/locales/zh-cn.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "登录以参与讨论", "widget.postDetail.teamAuthorFallback": "团队", "widget.postDetail.commentNoAccess": "你没有权限在此看板评论", + "widget.postDetail.authorOnlyReplies": "只有帖子作者和团队成员可以在此看板回复", "widget.commentList.empty": "暂无评论。快来分享你的想法吧!", "widget.commentList.deleted": "[已删除]", "widget.commentList.removed": "[已移除]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "编辑", "portal.commentThread.edited": "(已编辑)", "portal.commentThread.noAccess": "你没有权限在此看板评论", + "portal.commentThread.authorOnlyReplies": "只有帖子作者和团队成员可以在此看板回复", "portal.commentThread.save": "保存", "portal.commentThread.saving": "保存中…", "portal.commentThread.teamBadgeAria": "{name} 成员", diff --git a/apps/web/src/locales/zh-tw.json b/apps/web/src/locales/zh-tw.json index f318e6f783..31adb0d202 100644 --- a/apps/web/src/locales/zh-tw.json +++ b/apps/web/src/locales/zh-tw.json @@ -70,6 +70,7 @@ "widget.postDetail.loginToComment": "登入即可加入對話", "widget.postDetail.teamAuthorFallback": "團隊", "widget.postDetail.commentNoAccess": "你沒有權限在這個看板留言", + "widget.postDetail.authorOnlyReplies": "只有貼文作者和團隊成員可以在這個看板回覆", "widget.commentList.empty": "還沒有留言。搶先分享你的想法吧!", "widget.commentList.deleted": "[已刪除]", "widget.commentList.removed": "[已移除]", @@ -357,6 +358,7 @@ "portal.commentThread.edit": "編輯", "portal.commentThread.edited": "(已編輯)", "portal.commentThread.noAccess": "你沒有權限在這個看板留言", + "portal.commentThread.authorOnlyReplies": "只有貼文作者和團隊成員可以在這個看板回覆", "portal.commentThread.save": "儲存", "portal.commentThread.saving": "儲存中…", "portal.commentThread.teamBadgeAria": "{name} 成員", diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 7c2c88e2d7..02853c755b 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -129,6 +129,15 @@ export const ACCESS_TIER_RANK: Record = { export const MODERATION_RULE_VALUES = ['inherit', 'on', 'off'] as const export type ModerationRuleValue = (typeof MODERATION_RULE_VALUES)[number] +/** Who may reply on a board's threads, on top of the `comment` tier. + * - `anyone` — today's behaviour: the comment tier alone decides. + * - `author-only` — only each post's own author and the team may reply. + * Everyone the view tier admits still reads every thread in full — each + * thread stays a publicly readable conversation between its author and + * the team. */ +export const REPLY_POLICIES = ['anyone', 'author-only'] as const +export type ReplyPolicy = (typeof REPLY_POLICIES)[number] + export interface BoardAccess { view: AccessTier vote: AccessTier @@ -152,6 +161,12 @@ export interface BoardAccess { signedPosts: ModerationRuleValue comments: ModerationRuleValue } + /** Optional reply restriction (see {@link REPLY_POLICIES}). Absent means + * `'anyone'` — read it through {@link resolveReplyPolicy}, never directly. + * Deliberately optional and ABSENT from {@link DEFAULT_BOARD_ACCESS}: the + * column default is byte-pinned to its migration literal, so keeping the + * key out of the default is what lets this ship without a migration. */ + replyPolicy?: ReplyPolicy } // Key order is jsonb-canonical (length, then bytewise) so the serialized @@ -571,6 +586,17 @@ export function needsCloudOnboardingWizard(setupState: SetupState | null): boole return setupState.steps.startingPoint?.source === 'managed' } +/** + * Resolve a board's reply policy. Every row that predates the setting (and + * every board that never enabled it) carries no key, which means the + * permissive default — so absent, null and undefined all read as `'anyone'`. + */ +export function resolveReplyPolicy( + access: { replyPolicy?: ReplyPolicy } | null | undefined +): ReplyPolicy { + return access?.replyPolicy ?? 'anyone' +} + // Helper to get typed board settings export function getBoardSettings(board: Board): BoardSettings { const settings = (board.settings || {}) as BoardSettings