How should we design private posts?
create a plan for implementing private posts. it should specifiy how the UI should be designed, and explain the backend changes required.
my take is that a dropdown/select thing right on the left of the Post button in the composer should let the user select between Public and Private posts.
in the future, we might want to add teams, so the backend should be adaptable to that. like it's sort of a visibility: public, private, <team_id> in a way, right? or public,
I'll explore the codebase to understand the current architecture and then create a comprehensive plan for implementing private posts. Let me start by examining the structure and key components.
Now let me look at how posts are fetched and displayed to understand the data layer better:
Now let me look at how posts are queried/listed to understand the data fetching patterns:
Now let me check the Typesense indexing for search, and see how posts are displayed in the user profile:
Now let me look at the UI components to understand the design patterns:
Let me look at an existing migration to understand the format:
Now let me check where post access control would need to be enforced, particularly in the post detail page:
Now I have a good understanding of the codebase. Let me also check if there's a Select UI component:
Now I have all the information I need. Let me create a comprehensive implementation plan.
#Implementation Plan: Private Posts
##Overview
This plan outlines how to implement private posts with future team support in mind. The design uses a flexible visibility field that can accommodate public posts, private posts (author-only), and future team-based visibility.
##1. Database Schema Changes
###1.1 New Migration: 0013_add_visibility.sql
-- Add visibility column to posts table
-- NULL = public (default, backwards compatible)
-- 'private' = only author can see
-- Future: team IDs like 'team_abc123' for team visibility
ALTER TABLE "posts" ADD COLUMN "visibility" varchar(64);
-- Index for efficient filtering by visibility and author
CREATE INDEX "idx_posts_visibility" ON "posts" ("visibility");
CREATE INDEX "idx_posts_author_visibility" ON "posts" ("author_id", "visibility");
###1.2 Schema Update (lib/db/schema.ts)
export const posts = p.pgTable(
"posts",
{
id: p.varchar({ length: 32 }).primaryKey(),
number: p.integer().notNull(),
owner: p.varchar({ length: 255 }).notNull(),
repo: p.varchar({ length: 255 }).notNull(),
gitContexts: p.jsonb("git_contexts").$type<GitContextData[]>(),
title: p.varchar({ length: 500 }),
categoryId: p.varchar("category_id", { length: 32 }),
rootCommentId: p.varchar("root_comment_id", { length: 32 }),
authorId: p.varchar("author_id", { length: 255 }).notNull(),
// NEW: Visibility control
// null = public, 'private' = author only, future: 'team_<id>' for teams
visibility: p.varchar({ length: 64 }),
createdAt: p.bigint("created_at", { mode: "number" }).notNull(),
updatedAt: p.bigint("updated_at", { mode: "number" }).notNull(),
},
(table) => [
// ... existing indexes ...
p.index("idx_posts_visibility").on(table.visibility),
p.index("idx_posts_author_visibility").on(table.authorId, table.visibility),
]
)
###1.3 Visibility Type Definition (lib/types/visibility.ts)
// Current visibility options
export type PostVisibility = 'public' | 'private'
// Future-proof type that includes teams
export type PostVisibilityValue = null | 'private' | `team_${string}`
// Helper to check visibility
export function isPublic(visibility: PostVisibilityValue): boolean {
return visibility === null || visibility === 'public'
}
export function isPrivate(visibility: PostVisibilityValue): boolean {
return visibility === 'private'
}
export function isTeamVisible(visibility: PostVisibilityValue): visibility is `team_${string}` {
return typeof visibility === 'string' && visibility.startsWith('team_')
}
// Check if user can access a post
export function canAccessPost(
visibility: PostVisibilityValue,
authorId: string,
currentUserId: string | null,
userTeamIds: string[] = [] // Future: user's team memberships
): boolean {
// Public posts are accessible to everyone
if (isPublic(visibility)) return true
// Must be logged in for non-public posts
if (!currentUserId) return false
// Author can always access their own posts
if (authorId === currentUserId) return true
// Private posts are author-only
if (isPrivate(visibility)) return false
// Team visibility (future)
if (isTeamVisible(visibility)) {
const teamId = visibility.replace('team_', '')
return userTeamIds.includes(teamId)
}
return false
}
##2. UI Changes
###2.1 Visibility Selector Component (components/visibility-selector.tsx)
"use client"
import { ChevronDownIcon, GlobeIcon, LockIcon } from "lucide-react"
import { Menu } from "@/components/ui/menu"
import { buttonVariants } from "./button"
import { cn } from "@/lib/utils"
export type VisibilityOption = {
id: 'public' | 'private'
label: string
description: string
icon: React.ReactNode
}
const VISIBILITY_OPTIONS: VisibilityOption[] = [
{
id: 'public',
label: 'Public',
description: 'Anyone can see this post',
icon: <GlobeIcon className="size-4" />,
},
{
id: 'private',
label: 'Private',
description: 'Only you can see this post',
icon: <LockIcon className="size-4" />,
},
]
type VisibilitySelectorProps = {
value: 'public' | 'private'
onChange: (value: 'public' | 'private') => void
disabled?: boolean
}
export function VisibilitySelector({
value,
onChange,
disabled
}: VisibilitySelectorProps) {
const selected = VISIBILITY_OPTIONS.find((o) => o.id === value) ?? VISIBILITY_OPTIONS[0]
if (disabled) {
return (
<span
className={cn(
buttonVariants({ variant: "tertiary", size: "xs" }),
"cursor-not-allowed opacity-50 gap-1"
)}
>
{selected.icon}
{selected.label}
</span>
)
}
return (
<Menu.Root>
<Menu.Trigger
className={cn(
buttonVariants({ variant: "tertiary", size: "xs" }),
"gap-1 hover:text-label hover:no-underline"
)}
>
{selected.icon}
{selected.label}
<ChevronDownIcon className="size-3" />
</Menu.Trigger>
<Menu.Popup align="end">
{VISIBILITY_OPTIONS.map((option) => (
<Menu.Item
key={option.id}
onClick={() => onChange(option.id)}
className="flex-col items-start gap-0.5"
>
<span className="flex items-center gap-2">
{option.icon}
{option.label}
</span>
<span className="text-muted text-xs pl-6">
{option.description}
</span>
</Menu.Item>
))}
</Menu.Popup>
</Menu.Root>
)
}
###2.2 Update Composer Component (components/composer.tsx)
Modify the composer to include the visibility selector to the left of the Post button:
// Add to ComposerProps type
export type ComposerProps = {
// ... existing props ...
showVisibilitySelector?: boolean
defaultVisibility?: 'public' | 'private'
onVisibilityChange?: (visibility: 'public' | 'private') => void
}
// In the component, add state
const [visibility, setVisibility] = useState<'public' | 'private'>(
defaultVisibility ?? 'public'
)
// Update onSubmit to include visibility
onSubmit({ value, options: { asking: selectedAsking }, visibility })
// In the footer div, add the visibility selector before the Button:
<div className="flex items-center gap-2">
{showVisibilitySelector && isSignedIn && (
<VisibilitySelector
value={visibility}
onChange={(v) => {
setVisibility(v)
onVisibilityChange?.(v)
}}
disabled={isPending}
/>
)}
<Button
className="cursor-pointer"
disabled={isPending || /* ... existing conditions */}
// ... rest of button props
>
{/* ... existing button content */}
</Button>
</div>
###2.3 Visual Layout
┌─────────────────────────────────────────────────────────────┐
│ │
│ [textarea for question/post content] │
│ │
├─────────────────────────────────────────────────────────────┤
│ [Claude 4 Sonnet ▼] [🌐 Public ▼] [ Post ] │
│ │
└─────────────────────────────────────────────────────────────┘
The visibility selector:
- Uses
tertiarybutton variant withxssize (consistent with existing UI) - Shows an icon (globe for public, lock for private)
- Dropdown with options and descriptions
- Positioned immediately left of the "Post" button
##3. Backend Changes
###3.1 Update createPost Action (lib/actions/posts.ts)
export async function createPost(data: {
owner: string
repo: string
content: AgentUIMessage
seekingAnswerFrom?: string | null
categoryId?: string
visibility?: 'public' | 'private' // NEW
}) {
const session = await getSessionOrThrow()
// ... existing code ...
// Convert 'public' to null for storage (null = public is more efficient)
const visibilityValue = data.visibility === 'private' ? 'private' : null
const [llm, newPost] = await Promise.all([
// ... existing llm code ...
db
.insert(postCounters)
// ... existing code ...
.then(async (r) => {
return await db
.insert(posts)
.values({
id: postId,
number: r[0].lastNumber,
owner: data.owner,
repo: data.repo,
authorId: session.user.id,
rootCommentId: commentId,
categoryId: data.categoryId,
visibility: visibilityValue, // NEW
createdAt: now,
updatedAt: now,
})
.returning()
.then((p) => p[0])
}),
// ... rest of existing code ...
])
// ... existing code ...
}
###3.2 Access Control Helper (lib/utils/post-access.ts)
import { and, eq, or, isNull, sql } from "drizzle-orm"
import { posts } from "@/lib/db/schema"
/**
* Returns a WHERE clause fragment that filters posts
* to only those the user can access.
*/
export function visiblePostsFilter(userId: string | null) {
if (!userId) {
// Anonymous users can only see public posts
return or(isNull(posts.visibility), eq(posts.visibility, 'public'))
}
// Logged-in users can see:
// 1. Public posts (visibility is null)
// 2. Their own private posts
return or(
isNull(posts.visibility),
eq(posts.authorId, userId)
// Future: add team membership check here
// sql`${posts.visibility} = ANY(${userTeamIds})`
)
}
/**
* Check if a specific post is accessible to a user
*/
export function canAccessPost(
post: { authorId: string; visibility: string | null },
userId: string | null
): boolean {
// Public posts
if (!post.visibility) return true
// Must be logged in for non-public
if (!userId) return false
// Author access
if (post.authorId === userId) return true
// Future: team membership check
return false
}
###3.3 Update Post Queries
####Repo Page (app/[owner]/[repo]/page.tsx)
// Import the filter
import { visiblePostsFilter } from "@/lib/utils/post-access"
import { auth } from "@/lib/auth"
export default async function RepoPage({ params }) {
const { owner, repo } = await params
const session = await auth.api.getSession({ headers: await headers() })
const userId = session?.user?.id ?? null
const repoPosts = await db
.select({
// ... existing fields ...
visibility: posts.visibility, // Add this for UI indicators
})
.from(posts)
.leftJoin(comments, eq(posts.rootCommentId, comments.id))
.where(
and(
eq(posts.owner, owner),
eq(posts.repo, repo),
visiblePostsFilter(userId) // NEW: filter by visibility
)
)
.orderBy(desc(posts.createdAt))
// ... rest of existing code ...
}
####Post Detail Page (app/[owner]/[repo]/[postNumber]/page.tsx)
import { canAccessPost } from "@/lib/utils/post-access"
export default async function PostPage({ params }) {
// ... existing code to fetch post ...
const session = await auth.api.getSession({ headers: await headers() })
if (postWithCategory && !canAccessPost(postWithCategory, session?.user?.id ?? null)) {
// Could redirect to a "post not found" or "access denied" page
notFound()
}
// ... rest of existing code ...
}
####User Profile Page (app/user/[username]/page.tsx)
Update queries to filter out private posts that don't belong to the viewing user.
###3.4 Update Typesense Indexing (lib/typesense-index.ts)
// Update collection schema to include visibility
if (!existingNames.has(POSTS_COLLECTION)) {
await typesense.collections().create({
name: POSTS_COLLECTION,
fields: [
// ... existing fields ...
{ name: "visibility", type: "string", optional: true, facet: true },
],
// ...
})
}
// Update indexPost function
export async function indexPost(post: Post, commentCount: number) {
// Don't index private posts (they shouldn't appear in search)
if (post.visibility === 'private') {
// Remove from index if it exists
try {
await typesense.collections(POSTS_COLLECTION).documents(post.id).delete()
} catch {
// Ignore if not found
}
return
}
// ... existing indexing code ...
}
##4. UI Indicators for Private Posts
###4.1 Post List Item Badge
In the post list, show a lock icon for private posts:
// In repo-posts-section.tsx or similar
{post.visibility === 'private' && (
<span className="flex items-center gap-1 text-muted text-xs">
<LockIcon className="size-3" />
Private
</span>
)}
###4.2 Post Header Badge
In the post detail page header, indicate if a post is private:
// In post-header.tsx
{post.visibility === 'private' && (
<span className="flex items-center gap-1 bg-faint px-2 py-0.5 text-muted text-xs">
<LockIcon className="size-3" />
Private — only visible to you
</span>
)}
##5. Future Team Support
The schema is designed to easily support teams:
###5.1 Future Database Changes
-- Teams table
CREATE TABLE "teams" (
"id" varchar(32) PRIMARY KEY,
"name" varchar(100) NOT NULL,
"owner_id" varchar(255) NOT NULL,
"created_at" bigint NOT NULL
);
-- Team memberships
CREATE TABLE "team_members" (
"team_id" varchar(32) NOT NULL,
"user_id" varchar(255) NOT NULL,
"role" varchar(32) NOT NULL DEFAULT 'member',
"created_at" bigint NOT NULL,
PRIMARY KEY ("team_id", "user_id")
);
###5.2 Updated Visibility Options
const VISIBILITY_OPTIONS = [
{ id: 'public', label: 'Public', /* ... */ },
{ id: 'private', label: 'Private', /* ... */ },
// Future: dynamically add team options
...userTeams.map(team => ({
id: `team_${team.id}`,
label: team.name,
description: `Only ${team.name} members can see this`,
icon: <UsersIcon className="size-4" />,
}))
]
###5.3 Updated Access Control
export function visiblePostsFilter(userId: string | null, teamIds: string[] = []) {
if (!userId) {
return or(isNull(posts.visibility), eq(posts.visibility, 'public'))
}
const conditions = [
isNull(posts.visibility),
eq(posts.authorId, userId),
]
// Add team visibility conditions
if (teamIds.length > 0) {
conditions.push(
inArray(posts.visibility, teamIds.map(id => `team_${id}`))
)
}
return or(...conditions)
}
##6. Implementation Checklist
###Phase 1: Database & Backend
- Create migration
0013_add_visibility.sql - Update
lib/db/schema.tswith visibility field - Create
lib/utils/post-access.tshelper - Update
createPostaction to accept visibility
###Phase 2: UI Components
- Create
components/visibility-selector.tsx - Update
components/composer.tsxto include visibility selector - Update
ComposerPropstype
###Phase 3: Access Control Integration
- Update
app/[owner]/[repo]/page.tsxto filter posts - Update
app/[owner]/[repo]/[postNumber]/page.tsxwith access check - Update
app/user/[username]/page.tsxto respect visibility - Update search/Typesense indexing
###Phase 4: Visual Indicators
- Add private post badge to post list items
- Add private indicator to post header
- Test visual states
###Phase 5: Testing & Edge Cases
- Test anonymous user access
- Test author access to private posts
- Test other user blocked from private posts
- Test search doesn't include private posts
- Test sitemap excludes private posts
##7. Summary
| Aspect | Recommendation |
|---|---|
| Schema | visibility column: null (public), 'private', or future 'team_<id>' |
| UI Position | Selector left of "Post" button, using existing Menu component |
| Default | Public (null in DB, better for queries) |
| Access Control | Centralized helper functions for consistent filtering |
| Search | Private posts excluded from Typesense index |
| Future-proof | Schema supports team IDs without migration |
END OF POST
Claude Opus 4.5