added post insight feature
This commit is contained in:
122
config/migrate_add_post_insights.sql
Normal file
122
config/migrate_add_post_insights.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- Migration: Add LinkedIn post insights tables (daily snapshots)
|
||||
-- Description: Stores scraped post stats separately from linkedin_posts
|
||||
|
||||
CREATE TABLE IF NOT EXISTS linkedin_post_insights_posts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
linkedin_account_id UUID REFERENCES linkedin_accounts(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
-- Identity
|
||||
post_urn TEXT NOT NULL,
|
||||
post_url TEXT,
|
||||
|
||||
-- Content
|
||||
post_text TEXT,
|
||||
post_date TIMESTAMP WITH TIME ZONE,
|
||||
author_username TEXT,
|
||||
|
||||
-- Latest known totals (optional convenience)
|
||||
total_reactions INTEGER DEFAULT 0,
|
||||
likes INTEGER DEFAULT 0,
|
||||
comments INTEGER DEFAULT 0,
|
||||
shares INTEGER DEFAULT 0,
|
||||
reactions_breakdown JSONB DEFAULT '{}'::JSONB,
|
||||
|
||||
-- Raw data snapshot
|
||||
raw_data JSONB,
|
||||
|
||||
first_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, post_urn)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS linkedin_post_insights_daily (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
post_id UUID NOT NULL REFERENCES linkedin_post_insights_posts(id) ON DELETE CASCADE,
|
||||
snapshot_date DATE NOT NULL,
|
||||
|
||||
total_reactions INTEGER DEFAULT 0,
|
||||
likes INTEGER DEFAULT 0,
|
||||
comments INTEGER DEFAULT 0,
|
||||
shares INTEGER DEFAULT 0,
|
||||
reactions_breakdown JSONB DEFAULT '{}'::JSONB,
|
||||
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
||||
UNIQUE(post_id, snapshot_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_post_insights_posts_user_id ON linkedin_post_insights_posts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_insights_posts_post_date ON linkedin_post_insights_posts(post_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_insights_daily_user_id ON linkedin_post_insights_daily(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_post_insights_daily_snapshot_date ON linkedin_post_insights_daily(snapshot_date);
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE OR REPLACE FUNCTION update_linkedin_post_insights_posts_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS update_linkedin_post_insights_posts_updated_at ON linkedin_post_insights_posts;
|
||||
CREATE TRIGGER update_linkedin_post_insights_posts_updated_at
|
||||
BEFORE UPDATE ON linkedin_post_insights_posts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_linkedin_post_insights_posts_updated_at();
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_linkedin_post_insights_daily_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS update_linkedin_post_insights_daily_updated_at ON linkedin_post_insights_daily;
|
||||
CREATE TRIGGER update_linkedin_post_insights_daily_updated_at
|
||||
BEFORE UPDATE ON linkedin_post_insights_daily
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_linkedin_post_insights_daily_updated_at();
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE linkedin_post_insights_posts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE linkedin_post_insights_daily ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own post insights posts"
|
||||
ON linkedin_post_insights_posts FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own post insights posts"
|
||||
ON linkedin_post_insights_posts FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own post insights posts"
|
||||
ON linkedin_post_insights_posts FOR UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can delete own post insights posts"
|
||||
ON linkedin_post_insights_posts FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can view own post insights daily"
|
||||
ON linkedin_post_insights_daily FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can insert own post insights daily"
|
||||
ON linkedin_post_insights_daily FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own post insights daily"
|
||||
ON linkedin_post_insights_daily FOR UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can delete own post insights daily"
|
||||
ON linkedin_post_insights_daily FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
@@ -194,6 +194,81 @@ class DatabaseClient:
|
||||
)
|
||||
return [LinkedInPost(**item) for item in result.data]
|
||||
|
||||
# ==================== LINKEDIN POST INSIGHTS ====================
|
||||
|
||||
async def upsert_post_insights_posts(self, posts: List['LinkedInPostInsightPost']) -> List['LinkedInPostInsightPost']:
|
||||
"""Upsert LinkedIn post insights posts."""
|
||||
from datetime import datetime
|
||||
from src.database.models import LinkedInPostInsightPost
|
||||
|
||||
data = []
|
||||
for p in posts:
|
||||
post_dict = p.model_dump(exclude={"id", "created_at", "updated_at", "first_seen_at"}, exclude_none=True)
|
||||
post_dict["user_id"] = str(post_dict["user_id"])
|
||||
if post_dict.get("linkedin_account_id"):
|
||||
post_dict["linkedin_account_id"] = str(post_dict["linkedin_account_id"])
|
||||
if "post_date" in post_dict and isinstance(post_dict["post_date"], datetime):
|
||||
post_dict["post_date"] = post_dict["post_date"].isoformat()
|
||||
data.append(post_dict)
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_post_insights_posts").upsert(
|
||||
data,
|
||||
on_conflict="user_id,post_urn"
|
||||
).execute()
|
||||
)
|
||||
return [LinkedInPostInsightPost(**item) for item in result.data]
|
||||
|
||||
async def upsert_post_insights_daily(self, snapshots: List['LinkedInPostInsightDaily']) -> List['LinkedInPostInsightDaily']:
|
||||
"""Upsert daily snapshots for post insights."""
|
||||
from src.database.models import LinkedInPostInsightDaily
|
||||
|
||||
data = []
|
||||
for s in snapshots:
|
||||
snap_dict = s.model_dump(exclude={"id", "created_at", "updated_at"}, exclude_none=True)
|
||||
snap_dict["user_id"] = str(snap_dict["user_id"])
|
||||
snap_dict["post_id"] = str(snap_dict["post_id"])
|
||||
if "snapshot_date" in snap_dict and hasattr(snap_dict["snapshot_date"], "isoformat"):
|
||||
snap_dict["snapshot_date"] = snap_dict["snapshot_date"].isoformat()
|
||||
data.append(snap_dict)
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_post_insights_daily").upsert(
|
||||
data,
|
||||
on_conflict="post_id,snapshot_date"
|
||||
).execute()
|
||||
)
|
||||
return [LinkedInPostInsightDaily(**item) for item in result.data]
|
||||
|
||||
async def get_post_insights_posts(self, user_id: UUID) -> List['LinkedInPostInsightPost']:
|
||||
"""Get all LinkedIn post insights posts for user."""
|
||||
from src.database.models import LinkedInPostInsightPost
|
||||
result = await asyncio.to_thread(
|
||||
lambda: self.client.table("linkedin_post_insights_posts").select("*").eq(
|
||||
"user_id", str(user_id)
|
||||
).order("post_date", desc=True).execute()
|
||||
)
|
||||
return [LinkedInPostInsightPost(**item) for item in result.data]
|
||||
|
||||
async def get_post_insights_daily(self, user_id: UUID, since_date: Optional[str] = None) -> List['LinkedInPostInsightDaily']:
|
||||
"""Get daily post insights snapshots for user."""
|
||||
from src.database.models import LinkedInPostInsightDaily
|
||||
|
||||
def _query():
|
||||
q = self.client.table("linkedin_post_insights_daily").select("*").eq("user_id", str(user_id))
|
||||
if since_date:
|
||||
q = q.gte("snapshot_date", since_date)
|
||||
return q.order("snapshot_date", desc=False).execute()
|
||||
|
||||
result = await asyncio.to_thread(_query)
|
||||
return [LinkedInPostInsightDaily(**item) for item in result.data]
|
||||
|
||||
async def update_post_classification(
|
||||
self,
|
||||
post_id: UUID,
|
||||
@@ -768,6 +843,19 @@ class DatabaseClient:
|
||||
return LinkedInAccount(**result.data[0])
|
||||
return None
|
||||
|
||||
async def list_linkedin_accounts(self, active_only: bool = True) -> list['LinkedInAccount']:
|
||||
"""List LinkedIn accounts (optionally only active ones)."""
|
||||
from src.database.models import LinkedInAccount
|
||||
|
||||
def _query():
|
||||
q = self.client.table("linkedin_accounts").select("*")
|
||||
if active_only:
|
||||
q = q.eq("is_active", True)
|
||||
return q.execute()
|
||||
|
||||
result = await asyncio.to_thread(_query)
|
||||
return [LinkedInAccount(**item) for item in result.data]
|
||||
|
||||
async def create_linkedin_account(self, account: 'LinkedInAccount') -> 'LinkedInAccount':
|
||||
"""Create LinkedIn account connection."""
|
||||
from src.database.models import LinkedInAccount
|
||||
|
||||
@@ -289,6 +289,45 @@ class LinkedInPost(DBModel):
|
||||
classification_confidence: Optional[float] = None
|
||||
|
||||
|
||||
class LinkedInPostInsightPost(DBModel):
|
||||
"""LinkedIn post record for insights (separate from linkedin_posts)."""
|
||||
id: Optional[UUID] = None
|
||||
user_id: UUID
|
||||
linkedin_account_id: Optional[UUID] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
post_urn: str
|
||||
post_url: Optional[str] = None
|
||||
post_text: Optional[str] = None
|
||||
post_date: Optional[datetime] = None
|
||||
author_username: Optional[str] = None
|
||||
|
||||
total_reactions: int = 0
|
||||
likes: int = 0
|
||||
comments: int = 0
|
||||
shares: int = 0
|
||||
reactions_breakdown: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
raw_data: Optional[Dict[str, Any]] = None
|
||||
first_seen_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class LinkedInPostInsightDaily(DBModel):
|
||||
"""Daily snapshot of LinkedIn post insights."""
|
||||
id: Optional[UUID] = None
|
||||
user_id: UUID
|
||||
post_id: UUID
|
||||
snapshot_date: date
|
||||
total_reactions: int = 0
|
||||
likes: int = 0
|
||||
comments: int = 0
|
||||
shares: int = 0
|
||||
reactions_breakdown: Dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class Topic(DBModel):
|
||||
"""Topic model."""
|
||||
id: Optional[UUID] = None
|
||||
|
||||
409
src/services/post_insights_service.py
Normal file
409
src/services/post_insights_service.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""Post insights scraping and aggregation service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.database.models import LinkedInPostInsightPost, LinkedInPostInsightDaily
|
||||
from src.scraper import scraper
|
||||
|
||||
|
||||
def _parse_post_date(raw_post: Dict[str, Any]) -> Optional[datetime]:
|
||||
posted_at = raw_post.get("posted_at")
|
||||
if isinstance(posted_at, dict):
|
||||
date_str = posted_at.get("date")
|
||||
ts = posted_at.get("timestamp")
|
||||
if date_str:
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
return None
|
||||
if ts:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(ts) / 1000, tz=timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_post_urn(raw_post: Dict[str, Any]) -> Optional[str]:
|
||||
if raw_post.get("full_urn"):
|
||||
return raw_post["full_urn"]
|
||||
urn_obj = raw_post.get("urn") or {}
|
||||
for key, prefix in (("activity_urn", "urn:li:activity:"), ("share_urn", "urn:li:share:"), ("ugcPost_urn", "urn:li:ugcPost:")):
|
||||
val = urn_obj.get(key)
|
||||
if val:
|
||||
if str(val).startswith("urn:li:"):
|
||||
return str(val)
|
||||
return f"{prefix}{val}"
|
||||
return None
|
||||
|
||||
|
||||
def _extract_reactions(stats: Dict[str, Any]) -> Tuple[int, Dict[str, int]]:
|
||||
if not stats:
|
||||
return 0, {}
|
||||
breakdown = {
|
||||
"like": int(stats.get("like", 0) or 0),
|
||||
"love": int(stats.get("love", 0) or 0),
|
||||
"insight": int(stats.get("insight", 0) or 0),
|
||||
"celebrate": int(stats.get("celebrate", 0) or 0),
|
||||
"support": int(stats.get("support", 0) or 0),
|
||||
"funny": int(stats.get("funny", 0) or 0),
|
||||
}
|
||||
total = stats.get("total_reactions")
|
||||
if total is None:
|
||||
total = sum(breakdown.values())
|
||||
return int(total or 0), breakdown
|
||||
|
||||
|
||||
def _extract_username_from_url(linkedin_url: Optional[str]) -> Optional[str]:
|
||||
if not linkedin_url:
|
||||
return None
|
||||
import re
|
||||
url = linkedin_url.rstrip("/")
|
||||
match = re.search(r"/in/([^/?#]+)", url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def parse_apify_posts_for_insights(
|
||||
raw_posts: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
linkedin_account_id: Optional[str]
|
||||
) -> Tuple[List[LinkedInPostInsightPost], List[Dict[str, Any]]]:
|
||||
"""Parse Apify posts into insights models plus raw metadata for snapshots."""
|
||||
parsed: List[LinkedInPostInsightPost] = []
|
||||
snapshot_meta: List[Dict[str, Any]] = []
|
||||
|
||||
for post in raw_posts:
|
||||
post_urn = _extract_post_urn(post)
|
||||
if not post_urn:
|
||||
continue
|
||||
|
||||
stats = post.get("stats") or {}
|
||||
total_reactions, breakdown = _extract_reactions(stats)
|
||||
|
||||
parsed_post = LinkedInPostInsightPost(
|
||||
user_id=user_id,
|
||||
linkedin_account_id=linkedin_account_id,
|
||||
post_urn=post_urn,
|
||||
post_url=post.get("url"),
|
||||
post_text=post.get("text"),
|
||||
post_date=_parse_post_date(post),
|
||||
author_username=(post.get("author") or {}).get("username"),
|
||||
total_reactions=total_reactions,
|
||||
likes=int(stats.get("like", 0) or 0),
|
||||
comments=int(stats.get("comments", 0) or 0),
|
||||
shares=int(stats.get("reposts", 0) or 0),
|
||||
reactions_breakdown=breakdown,
|
||||
raw_data=post,
|
||||
)
|
||||
parsed.append(parsed_post)
|
||||
snapshot_meta.append(
|
||||
{
|
||||
"post_urn": post_urn,
|
||||
"total_reactions": total_reactions,
|
||||
"likes": int(stats.get("like", 0) or 0),
|
||||
"comments": int(stats.get("comments", 0) or 0),
|
||||
"shares": int(stats.get("reposts", 0) or 0),
|
||||
"reactions_breakdown": breakdown,
|
||||
}
|
||||
)
|
||||
|
||||
return parsed, snapshot_meta
|
||||
|
||||
|
||||
async def refresh_post_insights_for_account(db, account) -> None:
|
||||
"""Scrape posts for a connected LinkedIn account and store insights snapshots."""
|
||||
vanity_name = None
|
||||
try:
|
||||
profile = await db.get_profile(account.user_id)
|
||||
vanity_name = _extract_username_from_url(profile.linkedin_url if profile else None)
|
||||
except Exception:
|
||||
vanity_name = None
|
||||
if not vanity_name:
|
||||
logger.warning(f"LinkedIn account {account.id} missing profile link username; skipping insights scrape.")
|
||||
return
|
||||
|
||||
linkedin_url = f"https://www.linkedin.com/in/{vanity_name}/"
|
||||
logger.info(f"Scraping post insights for user {account.user_id} ({vanity_name})")
|
||||
|
||||
raw_posts = await scraper.scrape_posts(linkedin_url, limit=50)
|
||||
if not raw_posts:
|
||||
logger.info(f"No posts found for user {account.user_id}")
|
||||
return
|
||||
|
||||
posts, snapshot_meta = parse_apify_posts_for_insights(
|
||||
raw_posts,
|
||||
user_id=str(account.user_id),
|
||||
linkedin_account_id=str(account.id),
|
||||
)
|
||||
|
||||
if not posts:
|
||||
logger.info(f"No parsable posts for user {account.user_id}")
|
||||
return
|
||||
|
||||
saved_posts = await db.upsert_post_insights_posts(posts)
|
||||
post_id_by_urn = {p.post_urn: p.id for p in saved_posts}
|
||||
|
||||
today = date.today()
|
||||
snapshots: List[LinkedInPostInsightDaily] = []
|
||||
for meta in snapshot_meta:
|
||||
post_id = post_id_by_urn.get(meta["post_urn"])
|
||||
if not post_id:
|
||||
continue
|
||||
snapshots.append(
|
||||
LinkedInPostInsightDaily(
|
||||
user_id=account.user_id,
|
||||
post_id=post_id,
|
||||
snapshot_date=today,
|
||||
total_reactions=meta["total_reactions"],
|
||||
likes=meta["likes"],
|
||||
comments=meta["comments"],
|
||||
shares=meta["shares"],
|
||||
reactions_breakdown=meta["reactions_breakdown"],
|
||||
)
|
||||
)
|
||||
|
||||
if snapshots:
|
||||
await db.upsert_post_insights_daily(snapshots)
|
||||
logger.info(f"Saved {len(snapshots)} post insight snapshots for user {account.user_id}")
|
||||
|
||||
|
||||
async def refresh_all_post_insights(db) -> None:
|
||||
"""Run daily refresh for all active LinkedIn accounts."""
|
||||
accounts = await db.list_linkedin_accounts(active_only=True)
|
||||
if not accounts:
|
||||
return
|
||||
for account in accounts:
|
||||
try:
|
||||
await refresh_post_insights_for_account(db, account)
|
||||
except Exception as exc:
|
||||
logger.error(f"Post insights refresh failed for {account.id}: {exc}")
|
||||
|
||||
|
||||
def _word_count(text: Optional[str]) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
return len([w for w in text.split() if w.strip()])
|
||||
|
||||
|
||||
def _to_naive(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
def compute_post_insights(posts: List[LinkedInPostInsightPost], daily: List[LinkedInPostInsightDaily]) -> Dict[str, Any]:
|
||||
"""Compute aggregated post insights for UI rendering."""
|
||||
if not posts:
|
||||
return {"has_data": False}
|
||||
|
||||
posts_by_id = {p.id: p for p in posts if p.id}
|
||||
|
||||
latest_by_post: Dict[str, LinkedInPostInsightDaily] = {}
|
||||
daily_by_post: Dict[str, List[LinkedInPostInsightDaily]] = defaultdict(list)
|
||||
for snap in daily:
|
||||
if not snap.post_id:
|
||||
continue
|
||||
daily_by_post[str(snap.post_id)].append(snap)
|
||||
current = latest_by_post.get(str(snap.post_id))
|
||||
if not current or snap.snapshot_date > current.snapshot_date:
|
||||
latest_by_post[str(snap.post_id)] = snap
|
||||
|
||||
# Aggregate totals using latest snapshot per post
|
||||
total_posts = len(posts_by_id)
|
||||
total_reactions = 0
|
||||
total_likes = 0
|
||||
total_comments = 0
|
||||
total_shares = 0
|
||||
reaction_mix = defaultdict(int)
|
||||
|
||||
for post_id, snap in latest_by_post.items():
|
||||
total_reactions += snap.total_reactions or 0
|
||||
total_likes += snap.likes or 0
|
||||
total_comments += snap.comments or 0
|
||||
total_shares += snap.shares or 0
|
||||
for k, v in (snap.reactions_breakdown or {}).items():
|
||||
reaction_mix[k] += int(v or 0)
|
||||
|
||||
avg_reactions = round(total_reactions / total_posts, 2) if total_posts else 0
|
||||
avg_likes = round(total_likes / total_posts, 2) if total_posts else 0
|
||||
avg_comments = round(total_comments / total_posts, 2) if total_posts else 0
|
||||
avg_shares = round(total_shares / total_posts, 2) if total_posts else 0
|
||||
|
||||
# Daily deltas (last 30 days)
|
||||
delta_by_date = defaultdict(lambda: {"reactions": 0, "likes": 0, "comments": 0, "shares": 0})
|
||||
for post_id, snaps in daily_by_post.items():
|
||||
snaps_sorted = sorted(snaps, key=lambda s: s.snapshot_date)
|
||||
prev = None
|
||||
for snap in snaps_sorted:
|
||||
if prev:
|
||||
day = snap.snapshot_date
|
||||
delta_by_date[day]["reactions"] += max(0, (snap.total_reactions or 0) - (prev.total_reactions or 0))
|
||||
delta_by_date[day]["likes"] += max(0, (snap.likes or 0) - (prev.likes or 0))
|
||||
delta_by_date[day]["comments"] += max(0, (snap.comments or 0) - (prev.comments or 0))
|
||||
delta_by_date[day]["shares"] += max(0, (snap.shares or 0) - (prev.shares or 0))
|
||||
prev = snap
|
||||
|
||||
today = date.today()
|
||||
last_7_days = [today - timedelta(days=i) for i in range(6, -1, -1)]
|
||||
series = []
|
||||
for day in last_7_days:
|
||||
delta = delta_by_date.get(day, {"reactions": 0, "likes": 0, "comments": 0, "shares": 0})
|
||||
series.append({"date": day, **delta})
|
||||
|
||||
max_reactions = max([d["reactions"] for d in series], default=0) or 1
|
||||
|
||||
last_7 = sum(d["reactions"] for d in series[-7:])
|
||||
prev_7 = sum(d["reactions"] for d in series[-14:-7]) if len(series) >= 14 else 0
|
||||
trend_pct = None
|
||||
if prev_7:
|
||||
trend_pct = round(((last_7 - prev_7) / prev_7) * 100, 1)
|
||||
|
||||
# Top posts by engagement score
|
||||
top_posts = []
|
||||
for post_id, snap in latest_by_post.items():
|
||||
post = None
|
||||
if post_id:
|
||||
try:
|
||||
post = posts_by_id.get(UUID(post_id))
|
||||
except ValueError:
|
||||
post = None
|
||||
if not post:
|
||||
continue
|
||||
score = (snap.likes or 0) + (snap.comments or 0) * 2 + (snap.shares or 0) * 3
|
||||
top_posts.append(
|
||||
{
|
||||
"post_url": post.post_url,
|
||||
"post_date": post.post_date,
|
||||
"text": post.post_text or "",
|
||||
"likes": snap.likes or 0,
|
||||
"comments": snap.comments or 0,
|
||||
"shares": snap.shares or 0,
|
||||
"total_reactions": snap.total_reactions or 0,
|
||||
"engagement_score": score,
|
||||
}
|
||||
)
|
||||
top_posts = sorted(top_posts, key=lambda x: x["engagement_score"], reverse=True)[:5]
|
||||
|
||||
# Posting cadence and weekday performance
|
||||
weekday_stats = defaultdict(lambda: {"count": 0, "engagement": 0})
|
||||
post_dates = []
|
||||
for post in posts_by_id.values():
|
||||
if not post.post_date:
|
||||
continue
|
||||
post_dates.append(_to_naive(post.post_date))
|
||||
snap = latest_by_post.get(str(post.id))
|
||||
score = 0
|
||||
if snap:
|
||||
score = (snap.likes or 0) + (snap.comments or 0) * 2 + (snap.shares or 0) * 3
|
||||
weekday = post.post_date.strftime("%A")
|
||||
weekday_stats[weekday]["count"] += 1
|
||||
weekday_stats[weekday]["engagement"] += score
|
||||
|
||||
best_weekday = None
|
||||
if weekday_stats:
|
||||
best_weekday = max(
|
||||
weekday_stats.items(),
|
||||
key=lambda kv: (kv[1]["engagement"] / kv[1]["count"]) if kv[1]["count"] else 0
|
||||
)[0]
|
||||
weekday_breakdown = []
|
||||
if weekday_stats:
|
||||
order = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
for day in order:
|
||||
stats = weekday_stats.get(day, {"count": 0, "engagement": 0})
|
||||
avg = round(stats["engagement"] / stats["count"], 2) if stats["count"] else 0
|
||||
weekday_breakdown.append({"day": day, "count": stats["count"], "avg_engagement": avg})
|
||||
|
||||
cadence_per_week = None
|
||||
if post_dates:
|
||||
earliest = min(post_dates)
|
||||
weeks = max(1, int((datetime.now() - earliest).days / 7))
|
||||
cadence_per_week = round(len(post_dates) / weeks, 2)
|
||||
|
||||
# Length buckets
|
||||
length_buckets = {
|
||||
"0-100": {"count": 0, "avg_engagement": 0},
|
||||
"101-200": {"count": 0, "avg_engagement": 0},
|
||||
"201-400": {"count": 0, "avg_engagement": 0},
|
||||
"400+": {"count": 0, "avg_engagement": 0},
|
||||
}
|
||||
for post in posts_by_id.values():
|
||||
wc = _word_count(post.post_text)
|
||||
snap = latest_by_post.get(str(post.id))
|
||||
score = 0
|
||||
if snap:
|
||||
score = (snap.likes or 0) + (snap.comments or 0) * 2 + (snap.shares or 0) * 3
|
||||
if wc <= 100:
|
||||
bucket = "0-100"
|
||||
elif wc <= 200:
|
||||
bucket = "101-200"
|
||||
elif wc <= 400:
|
||||
bucket = "201-400"
|
||||
else:
|
||||
bucket = "400+"
|
||||
length_buckets[bucket]["count"] += 1
|
||||
length_buckets[bucket]["avg_engagement"] += score
|
||||
|
||||
for bucket in length_buckets.values():
|
||||
if bucket["count"]:
|
||||
bucket["avg_engagement"] = round(bucket["avg_engagement"] / bucket["count"], 2)
|
||||
|
||||
latest_snapshot_date = None
|
||||
if daily:
|
||||
latest_snapshot_date = max(s.snapshot_date for s in daily)
|
||||
|
||||
reaction_mix_total = sum(reaction_mix.values()) or 1
|
||||
reaction_mix_pct = [
|
||||
{
|
||||
"name": k,
|
||||
"count": v,
|
||||
"pct": round((v / reaction_mix_total) * 100, 1),
|
||||
}
|
||||
for k, v in sorted(reaction_mix.items(), key=lambda x: x[1], reverse=True)
|
||||
]
|
||||
|
||||
series_chart = [
|
||||
{
|
||||
"date": item["date"].isoformat(),
|
||||
"reactions": item["reactions"],
|
||||
"likes": item["likes"],
|
||||
"comments": item["comments"],
|
||||
"shares": item["shares"],
|
||||
}
|
||||
for item in series
|
||||
]
|
||||
|
||||
return {
|
||||
"has_data": True,
|
||||
"total_posts": total_posts,
|
||||
"total_reactions": total_reactions,
|
||||
"avg_reactions": avg_reactions,
|
||||
"avg_likes": avg_likes,
|
||||
"avg_comments": avg_comments,
|
||||
"avg_shares": avg_shares,
|
||||
"last_7_reactions": last_7,
|
||||
"prev_7_reactions": prev_7,
|
||||
"trend_pct": trend_pct,
|
||||
"best_weekday": best_weekday,
|
||||
"weekday_breakdown": weekday_breakdown,
|
||||
"cadence_per_week": cadence_per_week,
|
||||
"reaction_mix": reaction_mix_pct,
|
||||
"series": series,
|
||||
"series_chart": series_chart,
|
||||
"series_max": max_reactions,
|
||||
"top_posts": top_posts,
|
||||
"length_buckets": length_buckets,
|
||||
"latest_snapshot_date": latest_snapshot_date,
|
||||
}
|
||||
@@ -71,6 +71,11 @@ class SchedulerService:
|
||||
await self.db.cleanup_expired_email_tokens()
|
||||
except Exception as e:
|
||||
logger.error(f"Email token cleanup error: {e}")
|
||||
try:
|
||||
from src.services.post_insights_service import refresh_all_post_insights
|
||||
await refresh_all_post_insights(self.db)
|
||||
except Exception as e:
|
||||
logger.error(f"Post insights daily job error: {e}")
|
||||
|
||||
_tick += 1
|
||||
await asyncio.sleep(self.check_interval)
|
||||
|
||||
@@ -160,6 +160,15 @@
|
||||
<span class="sidebar-text">Unternehmensstrategie</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if session and session.account_type == 'employee' %}
|
||||
<a href="/employee/insights" class="nav-link flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:bg-brand-bg-light transition-colors {% if page == 'insights' %}active{% endif %}">
|
||||
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<span class="sidebar-text">Post Insights</span>
|
||||
<span class="ml-auto text-[10px] font-semibold px-2 py-0.5 rounded-full bg-brand-highlight text-brand-bg-dark sidebar-text">Neu</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-gray-600 space-y-2">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Willkommen, {{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}!</h1>
|
||||
|
||||
<div>
|
||||
<!-- Company Info Banner -->
|
||||
{% if session.company_name %}
|
||||
<div class="bg-brand-highlight/10 border border-brand-highlight/30 rounded-xl p-6 mb-8">
|
||||
@@ -144,4 +145,5 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
237
src/web/templates/user/employee_insights.html
Normal file
237
src/web/templates/user/employee_insights.html
Normal file
@@ -0,0 +1,237 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Post Insights{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Post Insights</h1>
|
||||
<p class="text-gray-400 text-sm mt-1">Tägliche Auswertung deiner LinkedIn-Posts</p>
|
||||
</div>
|
||||
<button id="insights-refresh-btn" class="px-4 py-2 rounded-lg text-sm font-medium bg-brand-highlight/20 text-brand-highlight hover:bg-brand-highlight/30 transition-colors">
|
||||
Jetzt aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if not linkedin_account %}
|
||||
<div class="card-bg border rounded-xl p-6 text-center">
|
||||
<p class="text-gray-300 mb-2">Verbinde deinen LinkedIn Account, damit wir täglich Post-Insights aktualisieren können.</p>
|
||||
<a href="/settings" class="inline-block mt-2 text-brand-highlight hover:underline">Zum LinkedIn Login</a>
|
||||
</div>
|
||||
{% elif not post_insights or not post_insights.has_data %}
|
||||
<div class="card-bg border rounded-xl p-6 text-center">
|
||||
<p class="text-gray-300">Noch keine Insights vorhanden. Der tägliche Import läuft in den nächsten 24 Stunden.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<p class="text-gray-400 text-sm">Posts getrackt</p>
|
||||
<p class="text-3xl font-bold text-white">{{ post_insights.total_posts }}</p>
|
||||
</div>
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<p class="text-gray-400 text-sm">Ø Reaktionen/Post</p>
|
||||
<p class="text-3xl font-bold text-white">{{ post_insights.avg_reactions }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Likes {{ post_insights.avg_likes }} · Comments {{ post_insights.avg_comments }} · Shares {{ post_insights.avg_shares }}</p>
|
||||
</div>
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<p class="text-gray-400 text-sm">Letzte 7 Tage</p>
|
||||
<p class="text-3xl font-bold text-white">{{ post_insights.last_7_reactions }}</p>
|
||||
{% if post_insights.trend_pct is not none %}
|
||||
<p class="text-xs mt-1 {% if post_insights.trend_pct >= 0 %}text-green-400{% else %}text-red-400{% endif %}">
|
||||
{% if post_insights.trend_pct >= 0 %}+{% endif %}{{ post_insights.trend_pct }}% vs. Vorwoche
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Engagement-Entwicklung (7 Tage)</h2>
|
||||
<div id="chart-engagement"></div>
|
||||
</div>
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Reaktions-Mix</h2>
|
||||
<div id="chart-reactions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Wochentag-Performance</h3>
|
||||
<div id="chart-weekday"></div>
|
||||
</div>
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Performance-Driver</h3>
|
||||
<div class="text-sm text-gray-400 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Bester Wochentag</span>
|
||||
<span class="text-gray-200">{{ post_insights.best_weekday or 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Posting-Kadenz</span>
|
||||
<span class="text-gray-200">{{ post_insights.cadence_per_week or 'N/A' }} Posts/Woche</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Letzter Snapshot</span>
|
||||
<span class="text-gray-200">{{ post_insights.latest_snapshot_date.strftime('%d.%m.%Y') if post_insights.latest_snapshot_date else 'N/A' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg border rounded-xl p-6 mb-8">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Top Posts (Engagement)</h2>
|
||||
<div class="space-y-3">
|
||||
{% for post in post_insights.top_posts %}
|
||||
<div class="p-4 bg-brand-bg-dark rounded-lg">
|
||||
<p class="text-white text-sm line-clamp-2">{{ post.text[:180] }}{% if post.text|length > 180 %}...{% endif %}</p>
|
||||
<div class="text-xs text-gray-500 mt-2 flex items-center gap-3">
|
||||
<span>{{ post.post_date.strftime('%d.%m.%Y') if post.post_date else 'N/A' }}</span>
|
||||
<span>Likes {{ post.likes }}</span>
|
||||
<span>Comments {{ post.comments }}</span>
|
||||
<span>Shares {{ post.shares }}</span>
|
||||
<span>Score {{ post.engagement_score }}</span>
|
||||
{% if post.post_url %}
|
||||
<a href="{{ post.post_url }}" target="_blank" class="text-brand-highlight hover:underline">Öffnen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-bg border rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Post-Länge vs. Engagement</h2>
|
||||
<div id="chart-length"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const refreshBtn = document.getElementById('insights-refresh-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', async () => {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.textContent = 'Aktualisiere...';
|
||||
try {
|
||||
const res = await fetch('/api/insights/refresh', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
refreshBtn.textContent = 'Aktualisiert (heute)';
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
} else if (res.status === 429) {
|
||||
refreshBtn.textContent = 'Heute bereits genutzt';
|
||||
} else if (res.status === 400) {
|
||||
refreshBtn.textContent = 'Kein LinkedIn Login';
|
||||
} else {
|
||||
refreshBtn.textContent = 'Fehler';
|
||||
}
|
||||
} catch (e) {
|
||||
refreshBtn.textContent = 'Fehler';
|
||||
} finally {
|
||||
setTimeout(() => { refreshBtn.disabled = false; }, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{% if post_insights and post_insights.has_data %}
|
||||
const seriesData = {{ post_insights.series_chart | tojson }};
|
||||
const reactionMix = {{ post_insights.reaction_mix | tojson }};
|
||||
const weekdayBreakdown = {{ post_insights.weekday_breakdown | tojson }};
|
||||
const lengthBuckets = {{ post_insights.length_buckets | tojson }};
|
||||
|
||||
if (seriesData && seriesData.length) {
|
||||
const dates = seriesData.map(d => d.date.slice(5, 10));
|
||||
const reactions = seriesData.map(d => d.reactions);
|
||||
const likes = seriesData.map(d => d.likes);
|
||||
const comments = seriesData.map(d => d.comments);
|
||||
const shares = seriesData.map(d => d.shares);
|
||||
|
||||
const engagementChart = new ApexCharts(document.querySelector('#chart-engagement'), {
|
||||
chart: { type: 'area', height: 260, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
dataLabels: { enabled: false },
|
||||
colors: ['#e94560', '#60a5fa', '#f59e0b', '#10b981'],
|
||||
series: [
|
||||
{ name: 'Reaktionen', data: reactions },
|
||||
{ name: 'Likes', data: likes },
|
||||
{ name: 'Comments', data: comments },
|
||||
{ name: 'Shares', data: shares }
|
||||
],
|
||||
xaxis: { categories: dates, labels: { style: { colors: '#9ca3af' } } },
|
||||
yaxis: { labels: { style: { colors: '#9ca3af' } } },
|
||||
grid: { borderColor: 'rgba(233,69,96,0.12)' },
|
||||
legend: { labels: { colors: '#e5e7eb' } },
|
||||
fill: { type: 'gradient', gradient: { opacityFrom: 0.35, opacityTo: 0.05 } },
|
||||
tooltip: { theme: 'dark' }
|
||||
});
|
||||
engagementChart.render();
|
||||
}
|
||||
|
||||
if (reactionMix && reactionMix.length) {
|
||||
const labels = reactionMix.map(r => r.name);
|
||||
const values = reactionMix.map(r => r.count);
|
||||
const reactionsChart = new ApexCharts(document.querySelector('#chart-reactions'), {
|
||||
chart: { type: 'donut', height: 260, foreColor: '#e5e7eb', background: 'transparent' },
|
||||
labels,
|
||||
series: values,
|
||||
legend: { labels: { colors: '#e5e7eb' } },
|
||||
dataLabels: { style: { colors: ['#111827'] } },
|
||||
colors: ['#e94560', '#60a5fa', '#f59e0b', '#10b981', '#a855f7', '#f97316'],
|
||||
stroke: { colors: ['#1a1a2e'] }
|
||||
});
|
||||
reactionsChart.render();
|
||||
}
|
||||
|
||||
if (weekdayBreakdown && weekdayBreakdown.length) {
|
||||
const dayLabels = weekdayBreakdown.map(d => d.day.slice(0, 3));
|
||||
const avgEngagement = weekdayBreakdown.map(d => d.avg_engagement);
|
||||
const counts = weekdayBreakdown.map(d => d.count);
|
||||
const weekdayChart = new ApexCharts(document.querySelector('#chart-weekday'), {
|
||||
chart: { type: 'bar', height: 240, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
|
||||
series: [
|
||||
{ name: 'Ø Engagement', data: avgEngagement },
|
||||
{ name: 'Posts', data: counts }
|
||||
],
|
||||
plotOptions: { bar: { columnWidth: '45%', borderRadius: 4 } },
|
||||
xaxis: { categories: dayLabels, labels: { style: { colors: '#9ca3af' } } },
|
||||
yaxis: { labels: { style: { colors: '#9ca3af' } } },
|
||||
grid: { borderColor: 'rgba(233,69,96,0.12)' },
|
||||
colors: ['#e94560', '#60a5fa'],
|
||||
legend: { labels: { colors: '#e5e7eb' } },
|
||||
tooltip: { theme: 'dark' }
|
||||
});
|
||||
weekdayChart.render();
|
||||
}
|
||||
|
||||
if (lengthBuckets) {
|
||||
const bucketLabels = Object.keys(lengthBuckets);
|
||||
const counts = bucketLabels.map(k => lengthBuckets[k].count || 0);
|
||||
const avgScores = bucketLabels.map(k => lengthBuckets[k].avg_engagement || 0);
|
||||
const lengthChart = new ApexCharts(document.querySelector('#chart-length'), {
|
||||
chart: { type: 'bar', height: 240, toolbar: { show: false }, foreColor: '#e5e7eb', background: 'transparent' },
|
||||
series: [
|
||||
{ name: 'Posts', data: counts },
|
||||
{ name: 'Ø Score', data: avgScores }
|
||||
],
|
||||
plotOptions: { bar: { columnWidth: '45%', borderRadius: 4 } },
|
||||
xaxis: { categories: bucketLabels, labels: { style: { colors: '#9ca3af' } } },
|
||||
yaxis: { labels: { style: { colors: '#9ca3af' } } },
|
||||
grid: { borderColor: 'rgba(233,69,96,0.12)' },
|
||||
colors: ['#10b981', '#f59e0b'],
|
||||
legend: { labels: { colors: '#e5e7eb' } },
|
||||
tooltip: { theme: 'dark' }
|
||||
});
|
||||
lengthChart.render();
|
||||
}
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -44,6 +44,7 @@ from src.services.storage_service import storage
|
||||
from src.services.link_extractor import LinkExtractor, LinkExtractionError
|
||||
from src.services.file_extractor import FileExtractor, FileExtractionError
|
||||
from src.agents.link_topic_builder import LinkTopicBuilderAgent
|
||||
from src.services.post_insights_service import compute_post_insights, refresh_post_insights_for_account
|
||||
|
||||
# Router for user frontend
|
||||
user_router = APIRouter(tags=["user"])
|
||||
@@ -2928,7 +2929,7 @@ async def linkedin_callback(
|
||||
linkedin_vanity_name = None
|
||||
try:
|
||||
profile_response = await client.get(
|
||||
"https://api.linkedin.com/v2/me",
|
||||
"https://api.linkedin.com/v2/me?projection=(id,vanityName,localizedFirstName,localizedLastName)",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
if profile_response.status_code == 200:
|
||||
@@ -3016,6 +3017,41 @@ async def linkedin_disconnect(request: Request):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== POST INSIGHTS ====================
|
||||
|
||||
@user_router.post("/api/insights/refresh")
|
||||
async def refresh_post_insights(request: Request):
|
||||
"""Manually refresh post insights (max once per day)."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
user_id = UUID(session.user_id)
|
||||
linkedin_account = await db.get_linkedin_account(user_id)
|
||||
if not linkedin_account:
|
||||
raise HTTPException(status_code=400, detail="LinkedIn account not connected")
|
||||
|
||||
profile = await db.get_profile(user_id)
|
||||
metadata = profile.metadata or {}
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
last_refresh = metadata.get("post_insights_manual_refresh_date")
|
||||
if last_refresh == today:
|
||||
raise HTTPException(status_code=429, detail="Manual refresh already used today")
|
||||
|
||||
await refresh_post_insights_for_account(db, linkedin_account)
|
||||
metadata["post_insights_manual_refresh_date"] = today
|
||||
await db.update_profile(user_id, {"metadata": metadata})
|
||||
|
||||
return {"success": True, "refreshed_at": today}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to refresh post insights: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== COMPANY MANAGEMENT ENDPOINTS ====================
|
||||
|
||||
@user_router.get("/company/strategy", response_class=HTMLResponse)
|
||||
@@ -3579,6 +3615,50 @@ async def employee_strategy_page(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@user_router.get("/employee/insights", response_class=HTMLResponse)
|
||||
async def employee_insights_page(request: Request):
|
||||
"""Employee post insights page."""
|
||||
session = require_user_session(request)
|
||||
if not session:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
if session.account_type != "employee":
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
try:
|
||||
user_id = UUID(session.user_id)
|
||||
profile_picture = await get_user_avatar(session, user_id)
|
||||
linkedin_account = await db.get_linkedin_account(user_id)
|
||||
|
||||
post_insights = {"has_data": False}
|
||||
if linkedin_account:
|
||||
try:
|
||||
from datetime import date, timedelta
|
||||
since = (date.today() - timedelta(days=90)).isoformat()
|
||||
insights_posts = await db.get_post_insights_posts(user_id)
|
||||
insights_daily = await db.get_post_insights_daily(user_id, since_date=since)
|
||||
post_insights = compute_post_insights(insights_posts, insights_daily)
|
||||
except Exception as e:
|
||||
logger.error(f"Error computing post insights: {e}")
|
||||
|
||||
return templates.TemplateResponse("employee_insights.html", {
|
||||
"request": request,
|
||||
"page": "insights",
|
||||
"session": session,
|
||||
"profile_picture": profile_picture,
|
||||
"linkedin_account": linkedin_account,
|
||||
"post_insights": post_insights
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading insights: {e}")
|
||||
return templates.TemplateResponse("employee_insights.html", {
|
||||
"request": request,
|
||||
"page": "insights",
|
||||
"session": session,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EMPLOYEE POST TYPES MANAGEMENT
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user