Skip to content

Commit 977144b

Browse files
committed
feat: implement persistent bot mode and update docs
1 parent 045d156 commit 977144b

File tree

14 files changed

+489
-71
lines changed

14 files changed

+489
-71
lines changed

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,28 @@ It's built for developers who want to grow their professional presence without s
107107

108108
---
109109

110+
## 🆕 Recent Updates (January 2026)
111+
112+
### Persistent Bot Mode 🤖
113+
114+
- **Auto-Save Drafts**: Generated posts are immediately saved to the database as drafts, preventing data loss on refresh.
115+
- **Historical Stats**: "Generated" and "Published" counts now reflect all-time history, not just the current session.
116+
- **Smart Publishing**: Publishing updates existing drafts instead of creating duplicates.
117+
118+
### Enhanced Analytics 📊
119+
120+
- **Real-Time Accuracy**: Dashboard stats now accurately reflect live counts for Published, Draft, and Scheduled posts.
121+
- **Growth Metrics**: Dynamic week-over-week growth percentages and "Published This Month" tracking.
122+
- **Visual Improvements**: New "Scheduled" stats card and updated "Generated" vs "Published" cards.
123+
124+
### Post Scheduling 📅
125+
126+
- **Schedule for Later**: Ability to schedule posts for future publication dates.
127+
- **Background Processing**: Powered by Celery and Redis for reliable timely delivery.
128+
- **Management**: View and manage scheduled posts directly from the history view.
129+
130+
---
131+
110132
## Security & LinkedIn Compliance
111133

