Skip to content

Commit cefbe15

Browse files
kenneivesclaude
andcommitted
Add post pinning, flair tags, and suggested follows
Post pinning: Submolt moderators/owners can pin posts. Pinned posts appear first in submolt feeds. Toggle pin/unpin via POST /social/pin/{id}. Post flair: Posts can have optional flair tags (question, discussion, announcement, etc.) for content categorization. Suggested follows: GET /social/suggested recommends entities by trust score, excluding already-followed and blocked entities. 8 new tests (297 total), all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f15aa2f commit cefbe15

6 files changed

Lines changed: 463 additions & 2 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""add_post_pinning_and_flair
2+
3+
Revision ID: 56d8b92830a1
4+
Revises: a43957244158
5+
Create Date: 2026-02-17 16:24:11.658661
6+
"""
7+
from __future__ import annotations
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
13+
revision = '56d8b92830a1'
14+
down_revision = 'a43957244158'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
# ### commands auto generated by Alembic - please adjust! ###
21+
op.add_column('posts', sa.Column('is_pinned', sa.Boolean(), nullable=True))
22+
op.add_column('posts', sa.Column('flair', sa.String(length=50), nullable=True))
23+
# ### end Alembic commands ###
24+
25+
26+
def downgrade() -> None:
27+
# ### commands auto generated by Alembic - please adjust! ###
28+
op.drop_column('posts', 'flair')
29+
op.drop_column('posts', 'is_pinned')
30+
# ### end Alembic commands ###

src/api/feed_router.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class CreatePostRequest(BaseModel):
3434
content: str = Field(..., min_length=1, max_length=10000)
3535
parent_post_id: uuid.UUID | None = None
3636
submolt_id: uuid.UUID | None = None
37+
flair: str | None = Field(None, max_length=50)
3738

3839

3940
class EditPostRequest(BaseModel):
@@ -63,6 +64,8 @@ class PostResponse(BaseModel):
6364
vote_count: int
6465
reply_count: int = 0
6566
is_edited: bool = False
67+
is_pinned: bool = False
68+
flair: str | None = None
6669
user_vote: str | None = None # "up", "down", or None
6770
is_bookmarked: bool = False
6871
author_trust_score: float | None = None
@@ -123,6 +126,7 @@ async def create_post(
123126
content=body.content,
124127
parent_post_id=body.parent_post_id,
125128
submolt_id=body.submolt_id,
129+
flair=body.flair,
126130
)
127131
db.add(post)
128132
await db.flush()
@@ -943,6 +947,8 @@ def _build_post_response(
943947
vote_count=post.vote_count,
944948
reply_count=reply_count,
945949
is_edited=post.is_edited or False,
950+
is_pinned=post.is_pinned or False,
951+
flair=post.flair,
946952
user_vote=user_vote,
947953
is_bookmarked=is_bookmarked,
948954
author_trust_score=author_trust_score,

src/api/social_router.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@
1010
from src.api.deps import get_current_entity
1111
from src.api.rate_limit import rate_limit_writes
1212
from src.database import get_db
13-
from src.models import Entity, EntityBlock, EntityRelationship, RelationshipType
13+
from src.models import (
14+
Entity,
15+
EntityBlock,
16+
EntityRelationship,
17+
Post,
18+
RelationshipType,
19+
SubmoltMembership,
20+
TrustScore,
21+
)
1422

1523
router = APIRouter(prefix="/social", tags=["social"])
1624

@@ -317,3 +325,108 @@ async def list_blocked(
317325
],
318326
"count": len(rows),
319327
}
328+
329+
330+
# --- Suggested Follows ---
331+
332+
333+
@router.get("/suggested")
334+
async def get_suggested_follows(
335+
limit: int = 10,
336+
current_entity: Entity = Depends(get_current_entity),
337+
db: AsyncSession = Depends(get_db),
338+
):
339+
"""Suggest entities to follow based on trust score and activity.
340+
341+
Excludes already-followed entities, blocked entities, and self.
342+
"""
343+
# Get already followed IDs
344+
following = await db.execute(
345+
select(EntityRelationship.target_entity_id).where(
346+
EntityRelationship.source_entity_id == current_entity.id,
347+
EntityRelationship.type == RelationshipType.FOLLOW,
348+
)
349+
)
350+
followed_ids = {row[0] for row in following.all()}
351+
followed_ids.add(current_entity.id)
352+
353+
# Get blocked IDs
354+
blocked = await db.execute(
355+
select(EntityBlock.blocked_id).where(
356+
EntityBlock.blocker_id == current_entity.id,
357+
)
358+
)
359+
blocked_ids = {row[0] for row in blocked.all()}
360+
exclude_ids = followed_ids | blocked_ids
361+
362+
# Find top entities by trust score that aren't followed
363+
query = (
364+
select(Entity, TrustScore.score)
365+
.outerjoin(TrustScore, TrustScore.entity_id == Entity.id)
366+
.where(
367+
Entity.is_active.is_(True),
368+
Entity.id.notin_(exclude_ids) if exclude_ids else True,
369+
)
370+
.order_by(
371+
func.coalesce(TrustScore.score, 0).desc(),
372+
Entity.created_at.desc(),
373+
)
374+
.limit(limit)
375+
)
376+
377+
result = await db.execute(query)
378+
rows = result.all()
379+
380+
return {
381+
"suggestions": [
382+
{
383+
"id": str(entity.id),
384+
"type": entity.type.value,
385+
"display_name": entity.display_name,
386+
"did_web": entity.did_web,
387+
"bio_markdown": entity.bio_markdown or "",
388+
"trust_score": score,
389+
}
390+
for entity, score in rows
391+
],
392+
}
393+
394+
395+
# --- Pin/Unpin Posts ---
396+
397+
398+
@router.post("/pin/{post_id}")
399+
async def pin_post(
400+
post_id: uuid.UUID,
401+
current_entity: Entity = Depends(get_current_entity),
402+
db: AsyncSession = Depends(get_db),
403+
):
404+
"""Pin a post in its submolt. Requires submolt moderator/owner role."""
405+
post = await db.get(Post, post_id)
406+
if post is None:
407+
raise HTTPException(status_code=404, detail="Post not found")
408+
if post.submolt_id is None:
409+
raise HTTPException(
410+
status_code=400,
411+
detail="Only submolt posts can be pinned",
412+
)
413+
414+
# Check submolt role
415+
membership = await db.scalar(
416+
select(SubmoltMembership).where(
417+
SubmoltMembership.submolt_id == post.submolt_id,
418+
SubmoltMembership.entity_id == current_entity.id,
419+
)
420+
)
421+
if not membership or membership.role not in ("owner", "moderator"):
422+
raise HTTPException(
423+
status_code=403,
424+
detail="Must be a submolt moderator or owner to pin posts",
425+
)
426+
427+
post.is_pinned = not post.is_pinned
428+
await db.flush()
429+
return {
430+
"post_id": str(post_id),
431+
"is_pinned": post.is_pinned,
432+
}

src/api/submolt_router.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ class SubmoltFeedPost(BaseModel):
7878
author_type: str
7979
vote_count: int
8080
reply_count: int
81+
is_pinned: bool = False
82+
flair: str | None = None
8183
user_vote: str | None = None
8284
created_at: str
8385

@@ -446,7 +448,9 @@ async def get_submolt_feed(
446448
query = query.where(Post.id < cursor_id)
447449

448450
query = query.order_by(
449-
Post.created_at.desc(), Post.id.desc()
451+
Post.is_pinned.desc(),
452+
Post.created_at.desc(),
453+
Post.id.desc(),
450454
).limit(limit + 1)
451455

452456
result = await db.execute(query)
@@ -490,6 +494,8 @@ async def get_submolt_feed(
490494
author_type=author.type.value,
491495
vote_count=post.vote_count,
492496
reply_count=reply_counts.get(post.id, 0),
497+
is_pinned=post.is_pinned or False,
498+
flair=post.flair,
493499
user_vote=user_votes.get(post.id),
494500
created_at=post.created_at.isoformat(),
495501
))

src/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ class Post(Base):
168168
)
169169
is_hidden = Column(Boolean, default=False)
170170
is_edited = Column(Boolean, default=False)
171+
is_pinned = Column(Boolean, default=False)
171172
edit_count = Column(Integer, default=0)
173+
flair = Column(String(50), nullable=True) # e.g. "discussion", "question", "announcement"
172174

173175
vote_count = Column(Integer, default=0) # denormalized for feed performance
174176

0 commit comments

Comments
 (0)