diff --git a/config/migrate_add_post_insights.sql b/config/migrate_add_post_insights.sql new file mode 100644 index 0000000..add166f --- /dev/null +++ b/config/migrate_add_post_insights.sql @@ -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); + diff --git a/src/database/client.py b/src/database/client.py index b84b8cb..d29438c 100644 --- a/src/database/client.py +++ b/src/database/client.py @@ -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 diff --git a/src/database/models.py b/src/database/models.py index 5f658a3..62a847f 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -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 diff --git a/src/services/post_insights_service.py b/src/services/post_insights_service.py new file mode 100644 index 0000000..595c1d8 --- /dev/null +++ b/src/services/post_insights_service.py @@ -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, + } diff --git a/src/services/scheduler_service.py b/src/services/scheduler_service.py index 9f11e8e..9a56680 100644 --- a/src/services/scheduler_service.py +++ b/src/services/scheduler_service.py @@ -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) diff --git a/src/web/templates/user/base.html b/src/web/templates/user/base.html index 282260f..7b0e4a7 100644 --- a/src/web/templates/user/base.html +++ b/src/web/templates/user/base.html @@ -160,6 +160,15 @@ Unternehmensstrategie {% endif %} + {% if session and session.account_type == 'employee' %} + + + + + Post Insights + Neu + + {% endif %}
diff --git a/src/web/templates/user/employee_dashboard.html b/src/web/templates/user/employee_dashboard.html index 1709b27..31aacec 100644 --- a/src/web/templates/user/employee_dashboard.html +++ b/src/web/templates/user/employee_dashboard.html @@ -6,6 +6,7 @@

Willkommen, {{ session.linkedin_name or session.customer_name or 'Mitarbeiter' }}!

+
{% if session.company_name %}
@@ -143,5 +144,6 @@ Ersten Post erstellen
{% endif %} +
{% endblock %} diff --git a/src/web/templates/user/employee_insights.html b/src/web/templates/user/employee_insights.html new file mode 100644 index 0000000..1ee2752 --- /dev/null +++ b/src/web/templates/user/employee_insights.html @@ -0,0 +1,237 @@ +{% extends "base.html" %} + +{% block title %}Post Insights{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+
+

Post Insights

+

Tägliche Auswertung deiner LinkedIn-Posts

+
+ +
+ + {% if not linkedin_account %} +
+

Verbinde deinen LinkedIn Account, damit wir täglich Post-Insights aktualisieren können.

+ Zum LinkedIn Login +
+ {% elif not post_insights or not post_insights.has_data %} +
+

Noch keine Insights vorhanden. Der tägliche Import läuft in den nächsten 24 Stunden.

+
+ {% else %} +
+
+

Posts getrackt

+

{{ post_insights.total_posts }}

+
+
+

Ø Reaktionen/Post

+

{{ post_insights.avg_reactions }}

+

Likes {{ post_insights.avg_likes }} · Comments {{ post_insights.avg_comments }} · Shares {{ post_insights.avg_shares }}

+
+
+

Letzte 7 Tage

+

{{ post_insights.last_7_reactions }}

+ {% if post_insights.trend_pct is not none %} +

+ {% if post_insights.trend_pct >= 0 %}+{% endif %}{{ post_insights.trend_pct }}% vs. Vorwoche +

+ {% endif %} +
+
+ +
+
+

Engagement-Entwicklung (7 Tage)

+
+
+
+

Reaktions-Mix

+
+
+
+ +
+
+

Wochentag-Performance

+
+
+
+

Performance-Driver

+
+
+ Bester Wochentag + {{ post_insights.best_weekday or 'N/A' }} +
+
+ Posting-Kadenz + {{ post_insights.cadence_per_week or 'N/A' }} Posts/Woche +
+
+ Letzter Snapshot + {{ post_insights.latest_snapshot_date.strftime('%d.%m.%Y') if post_insights.latest_snapshot_date else 'N/A' }} +
+
+
+
+ +
+

Top Posts (Engagement)

+
+ {% for post in post_insights.top_posts %} +
+

{{ post.text[:180] }}{% if post.text|length > 180 %}...{% endif %}

+
+ {{ post.post_date.strftime('%d.%m.%Y') if post.post_date else 'N/A' }} + Likes {{ post.likes }} + Comments {{ post.comments }} + Shares {{ post.shares }} + Score {{ post.engagement_score }} + {% if post.post_url %} + Öffnen + {% endif %} +
+
+ {% endfor %} +
+
+ +
+

Post-Länge vs. Engagement

+
+
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/src/web/user/routes.py b/src/web/user/routes.py index 58622af..1f86687 100644 --- a/src/web/user/routes.py +++ b/src/web/user/routes.py @@ -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 # ============================================================================