112134
This project prioritizes **safety and compliance**:
@@ -671,7 +693,7 @@ celery -A services.celery_app worker --loglevel=info
671693
celery -A services.celery_app beat --loglevel=info
672694
```
673695

674-
> **Note**: For local development without Redis, scheduled posts will not auto-publish.
696+
> **Note**: For local development without Redis, scheduled posts will not auto-publish.
675697
> The full Docker stack (`make up`) is recommended for testing scheduled functionality.
676698
677699
Open [http://localhost:3000](http://localhost:3000) to access the dashboard.

backend/repositories/posts.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,38 @@ async def get_stats(self) -> Dict:
102102
stats['total'] += count
103103

104104
return stats
105+
106+
async def get_bot_stats(self) -> Dict:
107+
"""
108+
Get aggregated statistics for bot-generated posts.
109+
110+
Returns:
111+
Dictionary with generated and published counts
112+
"""
113+
query = """
114+
SELECT
115+
status,
116+
COUNT(*) as count
117+
FROM post_history
118+
WHERE user_id = $1 AND post_type = 'bot'
119+
GROUP BY status
120+
"""
121+
result = await self.db.fetch_all(query, [self.user_id])
122+
123+
stats = {
124+
'generated': 0,
125+
'published': 0
126+
}
127+
128+
if result:
129+
for row in result:
130+
status = row['status']
131+
count = row['count']
132+
stats['generated'] += count # All bot posts count as generated
133+
if status == 'published':
134+
stats['published'] += count
135+
136+
return stats
105137

106138
async def save_post(
107139
self,

backend/routes/posts.py

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@
6969
get_access_token_for_urn = None
7070
get_token_by_user_id = None
7171

72+
try:
73+
from services.scheduled_posts import schedule_post
74+
except ImportError:
75+
schedule_post = None
76+
7277
try:
7378
from services.auth_service import (
7479
TokenNotFoundError,
@@ -91,11 +96,17 @@
9196
post_generation_limiter = None
9297
publish_limiter = None
9398

99+
try:
100+
from middleware.clerk_auth import get_current_user
101+
except ImportError:
94102
try:
95103
from middleware.clerk_auth import get_current_user
96104
except ImportError:
97105
get_current_user = None
98106

107+
from services.db import get_database
108+
from repositories.posts import PostRepository
109+
99110

100111
# =============================================================================
101112
# REQUEST MODELS
@@ -114,6 +125,13 @@ class PostRequest(BaseModel):
114125
model: Optional[str] = "groq"
115126

116127

128+
class ScheduleRequest(BaseModel):
129+
user_id: str
130+
post_content: str
131+
scheduled_time: int
132+
image_url: Optional[str] = None
133+
134+
117135
class BatchGenerateRequest(BaseModel):
118136
"""Request for batch post generation in Bot Mode."""
119137
user_id: str
@@ -292,15 +310,32 @@ async def generate_batch(req: BatchGenerateRequest):
292310
)
293311

294312
if result:
295-
generated_posts.append({
313+
final_post = {
296314
"id": f"gen_{success_count}_{activity.get('id', '')}",
297315
"content": result.content,
298316
"activity": activity,
299317
"style": style,
300318
"status": "draft",
301319
"provider": result.provider.value,
302320
"model": result.model,
303-
})
321+
}
322+
generated_posts.append(final_post)
323+
324+
# PERSISTENCE: Save as draft immediately
325+
try:
326+
db = get_database()
327+
repo = PostRepository(db, req.user_id)
328+
saved_id = await repo.save_post(
329+
post_content=result.content,
330+
post_type='bot',
331+
context=activity,
332+
status='draft'
333+
)
334+
final_post['id'] = str(saved_id)
335+
final_post['db_id'] = saved_id
336+
except Exception as e:
337+
logger.error("failed_to_persist_post_provider", error=str(e))
338+
304339
used_provider = result.provider.value
305340
was_downgraded = result.was_downgraded
306341
success_count += 1
@@ -315,22 +350,47 @@ async def generate_batch(req: BatchGenerateRequest):
315350
)
316351

317352
if post_content:
318-
generated_posts.append({
353+
final_post = {
319354
"id": f"gen_{success_count}_{activity.get('id', '')}",
320355
"content": post_content,
321356
"activity": activity,
322357
"style": style,
323358
"status": "draft",
324359
"provider": "groq",
325360
"model": "llama-3.3-70b-versatile",
326-
})
361+
}
362+
generated_posts.append(final_post)
363+
364+
# PERSISTENCE: Save as draft immediately
365+
try:
366+
db = get_database()
367+
repo = PostRepository(db, req.user_id)
368+
saved_id = await repo.save_post(
369+
post_content=post_content,
370+
post_type='bot',
371+
context=activity,
372+
status='draft'
373+
)
374+
# Build full object with real ID so frontend can use it for publishing
375+
final_post['id'] = str(saved_id)
376+
final_post['db_id'] = saved_id # explicitly track DB ID
377+
except Exception as e:
378+
logger.error("failed_to_persist_post", error=str(e))
379+
327380
success_count += 1
328381
else:
329382
failed_count += 1
330383

331384
except Exception as e:
332385
logger.error("failed_to_generate_post", error=str(e))
333386
failed_count += 1
387+
388+
# For provider-based generation loop above, we also need persistence
389+
# (Note: I'm patching the loop above in a second chunk or assuming the user meant to cover both paths.
390+
# To be safe and clean, I will wrap the persistence logic in a helper or duplicate it for the first branch if I can't easily merge.)
391+
# Actually, the previous 'if result:' block also needs persistence.
392+
# Let me re-read the file content to ensure I catch both branches.
393+
# The file view showed headers 300-450.
334394

335395
return {
336396
"posts": generated_posts,
@@ -342,6 +402,24 @@ async def generate_batch(req: BatchGenerateRequest):
342402
}
343403

344404

405+
@router.get("/bot-stats")
406+
async def get_bot_stats(
407+
user_id: str,
408+
current_user: dict = Depends(get_current_user) if get_current_user else None
409+
):
410+
"""Get statistics for bot mode (generated vs published)."""
411+
if current_user and current_user.get("user_id") != user_id:
412+
raise HTTPException(status_code=403, detail="Unauthorized")
413+
414+
try:
415+
db = get_database()
416+
repo = PostRepository(db, user_id)
417+
return await repo.get_bot_stats()
418+
except Exception as e:
419+
logger.error("failed_to_get_bot_stats", error=str(e))
420+
return {"generated": 0, "published": 0}
421+
422+
345423
@router.get("/providers")
346424
async def list_providers(
347425
current_user: dict = Depends(get_current_user) if get_current_user else None
@@ -504,3 +582,23 @@ async def publish(req: PostRequest):
504582
if post_to_linkedin:
505583
post_to_linkedin(post, image_asset)
506584
return {"status": "posted", "post": post, "image_asset": image_asset}
585+
586+
587+
@router.post("/schedule")
588+
async def schedule(req: ScheduleRequest):
589+
"""Schedule a post for later publishing."""
590+
if not schedule_post:
591+
raise HTTPException(status_code=500, detail="Schedule service not available")
592+
593+
result = await schedule_post(
594+
user_id=req.user_id,
595+
post_content=req.post_content,
596+
scheduled_time=req.scheduled_time,
597+
image_url=req.image_url
598+
)
599+
600+
if not result.get("success"):
601+
raise HTTPException(status_code=400, detail=result.get("error"))
602+
603+
return result
604+

0 commit comments

Comments
 (0)