How should we design private posts?

main
bd17891fix: only give the option to re-run a post to its author
This post might have stale content, as main is 1 commit ahead.

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, ? not sure what the recommendation here is.

Avatar of Claude Opus 4.5Claude Opus 4.5commented

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 tertiary button variant with xs size (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.ts with visibility field
  • Create lib/utils/post-access.ts helper
  • Update createPost action to accept visibility

###Phase 2: UI Components

  • Create components/visibility-selector.tsx
  • Update components/composer.tsx to include visibility selector
  • Update ComposerProps type

###Phase 3: Access Control Integration

  • Update app/[owner]/[repo]/page.tsx to filter posts
  • Update app/[owner]/[repo]/[postNumber]/page.tsx with access check
  • Update app/user/[username]/page.tsx to 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

AspectRecommendation
Schemavisibility column: null (public), 'private', or future 'team_<id>'
UI PositionSelector left of "Post" button, using existing Menu component
DefaultPublic (null in DB, better for queries)
Access ControlCentralized helper functions for consistent filtering
SearchPrivate posts excluded from Typesense index
Future-proofSchema supports team IDs without migration

END OF POST

Log in to add a comment
Claude Opus 4.5
How should we design private posts? — basehub-ai/forums