Add Reddit monitoring bot — backend, frontend, and Docker config
Python/FastAPI backend with PostgreSQL for collecting Reddit data via public .json endpoints. React/Vite dashboard for analytics. Docker Compose setup with API and worker services connecting to shared PostgreSQL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Database (shared PostgreSQL instance)
|
||||||
|
DATABASE_URL=postgresql+asyncpg://reddit:changeme@localhost:5432/reddit_monitor
|
||||||
|
|
||||||
|
# Reddit scraping
|
||||||
|
REDDIT_USER_AGENT=reddit-monitor:v1.0
|
||||||
|
|
||||||
|
# Subreddits to auto-add on first worker startup (comma-separated)
|
||||||
|
SEED_SUBREDDITS=
|
||||||
|
|
||||||
|
# Daily digest generation hour (UTC, 0-23)
|
||||||
|
DIGEST_HOUR_UTC=23
|
||||||
|
|
||||||
|
# AI summaries (stub — enable later)
|
||||||
|
AI_SUMMARY_ENABLED=false
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
alembic/versions/__pycache__/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Stage 1: Build React frontend
|
||||||
|
FROM node:20-alpine AS frontend
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Python runtime
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ backend/
|
||||||
|
COPY alembic/ alembic/
|
||||||
|
COPY alembic.ini .
|
||||||
|
|
||||||
|
# Copy frontend build output
|
||||||
|
COPY --from=frontend /app/frontend/dist/ static/
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["sh", "-c", "alembic upgrade head && uvicorn backend.main:app --host 0.0.0.0 --port 8000"]
|
||||||
11
Dockerfile.worker
Normal file
11
Dockerfile.worker
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/ backend/
|
||||||
|
COPY alembic/ alembic/
|
||||||
|
COPY alembic.ini .
|
||||||
|
|
||||||
|
CMD ["python", "-m", "backend.worker.main"]
|
||||||
36
alembic.ini
Normal file
36
alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
56
alembic/env.py
Normal file
56
alembic/env.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
18
backend/config.py
Normal file
18
backend/config.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: str = "postgresql+asyncpg://reddit:changeme@localhost:5432/reddit_monitor"
|
||||||
|
reddit_user_agent: str = "reddit-monitor:v1.0"
|
||||||
|
seed_subreddits: str = ""
|
||||||
|
digest_hour_utc: int = 23
|
||||||
|
ai_summary_enabled: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url_sync(self) -> str:
|
||||||
|
return self.database_url.replace("+asyncpg", "+psycopg2")
|
||||||
|
|
||||||
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
18
backend/database.py
Normal file
18
backend/database.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_recycle=3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
async with async_session() as session:
|
||||||
|
yield session
|
||||||
39
backend/main.py
Normal file
39
backend/main.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from backend.database import engine
|
||||||
|
from backend.routers import health, subreddits, posts, comments, authors, analytics, digests, summaries
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Reddit Monitor", lifespan=lifespan)
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
app.include_router(health.router, prefix="/api/v1")
|
||||||
|
app.include_router(subreddits.router, prefix="/api/v1")
|
||||||
|
app.include_router(posts.router, prefix="/api/v1")
|
||||||
|
app.include_router(comments.router, prefix="/api/v1")
|
||||||
|
app.include_router(authors.router, prefix="/api/v1")
|
||||||
|
app.include_router(analytics.router, prefix="/api/v1")
|
||||||
|
app.include_router(digests.router, prefix="/api/v1")
|
||||||
|
app.include_router(summaries.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
# SPA static file serving (only when frontend is built)
|
||||||
|
import os
|
||||||
|
|
||||||
|
static_dir = os.path.join(os.path.dirname(__file__), "..", "static")
|
||||||
|
if os.path.isdir(static_dir):
|
||||||
|
app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets")
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
return FileResponse(os.path.join(static_dir, "index.html"))
|
||||||
19
backend/models/__init__.py
Normal file
19
backend/models/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from backend.models.base import Base
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
from backend.models.author import Author
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.comment import Comment
|
||||||
|
from backend.models.metric_snapshot import MetricSnapshot
|
||||||
|
from backend.models.daily_digest import DailyDigest
|
||||||
|
from backend.models.summary import Summary
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"MonitoredSubreddit",
|
||||||
|
"Author",
|
||||||
|
"Post",
|
||||||
|
"Comment",
|
||||||
|
"MetricSnapshot",
|
||||||
|
"DailyDigest",
|
||||||
|
"Summary",
|
||||||
|
]
|
||||||
23
backend/models/author.py
Normal file
23
backend/models/author.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Integer, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from backend.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Author(Base):
|
||||||
|
__tablename__ = "authors"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
total_posts: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
total_comments: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
posts: Mapped[list["Post"]] = relationship(back_populates="author") # noqa: F821
|
||||||
|
comments: Mapped[list["Comment"]] = relationship(back_populates="author") # noqa: F821
|
||||||
5
backend/models/base.py
Normal file
5
backend/models/base.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
34
backend/models/comment.py
Normal file
34
backend/models/comment.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Integer, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from backend.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(Base):
|
||||||
|
__tablename__ = "comments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
reddit_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"), nullable=False, index=True)
|
||||||
|
parent_comment_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("comments.id"), index=True
|
||||||
|
)
|
||||||
|
author_id: Mapped[int | None] = mapped_column(ForeignKey("authors.id"), index=True)
|
||||||
|
body: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
score: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
collected_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
post: Mapped["Post"] = relationship(back_populates="comments") # noqa: F821
|
||||||
|
author: Mapped["Author | None"] = relationship(back_populates="comments") # noqa: F821
|
||||||
|
parent_comment: Mapped["Comment | None"] = relationship(
|
||||||
|
remote_side="Comment.id", foreign_keys=[parent_comment_id]
|
||||||
|
)
|
||||||
22
backend/models/daily_digest.py
Normal file
22
backend/models/daily_digest.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from backend.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DailyDigest(Base):
|
||||||
|
__tablename__ = "daily_digests"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
subreddit_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("monitored_subreddits.id"), nullable=False
|
||||||
|
)
|
||||||
|
digest_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON)
|
||||||
|
generated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
subreddit: Mapped["MonitoredSubreddit"] = relationship(back_populates="daily_digests") # noqa: F821
|
||||||
23
backend/models/metric_snapshot.py
Normal file
23
backend/models/metric_snapshot.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Integer, Float, DateTime, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from backend.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MetricSnapshot(Base):
|
||||||
|
__tablename__ = "metric_snapshots"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_metric_snapshots_post_snapshot", "post_id", "snapshot_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"), nullable=False)
|
||||||
|
score: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
num_comments: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
upvote_ratio: Mapped[float | None] = mapped_column(Float)
|
||||||
|
snapshot_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
post: Mapped["Post"] = relationship(back_populates="metric_snapshots") # noqa: F821
|
||||||
42
backend/models/post.py
Normal file
42
backend/models/post.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, Integer, Float, DateTime, ForeignKey, Index
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from backend.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Post(Base):
|
||||||
|
__tablename__ = "posts"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_posts_subreddit_created", "subreddit_id", "created_utc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
reddit_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
subreddit_id: Mapped[int] = mapped_column(ForeignKey("monitored_subreddits.id"), index=True)
|
||||||
|
author_id: Mapped[int | None] = mapped_column(ForeignKey("authors.id"), index=True)
|
||||||
|
title: Mapped[str] = mapped_column(nullable=False)
|
||||||
|
selftext: Mapped[str | None] = mapped_column()
|
||||||
|
url: Mapped[str | None] = mapped_column()
|
||||||
|
permalink: Mapped[str | None] = mapped_column()
|
||||||
|
flair: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
score: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||||
|
upvote_ratio: Mapped[float | None] = mapped_column(Float)
|
||||||
|
num_comments: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
is_self: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
|
over_18: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
hot_rank: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
created_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
collected_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
subreddit: Mapped["MonitoredSubreddit"] = relationship(back_populates="posts") # noqa: F821
|
||||||
|
author: Mapped["Author | None"] = relationship(back_populates="posts") # noqa: F821
|
||||||
|
comments: Mapped[list["Comment"]] = relationship(back_populates="post") # noqa: F821
|
||||||
|
metric_snapshots: Mapped[list["MetricSnapshot"]] = relationship(back_populates="post") # noqa: F821
|
||||||
28
backend/models/subreddit.py
Normal file
28
backend/models/subreddit.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, Boolean, Integer, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from backend.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoredSubreddit(Base):
|
||||||
|
__tablename__ = "monitored_subreddits"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
description: Mapped[str | None] = mapped_column()
|
||||||
|
subscribers: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
posts: Mapped[list["Post"]] = relationship(back_populates="subreddit") # noqa: F821
|
||||||
|
daily_digests: Mapped[list["DailyDigest"]] = relationship(back_populates="subreddit") # noqa: F821
|
||||||
|
summaries: Mapped[list["Summary"]] = relationship(back_populates="subreddit") # noqa: F821
|
||||||
25
backend/models/summary.py
Normal file
25
backend/models/summary.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from backend.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Summary(Base):
|
||||||
|
__tablename__ = "summaries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
subreddit_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("monitored_subreddits.id"), nullable=False
|
||||||
|
)
|
||||||
|
summary_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
content: Mapped[str | None] = mapped_column()
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSON)
|
||||||
|
period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
generated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
subreddit: Mapped["MonitoredSubreddit"] = relationship(back_populates="summaries") # noqa: F821
|
||||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
61
backend/routers/analytics.py
Normal file
61
backend/routers/analytics.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services import analytics_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/engagement")
|
||||||
|
async def engagement(
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
granularity: str = Query("day", pattern="^(hour|day|week)$"),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await analytics_service.get_engagement(db, subreddit_id, granularity, since, until)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/top-posts")
|
||||||
|
async def top_posts(
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
metric: str = Query("score", pattern="^(score|num_comments)$"),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await analytics_service.get_top_posts(db, subreddit_id, metric, since, until, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/top-authors")
|
||||||
|
async def top_authors(
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await analytics_service.get_top_authors(db, subreddit_id, since, until, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subreddit-summary")
|
||||||
|
async def subreddit_summary(
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await analytics_service.get_subreddit_summary(db, since, until)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flair-distribution")
|
||||||
|
async def flair_distribution(
|
||||||
|
subreddit_id: int = Query(...),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await analytics_service.get_flair_distribution(db, subreddit_id, since, until)
|
||||||
39
backend/routers/authors.py
Normal file
39
backend/routers/authors.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services import author_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_authors(
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
sort_by: str = Query("total_comments", pattern="^(total_posts|total_comments)$"),
|
||||||
|
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
authors, total = await author_service.list_authors(
|
||||||
|
db, subreddit_id, sort_by, sort_order, since, until, page, per_page
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data": authors,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"pages": (total + per_page - 1) // per_page if per_page else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{author_id}")
|
||||||
|
async def get_author(author_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
author = await author_service.get_author(db, author_id)
|
||||||
|
if not author:
|
||||||
|
raise HTTPException(status_code=404, detail="Author not found")
|
||||||
|
return author
|
||||||
33
backend/routers/comments.py
Normal file
33
backend/routers/comments.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.services import comment_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/comments", tags=["comments"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_comments(
|
||||||
|
post_id: int | None = None,
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
author: str | None = None,
|
||||||
|
sort_by: str = Query("created_utc", pattern="^(created_utc|score)$"),
|
||||||
|
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
comments, total = await comment_service.list_comments(
|
||||||
|
db, post_id, subreddit_id, author, sort_by, sort_order, since, until, page, per_page
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data": comments,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"pages": (total + per_page - 1) // per_page if per_page else 0,
|
||||||
|
}
|
||||||
54
backend/routers/digests.py
Normal file
54
backend/routers/digests.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models.daily_digest import DailyDigest
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/digests", tags=["digests"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_digests(
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = (
|
||||||
|
select(DailyDigest, MonitoredSubreddit.name)
|
||||||
|
.join(MonitoredSubreddit)
|
||||||
|
.order_by(DailyDigest.digest_date.desc())
|
||||||
|
)
|
||||||
|
if subreddit_id:
|
||||||
|
stmt = stmt.where(DailyDigest.subreddit_id == subreddit_id)
|
||||||
|
|
||||||
|
stmt = stmt.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
|
||||||
|
digests = []
|
||||||
|
for digest, sub_name in result.all():
|
||||||
|
data = {c.name: getattr(digest, c.name) for c in digest.__table__.columns}
|
||||||
|
data["subreddit_name"] = sub_name
|
||||||
|
digests.append(data)
|
||||||
|
|
||||||
|
return {"data": digests, "page": page, "per_page": per_page}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{digest_id}")
|
||||||
|
async def get_digest(digest_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = (
|
||||||
|
select(DailyDigest, MonitoredSubreddit.name)
|
||||||
|
.join(MonitoredSubreddit)
|
||||||
|
.where(DailyDigest.id == digest_id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Digest not found")
|
||||||
|
|
||||||
|
digest, sub_name = row
|
||||||
|
data = {c.name: getattr(digest, c.name) for c in digest.__table__.columns}
|
||||||
|
data["subreddit_name"] = sub_name
|
||||||
|
return data
|
||||||
16
backend/routers/health.py
Normal file
16
backend/routers/health.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check(db: AsyncSession = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
return {"status": "ok", "db": "connected"}
|
||||||
|
except Exception:
|
||||||
|
return {"status": "degraded", "db": "disconnected"}
|
||||||
64
backend/routers/posts.py
Normal file
64
backend/routers/posts.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.schemas.post import PostResponse, PostDetailResponse
|
||||||
|
from backend.services import post_service
|
||||||
|
from backend.models.metric_snapshot import MetricSnapshot
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/posts", tags=["posts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_posts(
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
author: str | None = None,
|
||||||
|
flair: str | None = None,
|
||||||
|
sort_by: str = Query("created_utc", pattern="^(created_utc|score|num_comments)$"),
|
||||||
|
sort_order: str = Query("desc", pattern="^(asc|desc)$"),
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(25, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
posts, total = await post_service.list_posts(
|
||||||
|
db, subreddit_id, author, flair, sort_by, sort_order, since, until, page, per_page
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"data": posts,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"pages": (total + per_page - 1) // per_page if per_page else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{post_id}")
|
||||||
|
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
post = await post_service.get_post(db, post_id)
|
||||||
|
if not post:
|
||||||
|
raise HTTPException(status_code=404, detail="Post not found")
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{post_id}/snapshots")
|
||||||
|
async def get_post_snapshots(post_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
stmt = (
|
||||||
|
select(MetricSnapshot)
|
||||||
|
.where(MetricSnapshot.post_id == post_id)
|
||||||
|
.order_by(MetricSnapshot.snapshot_at.asc())
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
snapshots = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"score": s.score,
|
||||||
|
"num_comments": s.num_comments,
|
||||||
|
"upvote_ratio": s.upvote_ratio,
|
||||||
|
"snapshot_at": s.snapshot_at,
|
||||||
|
}
|
||||||
|
for s in snapshots
|
||||||
|
]
|
||||||
47
backend/routers/subreddits.py
Normal file
47
backend/routers/subreddits.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.schemas.subreddit import SubredditCreate, SubredditUpdate, SubredditResponse
|
||||||
|
from backend.services import subreddit_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/subreddits", tags=["subreddits"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[SubredditResponse])
|
||||||
|
async def list_subreddits(db: AsyncSession = Depends(get_db)):
|
||||||
|
return await subreddit_service.list_subreddits(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SubredditResponse, status_code=201)
|
||||||
|
async def create_subreddit(body: SubredditCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
sub = await subreddit_service.create_subreddit(db, body.name)
|
||||||
|
data = {c.name: getattr(sub, c.name) for c in sub.__table__.columns}
|
||||||
|
data["post_count"] = 0
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{subreddit_id}", response_model=SubredditResponse)
|
||||||
|
async def get_subreddit(subreddit_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
sub = await subreddit_service.get_subreddit(db, subreddit_id)
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(status_code=404, detail="Subreddit not found")
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{subreddit_id}", response_model=SubredditResponse)
|
||||||
|
async def update_subreddit(
|
||||||
|
subreddit_id: int, body: SubredditUpdate, db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
sub = await subreddit_service.update_subreddit(db, subreddit_id, body.is_active)
|
||||||
|
if not sub:
|
||||||
|
raise HTTPException(status_code=404, detail="Subreddit not found")
|
||||||
|
result = await subreddit_service.get_subreddit(db, subreddit_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{subreddit_id}", status_code=204)
|
||||||
|
async def delete_subreddit(subreddit_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
deleted = await subreddit_service.delete_subreddit(db, subreddit_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Subreddit not found")
|
||||||
13
backend/routers/summaries.py
Normal file
13
backend/routers/summaries.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/summaries", tags=["summaries"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_summaries():
|
||||||
|
return {"data": [], "message": "AI summaries not yet configured"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{summary_id}")
|
||||||
|
async def get_summary(summary_id: int):
|
||||||
|
return {"detail": "AI summaries not yet configured"}
|
||||||
0
backend/schemas/__init__.py
Normal file
0
backend/schemas/__init__.py
Normal file
54
backend/schemas/analytics.py
Normal file
54
backend/schemas/analytics.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from datetime import datetime, date
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class EngagementPoint(BaseModel):
|
||||||
|
period: str
|
||||||
|
posts: int
|
||||||
|
comments: int
|
||||||
|
avg_score: float
|
||||||
|
|
||||||
|
|
||||||
|
class TopPost(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
score: int
|
||||||
|
num_comments: int
|
||||||
|
author_name: str | None
|
||||||
|
subreddit_name: str
|
||||||
|
created_utc: datetime
|
||||||
|
permalink: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class TopAuthor(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
post_count: int
|
||||||
|
comment_count: int
|
||||||
|
total_activity: int
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditSummary(BaseModel):
|
||||||
|
subreddit_id: int
|
||||||
|
subreddit_name: str
|
||||||
|
total_posts: int
|
||||||
|
total_comments: int
|
||||||
|
avg_score: float
|
||||||
|
top_flair: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class FlairCount(BaseModel):
|
||||||
|
flair: str | None
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class DigestResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
subreddit_id: int
|
||||||
|
subreddit_name: str | None = None
|
||||||
|
digest_date: date
|
||||||
|
content: str
|
||||||
|
metadata_: dict | None = None
|
||||||
|
generated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
13
backend/schemas/author.py
Normal file
13
backend/schemas/author.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
first_seen_at: datetime
|
||||||
|
last_seen_at: datetime
|
||||||
|
total_posts: int
|
||||||
|
total_comments: int
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
19
backend/schemas/common.py
Normal file
19
backend/schemas/common.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationParams(BaseModel):
|
||||||
|
page: int = 1
|
||||||
|
per_page: int = 25
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
per_page: int
|
||||||
|
pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class TimeRangeParams(BaseModel):
|
||||||
|
since: datetime | None = None
|
||||||
|
until: datetime | None = None
|
||||||
45
backend/schemas/post.py
Normal file
45
backend/schemas/post.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class PostResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
reddit_id: str
|
||||||
|
subreddit_id: int
|
||||||
|
subreddit_name: str | None = None
|
||||||
|
author_id: int | None
|
||||||
|
author_name: str | None = None
|
||||||
|
title: str
|
||||||
|
selftext: str | None
|
||||||
|
url: str | None
|
||||||
|
permalink: str | None
|
||||||
|
flair: str | None
|
||||||
|
score: int
|
||||||
|
upvote_ratio: float | None
|
||||||
|
num_comments: int
|
||||||
|
is_self: bool | None
|
||||||
|
over_18: bool
|
||||||
|
hot_rank: int | None
|
||||||
|
created_utc: datetime
|
||||||
|
collected_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class PostDetailResponse(PostResponse):
|
||||||
|
comments: list["CommentResponse"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class CommentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
reddit_id: str
|
||||||
|
post_id: int
|
||||||
|
parent_comment_id: int | None
|
||||||
|
author_id: int | None
|
||||||
|
author_name: str | None = None
|
||||||
|
body: str
|
||||||
|
score: int
|
||||||
|
created_utc: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
24
backend/schemas/subreddit.py
Normal file
24
backend/schemas/subreddit.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditUpdate(BaseModel):
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubredditResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
display_name: str | None
|
||||||
|
description: str | None
|
||||||
|
subscribers: int | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
post_count: int = 0
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
231
backend/services/analytics_service.py
Normal file
231
backend/services/analytics_service.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from sqlalchemy import select, func, case, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.comment import Comment
|
||||||
|
from backend.models.author import Author
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
|
||||||
|
|
||||||
|
async def get_engagement(
|
||||||
|
db: AsyncSession,
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
granularity: str = "day",
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not since:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=30)
|
||||||
|
if not until:
|
||||||
|
until = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
trunc = func.date_trunc(granularity, Post.created_utc)
|
||||||
|
|
||||||
|
stmt = select(
|
||||||
|
trunc.label("period"),
|
||||||
|
func.count(Post.id).label("posts"),
|
||||||
|
func.coalesce(func.avg(Post.score), 0).label("avg_score"),
|
||||||
|
).where(Post.created_utc >= since, Post.created_utc <= until)
|
||||||
|
|
||||||
|
if subreddit_id:
|
||||||
|
stmt = stmt.where(Post.subreddit_id == subreddit_id)
|
||||||
|
|
||||||
|
stmt = stmt.group_by("period").order_by("period")
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
|
||||||
|
# Get comment counts per period
|
||||||
|
comment_trunc = func.date_trunc(granularity, Comment.created_utc)
|
||||||
|
comment_stmt = (
|
||||||
|
select(
|
||||||
|
comment_trunc.label("period"),
|
||||||
|
func.count(Comment.id).label("comments"),
|
||||||
|
)
|
||||||
|
.join(Post)
|
||||||
|
.where(Comment.created_utc >= since, Comment.created_utc <= until)
|
||||||
|
)
|
||||||
|
if subreddit_id:
|
||||||
|
comment_stmt = comment_stmt.where(Post.subreddit_id == subreddit_id)
|
||||||
|
comment_stmt = comment_stmt.group_by("period")
|
||||||
|
comment_result = await db.execute(comment_stmt)
|
||||||
|
comment_map = {str(r.period): r.comments for r in comment_result}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"period": str(r.period),
|
||||||
|
"posts": r.posts,
|
||||||
|
"comments": comment_map.get(str(r.period), 0),
|
||||||
|
"avg_score": round(float(r.avg_score), 1),
|
||||||
|
}
|
||||||
|
for r in result
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_top_posts(
|
||||||
|
db: AsyncSession,
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
metric: str = "score",
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not since:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Post, MonitoredSubreddit.name, Author.username)
|
||||||
|
.join(MonitoredSubreddit)
|
||||||
|
.outerjoin(Author)
|
||||||
|
.where(Post.created_utc >= since)
|
||||||
|
)
|
||||||
|
if until:
|
||||||
|
stmt = stmt.where(Post.created_utc <= until)
|
||||||
|
if subreddit_id:
|
||||||
|
stmt = stmt.where(Post.subreddit_id == subreddit_id)
|
||||||
|
|
||||||
|
sort_col = Post.score if metric == "score" else Post.num_comments
|
||||||
|
stmt = stmt.order_by(sort_col.desc()).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": post.id,
|
||||||
|
"title": post.title,
|
||||||
|
"score": post.score,
|
||||||
|
"num_comments": post.num_comments,
|
||||||
|
"author_name": author_name,
|
||||||
|
"subreddit_name": sub_name,
|
||||||
|
"created_utc": post.created_utc,
|
||||||
|
"permalink": post.permalink,
|
||||||
|
}
|
||||||
|
for post, sub_name, author_name in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_top_authors(
|
||||||
|
db: AsyncSession,
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not since:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
|
||||||
|
post_count = (
|
||||||
|
select(func.count(Post.id))
|
||||||
|
.where(Post.author_id == Author.id, Post.created_utc >= since)
|
||||||
|
)
|
||||||
|
comment_count = (
|
||||||
|
select(func.count(Comment.id))
|
||||||
|
.where(Comment.author_id == Author.id, Comment.created_utc >= since)
|
||||||
|
)
|
||||||
|
|
||||||
|
if until:
|
||||||
|
post_count = post_count.where(Post.created_utc <= until)
|
||||||
|
comment_count = comment_count.where(Comment.created_utc <= until)
|
||||||
|
|
||||||
|
if subreddit_id:
|
||||||
|
post_count = post_count.where(Post.subreddit_id == subreddit_id)
|
||||||
|
comment_count = comment_count.join(Post).where(Post.subreddit_id == subreddit_id)
|
||||||
|
|
||||||
|
pc = post_count.correlate(Author).scalar_subquery().label("post_count")
|
||||||
|
cc = comment_count.correlate(Author).scalar_subquery().label("comment_count")
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Author, pc, cc)
|
||||||
|
.order_by((pc + cc).desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": author.id,
|
||||||
|
"username": author.username,
|
||||||
|
"post_count": pc or 0,
|
||||||
|
"comment_count": cc or 0,
|
||||||
|
"total_activity": (pc or 0) + (cc or 0),
|
||||||
|
}
|
||||||
|
for author, pc, cc in result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subreddit_summary(
|
||||||
|
db: AsyncSession,
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not since:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
MonitoredSubreddit.id,
|
||||||
|
MonitoredSubreddit.name,
|
||||||
|
func.count(Post.id).label("total_posts"),
|
||||||
|
func.coalesce(func.avg(Post.score), 0).label("avg_score"),
|
||||||
|
)
|
||||||
|
.outerjoin(Post, (Post.subreddit_id == MonitoredSubreddit.id) & (Post.created_utc >= since))
|
||||||
|
.where(MonitoredSubreddit.is_active == True) # noqa: E712
|
||||||
|
.group_by(MonitoredSubreddit.id)
|
||||||
|
.order_by(MonitoredSubreddit.name)
|
||||||
|
)
|
||||||
|
if until:
|
||||||
|
stmt = stmt.where(Post.created_utc <= until)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
|
||||||
|
summaries = []
|
||||||
|
for sub_id, sub_name, total_posts, avg_score in result.all():
|
||||||
|
# Get comment count
|
||||||
|
cc = await db.execute(
|
||||||
|
select(func.count(Comment.id))
|
||||||
|
.join(Post)
|
||||||
|
.where(Post.subreddit_id == sub_id, Comment.created_utc >= since)
|
||||||
|
)
|
||||||
|
comment_count = cc.scalar() or 0
|
||||||
|
|
||||||
|
# Top flair
|
||||||
|
flair_stmt = (
|
||||||
|
select(Post.flair, func.count(Post.id).label("cnt"))
|
||||||
|
.where(Post.subreddit_id == sub_id, Post.created_utc >= since, Post.flair.isnot(None))
|
||||||
|
.group_by(Post.flair)
|
||||||
|
.order_by(func.count(Post.id).desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
flair_result = await db.execute(flair_stmt)
|
||||||
|
top_flair_row = flair_result.first()
|
||||||
|
|
||||||
|
summaries.append({
|
||||||
|
"subreddit_id": sub_id,
|
||||||
|
"subreddit_name": sub_name,
|
||||||
|
"total_posts": total_posts,
|
||||||
|
"total_comments": comment_count,
|
||||||
|
"avg_score": round(float(avg_score), 1),
|
||||||
|
"top_flair": top_flair_row[0] if top_flair_row else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
async def get_flair_distribution(
|
||||||
|
db: AsyncSession,
|
||||||
|
subreddit_id: int,
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not since:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=30)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Post.flair, func.count(Post.id).label("count"))
|
||||||
|
.where(Post.subreddit_id == subreddit_id, Post.created_utc >= since)
|
||||||
|
.group_by(Post.flair)
|
||||||
|
.order_by(func.count(Post.id).desc())
|
||||||
|
)
|
||||||
|
if until:
|
||||||
|
stmt = stmt.where(Post.created_utc <= until)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return [{"flair": flair, "count": count} for flair, count in result.all()]
|
||||||
79
backend/services/author_service.py
Normal file
79
backend/services/author_service.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.models.author import Author
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.comment import Comment
|
||||||
|
|
||||||
|
|
||||||
|
async def list_authors(
|
||||||
|
db: AsyncSession,
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
sort_by: str = "total_comments",
|
||||||
|
sort_order: str = "desc",
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 25,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
base = select(Author)
|
||||||
|
|
||||||
|
if subreddit_id or since or until:
|
||||||
|
# Need to compute activity counts with filters
|
||||||
|
post_count = (
|
||||||
|
select(func.count(Post.id))
|
||||||
|
.where(Post.author_id == Author.id)
|
||||||
|
)
|
||||||
|
comment_count = (
|
||||||
|
select(func.count(Comment.id))
|
||||||
|
.where(Comment.author_id == Author.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if subreddit_id:
|
||||||
|
post_count = post_count.where(Post.subreddit_id == subreddit_id)
|
||||||
|
comment_count = comment_count.join(Post).where(Post.subreddit_id == subreddit_id)
|
||||||
|
if since:
|
||||||
|
post_count = post_count.where(Post.created_utc >= since)
|
||||||
|
comment_count = comment_count.where(Comment.created_utc >= since)
|
||||||
|
if until:
|
||||||
|
post_count = post_count.where(Post.created_utc <= until)
|
||||||
|
comment_count = comment_count.where(Comment.created_utc <= until)
|
||||||
|
|
||||||
|
base = select(
|
||||||
|
Author,
|
||||||
|
post_count.correlate(Author).scalar_subquery().label("filtered_posts"),
|
||||||
|
comment_count.correlate(Author).scalar_subquery().label("filtered_comments"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
base = select(Author)
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).select_from(base.subquery())
|
||||||
|
total = (await db.execute(count_stmt)).scalar() or 0
|
||||||
|
|
||||||
|
sort_col = getattr(Author, sort_by, Author.total_comments)
|
||||||
|
if sort_order == "asc":
|
||||||
|
base = base.order_by(sort_col.asc())
|
||||||
|
else:
|
||||||
|
base = base.order_by(sort_col.desc())
|
||||||
|
|
||||||
|
base = base.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
|
||||||
|
result = await db.execute(base)
|
||||||
|
authors = []
|
||||||
|
for row in result.all():
|
||||||
|
if isinstance(row, tuple):
|
||||||
|
author = row[0]
|
||||||
|
else:
|
||||||
|
author = row
|
||||||
|
data = {c.name: getattr(author, c.name) for c in author.__table__.columns}
|
||||||
|
authors.append(data)
|
||||||
|
|
||||||
|
return authors, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_author(db: AsyncSession, author_id: int) -> dict | None:
|
||||||
|
author = await db.get(Author, author_id)
|
||||||
|
if not author:
|
||||||
|
return None
|
||||||
|
return {c.name: getattr(author, c.name) for c in author.__table__.columns}
|
||||||
57
backend/services/comment_service.py
Normal file
57
backend/services/comment_service.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.models.comment import Comment
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.author import Author
|
||||||
|
|
||||||
|
|
||||||
|
async def list_comments(
|
||||||
|
db: AsyncSession,
|
||||||
|
post_id: int | None = None,
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
author: str | None = None,
|
||||||
|
sort_by: str = "created_utc",
|
||||||
|
sort_order: str = "desc",
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 25,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
base = select(Comment, Author.username).outerjoin(Author).join(Post)
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if post_id:
|
||||||
|
filters.append(Comment.post_id == post_id)
|
||||||
|
if subreddit_id:
|
||||||
|
filters.append(Post.subreddit_id == subreddit_id)
|
||||||
|
if author:
|
||||||
|
filters.append(Author.username == author)
|
||||||
|
if since:
|
||||||
|
filters.append(Comment.created_utc >= since)
|
||||||
|
if until:
|
||||||
|
filters.append(Comment.created_utc <= until)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
base = base.where(*filters)
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).select_from(base.subquery())
|
||||||
|
total = (await db.execute(count_stmt)).scalar() or 0
|
||||||
|
|
||||||
|
sort_col = getattr(Comment, sort_by, Comment.created_utc)
|
||||||
|
if sort_order == "asc":
|
||||||
|
base = base.order_by(sort_col.asc())
|
||||||
|
else:
|
||||||
|
base = base.order_by(sort_col.desc())
|
||||||
|
|
||||||
|
base = base.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
|
||||||
|
result = await db.execute(base)
|
||||||
|
comments = []
|
||||||
|
for comment, author_name in result.all():
|
||||||
|
data = {c.name: getattr(comment, c.name) for c in comment.__table__.columns}
|
||||||
|
data["author_name"] = author_name
|
||||||
|
comments.append(data)
|
||||||
|
|
||||||
|
return comments, total
|
||||||
102
backend/services/post_service.py
Normal file
102
backend/services/post_service.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
from backend.models.author import Author
|
||||||
|
from backend.models.comment import Comment
|
||||||
|
|
||||||
|
|
||||||
|
async def list_posts(
|
||||||
|
db: AsyncSession,
|
||||||
|
subreddit_id: int | None = None,
|
||||||
|
author: str | None = None,
|
||||||
|
flair: str | None = None,
|
||||||
|
sort_by: str = "created_utc",
|
||||||
|
sort_order: str = "desc",
|
||||||
|
since: datetime | None = None,
|
||||||
|
until: datetime | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 25,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
base = select(Post, MonitoredSubreddit.name, Author.username).join(
|
||||||
|
MonitoredSubreddit
|
||||||
|
).outerjoin(Author)
|
||||||
|
|
||||||
|
filters = []
|
||||||
|
if subreddit_id:
|
||||||
|
filters.append(Post.subreddit_id == subreddit_id)
|
||||||
|
if flair:
|
||||||
|
filters.append(Post.flair == flair)
|
||||||
|
if since:
|
||||||
|
filters.append(Post.created_utc >= since)
|
||||||
|
if until:
|
||||||
|
filters.append(Post.created_utc <= until)
|
||||||
|
if author:
|
||||||
|
filters.append(Author.username == author)
|
||||||
|
|
||||||
|
if filters:
|
||||||
|
base = base.where(*filters)
|
||||||
|
|
||||||
|
# Count
|
||||||
|
count_stmt = select(func.count()).select_from(base.subquery())
|
||||||
|
total = (await db.execute(count_stmt)).scalar() or 0
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
sort_col = getattr(Post, sort_by, Post.created_utc)
|
||||||
|
if sort_order == "asc":
|
||||||
|
base = base.order_by(sort_col.asc())
|
||||||
|
else:
|
||||||
|
base = base.order_by(sort_col.desc())
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
base = base.offset((page - 1) * per_page).limit(per_page)
|
||||||
|
|
||||||
|
result = await db.execute(base)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
posts = []
|
||||||
|
for post, sub_name, author_name in rows:
|
||||||
|
data = {c.name: getattr(post, c.name) for c in post.__table__.columns}
|
||||||
|
data["subreddit_name"] = sub_name
|
||||||
|
data["author_name"] = author_name
|
||||||
|
posts.append(data)
|
||||||
|
|
||||||
|
return posts, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_post(db: AsyncSession, post_id: int) -> dict | None:
|
||||||
|
stmt = (
|
||||||
|
select(Post, MonitoredSubreddit.name, Author.username)
|
||||||
|
.join(MonitoredSubreddit)
|
||||||
|
.outerjoin(Author)
|
||||||
|
.where(Post.id == post_id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
post, sub_name, author_name = row
|
||||||
|
data = {c.name: getattr(post, c.name) for c in post.__table__.columns}
|
||||||
|
data["subreddit_name"] = sub_name
|
||||||
|
data["author_name"] = author_name
|
||||||
|
|
||||||
|
# Get comments
|
||||||
|
comment_stmt = (
|
||||||
|
select(Comment, Author.username)
|
||||||
|
.outerjoin(Author)
|
||||||
|
.where(Comment.post_id == post_id)
|
||||||
|
.order_by(Comment.created_utc.asc())
|
||||||
|
)
|
||||||
|
comment_result = await db.execute(comment_stmt)
|
||||||
|
comments = []
|
||||||
|
for comment, c_author in comment_result.all():
|
||||||
|
c_data = {c.name: getattr(comment, c.name) for c in comment.__table__.columns}
|
||||||
|
c_data["author_name"] = c_author
|
||||||
|
comments.append(c_data)
|
||||||
|
|
||||||
|
data["comments"] = comments
|
||||||
|
return data
|
||||||
75
backend/services/subreddit_service.py
Normal file
75
backend/services/subreddit_service.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
from backend.models.post import Post
|
||||||
|
|
||||||
|
|
||||||
|
async def list_subreddits(db: AsyncSession) -> list[dict]:
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
MonitoredSubreddit,
|
||||||
|
func.count(Post.id).label("post_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(Post, Post.subreddit_id == MonitoredSubreddit.id)
|
||||||
|
.group_by(MonitoredSubreddit.id)
|
||||||
|
.order_by(MonitoredSubreddit.name)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.all()
|
||||||
|
out = []
|
||||||
|
for sub, post_count in rows:
|
||||||
|
data = {c.name: getattr(sub, c.name) for c in sub.__table__.columns}
|
||||||
|
data["post_count"] = post_count
|
||||||
|
out.append(data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def get_subreddit(db: AsyncSession, subreddit_id: int) -> dict | None:
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
MonitoredSubreddit,
|
||||||
|
func.count(Post.id).label("post_count"),
|
||||||
|
)
|
||||||
|
.outerjoin(Post, Post.subreddit_id == MonitoredSubreddit.id)
|
||||||
|
.where(MonitoredSubreddit.id == subreddit_id)
|
||||||
|
.group_by(MonitoredSubreddit.id)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
row = result.first()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
sub, post_count = row
|
||||||
|
data = {c.name: getattr(sub, c.name) for c in sub.__table__.columns}
|
||||||
|
data["post_count"] = post_count
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def create_subreddit(db: AsyncSession, name: str) -> MonitoredSubreddit:
|
||||||
|
sub = MonitoredSubreddit(name=name.lower().strip())
|
||||||
|
db.add(sub)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(sub)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
async def update_subreddit(
|
||||||
|
db: AsyncSession, subreddit_id: int, is_active: bool | None = None
|
||||||
|
) -> MonitoredSubreddit | None:
|
||||||
|
sub = await db.get(MonitoredSubreddit, subreddit_id)
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
if is_active is not None:
|
||||||
|
sub.is_active = is_active
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(sub)
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_subreddit(db: AsyncSession, subreddit_id: int) -> bool:
|
||||||
|
sub = await db.get(MonitoredSubreddit, subreddit_id)
|
||||||
|
if not sub:
|
||||||
|
return False
|
||||||
|
sub.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
0
backend/utils/__init__.py
Normal file
0
backend/utils/__init__.py
Normal file
0
backend/worker/__init__.py
Normal file
0
backend/worker/__init__.py
Normal file
140
backend/worker/digest_job.py
Normal file
140
backend/worker/digest_job.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta, date
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.comment import Comment
|
||||||
|
from backend.models.author import Author
|
||||||
|
from backend.models.daily_digest import DailyDigest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_engine = create_engine(settings.database_url_sync, pool_size=2, pool_recycle=3600)
|
||||||
|
SyncSession = sessionmaker(_engine)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_daily_digests():
|
||||||
|
"""Generate daily digest for each active subreddit."""
|
||||||
|
yesterday = date.today() - timedelta(days=1)
|
||||||
|
day_start = datetime(yesterday.year, yesterday.month, yesterday.day, tzinfo=timezone.utc)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
subs = db.execute(
|
||||||
|
select(MonitoredSubreddit).where(MonitoredSubreddit.is_active == True) # noqa: E712
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
for sub in subs:
|
||||||
|
# Check if digest already exists
|
||||||
|
existing = db.execute(
|
||||||
|
select(DailyDigest).where(
|
||||||
|
DailyDigest.subreddit_id == sub.id,
|
||||||
|
DailyDigest.digest_date == yesterday,
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Gather stats
|
||||||
|
post_count = db.execute(
|
||||||
|
select(func.count(Post.id)).where(
|
||||||
|
Post.subreddit_id == sub.id,
|
||||||
|
Post.created_utc >= day_start,
|
||||||
|
Post.created_utc < day_end,
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
comment_count = db.execute(
|
||||||
|
select(func.count(Comment.id))
|
||||||
|
.join(Post)
|
||||||
|
.where(
|
||||||
|
Post.subreddit_id == sub.id,
|
||||||
|
Comment.created_utc >= day_start,
|
||||||
|
Comment.created_utc < day_end,
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Top posts by score
|
||||||
|
top_posts = db.execute(
|
||||||
|
select(Post.title, Post.score, Post.num_comments, Post.permalink)
|
||||||
|
.where(
|
||||||
|
Post.subreddit_id == sub.id,
|
||||||
|
Post.created_utc >= day_start,
|
||||||
|
Post.created_utc < day_end,
|
||||||
|
)
|
||||||
|
.order_by(Post.score.desc())
|
||||||
|
.limit(5)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Top authors
|
||||||
|
top_authors = db.execute(
|
||||||
|
select(Author.username, func.count(Comment.id).label("cnt"))
|
||||||
|
.join(Comment, Comment.author_id == Author.id)
|
||||||
|
.join(Post, Comment.post_id == Post.id)
|
||||||
|
.where(
|
||||||
|
Post.subreddit_id == sub.id,
|
||||||
|
Comment.created_utc >= day_start,
|
||||||
|
Comment.created_utc < day_end,
|
||||||
|
)
|
||||||
|
.group_by(Author.username)
|
||||||
|
.order_by(func.count(Comment.id).desc())
|
||||||
|
.limit(5)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
avg_score = db.execute(
|
||||||
|
select(func.avg(Post.score)).where(
|
||||||
|
Post.subreddit_id == sub.id,
|
||||||
|
Post.created_utc >= day_start,
|
||||||
|
Post.created_utc < day_end,
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
# Build markdown digest
|
||||||
|
lines = [
|
||||||
|
f"# r/{sub.name} — Daily Digest for {yesterday}",
|
||||||
|
"",
|
||||||
|
f"**Posts:** {post_count} | **Comments:** {comment_count} | **Avg Score:** {avg_score:.1f}" if avg_score else f"**Posts:** {post_count} | **Comments:** {comment_count}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if top_posts:
|
||||||
|
lines.append("## Top Posts")
|
||||||
|
for i, (title, score, num_comments, permalink) in enumerate(top_posts, 1):
|
||||||
|
lines.append(f"{i}. **{title}** — {score} pts, {num_comments} comments")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if top_authors:
|
||||||
|
lines.append("## Most Active Users")
|
||||||
|
for username, cnt in top_authors:
|
||||||
|
lines.append(f"- u/{username}: {cnt} comments")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
content = "\n".join(lines)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"post_count": post_count,
|
||||||
|
"comment_count": comment_count,
|
||||||
|
"avg_score": float(avg_score) if avg_score else 0,
|
||||||
|
"top_posts": [
|
||||||
|
{"title": t, "score": s, "num_comments": n}
|
||||||
|
for t, s, n, _ in top_posts
|
||||||
|
],
|
||||||
|
"top_authors": [
|
||||||
|
{"username": u, "comment_count": c}
|
||||||
|
for u, c in top_authors
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
digest = DailyDigest(
|
||||||
|
subreddit_id=sub.id,
|
||||||
|
digest_date=yesterday,
|
||||||
|
content=content,
|
||||||
|
metadata_=metadata,
|
||||||
|
)
|
||||||
|
db.add(digest)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Generated daily digest for r/{sub.name} on {yesterday}")
|
||||||
90
backend/worker/main.py
Normal file
90
backend/worker/main.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.worker.monitor import poll_new_posts, poll_hot_posts, collect_comments, update_scores
|
||||||
|
from backend.worker.snapshot import take_metric_snapshots
|
||||||
|
from backend.worker.digest_job import generate_daily_digests
|
||||||
|
from backend.worker.summary_job import generate_summaries
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_subreddits():
|
||||||
|
"""Add seed subreddits on first startup if configured."""
|
||||||
|
if not settings.seed_subreddits:
|
||||||
|
return
|
||||||
|
from sqlalchemy import select, create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url_sync)
|
||||||
|
Session = sessionmaker(engine)
|
||||||
|
|
||||||
|
names = [s.strip().lower() for s in settings.seed_subreddits.split(",") if s.strip()]
|
||||||
|
with Session() as db:
|
||||||
|
for name in names:
|
||||||
|
existing = db.execute(
|
||||||
|
select(MonitoredSubreddit).where(MonitoredSubreddit.name == name)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not existing:
|
||||||
|
db.add(MonitoredSubreddit(name=name))
|
||||||
|
logger.info(f"Seeded subreddit: r/{name}")
|
||||||
|
db.commit()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logger.info("Starting Reddit monitor worker")
|
||||||
|
seed_subreddits()
|
||||||
|
|
||||||
|
scheduler = BlockingScheduler()
|
||||||
|
|
||||||
|
# Reddit polling jobs
|
||||||
|
scheduler.add_job(poll_new_posts, IntervalTrigger(minutes=2), id="poll_new", max_instances=1)
|
||||||
|
scheduler.add_job(poll_hot_posts, IntervalTrigger(minutes=2), id="poll_hot", max_instances=1)
|
||||||
|
scheduler.add_job(collect_comments, IntervalTrigger(minutes=5), id="comments", max_instances=1)
|
||||||
|
scheduler.add_job(update_scores, IntervalTrigger(minutes=15), id="scores", max_instances=1)
|
||||||
|
|
||||||
|
# Metric snapshots
|
||||||
|
scheduler.add_job(take_metric_snapshots, IntervalTrigger(minutes=30), id="snapshots", max_instances=1)
|
||||||
|
|
||||||
|
# Daily digest
|
||||||
|
scheduler.add_job(
|
||||||
|
generate_daily_digests,
|
||||||
|
CronTrigger(hour=settings.digest_hour_utc, minute=0),
|
||||||
|
id="digest",
|
||||||
|
max_instances=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI summary stub
|
||||||
|
scheduler.add_job(
|
||||||
|
generate_summaries,
|
||||||
|
CronTrigger(hour=settings.digest_hour_utc, minute=30),
|
||||||
|
id="summary",
|
||||||
|
max_instances=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def shutdown(signum, frame):
|
||||||
|
logger.info("Shutting down worker...")
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
|
||||||
|
logger.info("Worker started. Scheduled jobs are running.")
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
295
backend/worker/monitor.py
Normal file
295
backend/worker/monitor.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select, create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
from backend.models.author import Author
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.comment import Comment
|
||||||
|
from backend.worker.reddit_client import create_client, fetch_json
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Sync engine for worker (PRAW-replacement uses async httpx, but DB writes are sync for simplicity with APScheduler)
|
||||||
|
_engine = create_engine(settings.database_url_sync, pool_size=3, max_overflow=5, pool_recycle=3600)
|
||||||
|
SyncSession = sessionmaker(_engine)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_subreddits() -> list[dict]:
|
||||||
|
with SyncSession() as db:
|
||||||
|
stmt = select(MonitoredSubreddit).where(MonitoredSubreddit.is_active == True) # noqa: E712
|
||||||
|
result = db.execute(stmt)
|
||||||
|
return [{"id": s.id, "name": s.name} for s in result.scalars()]
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_author(db: Session, username: str) -> int | None:
|
||||||
|
if not username or username == "[deleted]":
|
||||||
|
return None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
stmt = insert(Author).values(username=username, first_seen_at=now, last_seen_at=now)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=[Author.username],
|
||||||
|
set_={"last_seen_at": now},
|
||||||
|
)
|
||||||
|
db.execute(stmt)
|
||||||
|
result = db.execute(select(Author.id).where(Author.username == username))
|
||||||
|
row = result.first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_post(post_data: dict, subreddit_id: int, db: Session, hot_rank: int | None = None) -> dict:
|
||||||
|
data = post_data.get("data", post_data)
|
||||||
|
author_id = _upsert_author(db, data.get("author"))
|
||||||
|
created = datetime.fromtimestamp(data.get("created_utc", 0), tz=timezone.utc)
|
||||||
|
return {
|
||||||
|
"reddit_id": data.get("name", f"t3_{data.get('id', '')}"),
|
||||||
|
"subreddit_id": subreddit_id,
|
||||||
|
"author_id": author_id,
|
||||||
|
"title": data.get("title", ""),
|
||||||
|
"selftext": data.get("selftext"),
|
||||||
|
"url": data.get("url"),
|
||||||
|
"permalink": data.get("permalink"),
|
||||||
|
"flair": data.get("link_flair_text"),
|
||||||
|
"score": data.get("score", 0),
|
||||||
|
"upvote_ratio": data.get("upvote_ratio"),
|
||||||
|
"num_comments": data.get("num_comments", 0),
|
||||||
|
"is_self": data.get("is_self"),
|
||||||
|
"over_18": data.get("over_18", False),
|
||||||
|
"hot_rank": hot_rank,
|
||||||
|
"created_utc": created,
|
||||||
|
"collected_at": datetime.now(timezone.utc),
|
||||||
|
"updated_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_posts(db: Session, posts: list[dict], update_hot_rank: bool = False):
|
||||||
|
if not posts:
|
||||||
|
return
|
||||||
|
update_set = {
|
||||||
|
"score": insert(Post).excluded.score,
|
||||||
|
"upvote_ratio": insert(Post).excluded.upvote_ratio,
|
||||||
|
"num_comments": insert(Post).excluded.num_comments,
|
||||||
|
"updated_at": insert(Post).excluded.updated_at,
|
||||||
|
}
|
||||||
|
if update_hot_rank:
|
||||||
|
update_set["hot_rank"] = insert(Post).excluded.hot_rank
|
||||||
|
|
||||||
|
stmt = insert(Post).values(posts)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=[Post.reddit_id],
|
||||||
|
set_=update_set,
|
||||||
|
)
|
||||||
|
db.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_comment(comment_data: dict, post_id: int, db: Session, parent_map: dict) -> dict | None:
|
||||||
|
data = comment_data.get("data", comment_data)
|
||||||
|
if data.get("kind") == "more" or not data.get("body"):
|
||||||
|
return None
|
||||||
|
reddit_id = data.get("name", f"t1_{data.get('id', '')}")
|
||||||
|
author_id = _upsert_author(db, data.get("author"))
|
||||||
|
created = datetime.fromtimestamp(data.get("created_utc", 0), tz=timezone.utc)
|
||||||
|
|
||||||
|
parent_reddit_id = data.get("parent_id", "")
|
||||||
|
parent_comment_id = parent_map.get(parent_reddit_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reddit_id": reddit_id,
|
||||||
|
"post_id": post_id,
|
||||||
|
"parent_comment_id": parent_comment_id,
|
||||||
|
"author_id": author_id,
|
||||||
|
"body": data.get("body", ""),
|
||||||
|
"score": data.get("score", 0),
|
||||||
|
"created_utc": created,
|
||||||
|
"collected_at": datetime.now(timezone.utc),
|
||||||
|
"updated_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def poll_new_posts():
|
||||||
|
"""Fetch /new for each active subreddit and upsert posts."""
|
||||||
|
asyncio.run(_poll_new_posts_async())
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_new_posts_async():
|
||||||
|
subreddits = _get_active_subreddits()
|
||||||
|
if not subreddits:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = create_client()
|
||||||
|
async with client:
|
||||||
|
for sub in subreddits:
|
||||||
|
data = await fetch_json(client, f"/r/{sub['name']}/new", {"limit": "100"})
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
children = data.get("data", {}).get("children", [])
|
||||||
|
if not children:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
posts = [_parse_post(child, sub["id"], db) for child in children]
|
||||||
|
_upsert_posts(db, posts)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"r/{sub['name']}: upserted {len(children)} new posts")
|
||||||
|
|
||||||
|
|
||||||
|
def poll_hot_posts():
|
||||||
|
"""Fetch /hot for each active subreddit and update hot_rank."""
|
||||||
|
asyncio.run(_poll_hot_posts_async())
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_hot_posts_async():
|
||||||
|
subreddits = _get_active_subreddits()
|
||||||
|
if not subreddits:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = create_client()
|
||||||
|
async with client:
|
||||||
|
for sub in subreddits:
|
||||||
|
data = await fetch_json(client, f"/r/{sub['name']}/hot", {"limit": "100"})
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
children = data.get("data", {}).get("children", [])
|
||||||
|
if not children:
|
||||||
|
continue
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
posts = [
|
||||||
|
_parse_post(child, sub["id"], db, hot_rank=i + 1)
|
||||||
|
for i, child in enumerate(children)
|
||||||
|
]
|
||||||
|
_upsert_posts(db, posts, update_hot_rank=True)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"r/{sub['name']}: updated hot ranks for {len(children)} posts")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_comments():
|
||||||
|
"""Fetch comments for recent posts."""
|
||||||
|
asyncio.run(_collect_comments_async())
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_comments_async():
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=48)
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
stmt = (
|
||||||
|
select(Post.id, Post.reddit_id, Post.subreddit_id)
|
||||||
|
.join(MonitoredSubreddit)
|
||||||
|
.where(
|
||||||
|
MonitoredSubreddit.is_active == True, # noqa: E712
|
||||||
|
Post.created_utc >= cutoff,
|
||||||
|
)
|
||||||
|
.order_by(Post.created_utc.desc())
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
result = db.execute(stmt)
|
||||||
|
recent_posts = [{"id": r[0], "reddit_id": r[1], "subreddit_id": r[2]} for r in result]
|
||||||
|
|
||||||
|
if not recent_posts:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = create_client()
|
||||||
|
async with client:
|
||||||
|
for post in recent_posts:
|
||||||
|
short_id = post["reddit_id"].replace("t3_", "")
|
||||||
|
data = await fetch_json(client, f"/comments/{short_id}", {"limit": "500", "sort": "new"})
|
||||||
|
if not data or len(data) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
comment_listing = data[1].get("data", {}).get("children", [])
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
# Build parent_map from existing comments
|
||||||
|
existing = db.execute(
|
||||||
|
select(Comment.id, Comment.reddit_id).where(Comment.post_id == post["id"])
|
||||||
|
)
|
||||||
|
parent_map = {r[1]: r[0] for r in existing}
|
||||||
|
|
||||||
|
comments_to_upsert = []
|
||||||
|
|
||||||
|
def process_comments(children):
|
||||||
|
for child in children:
|
||||||
|
if child.get("kind") == "more":
|
||||||
|
continue
|
||||||
|
c_data = child.get("data", {})
|
||||||
|
parsed = _parse_comment(c_data, post["id"], db, parent_map)
|
||||||
|
if parsed:
|
||||||
|
comments_to_upsert.append(parsed)
|
||||||
|
# Process replies recursively
|
||||||
|
replies = c_data.get("replies")
|
||||||
|
if isinstance(replies, dict):
|
||||||
|
reply_children = replies.get("data", {}).get("children", [])
|
||||||
|
process_comments(reply_children)
|
||||||
|
|
||||||
|
process_comments(comment_listing)
|
||||||
|
|
||||||
|
if comments_to_upsert:
|
||||||
|
# Upsert comments one at a time to handle parent references
|
||||||
|
for comment in comments_to_upsert:
|
||||||
|
stmt = insert(Comment).values(comment)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=[Comment.reddit_id],
|
||||||
|
set_={
|
||||||
|
"score": stmt.excluded.score,
|
||||||
|
"body": stmt.excluded.body,
|
||||||
|
"updated_at": stmt.excluded.updated_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.execute(stmt)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Post {short_id}: upserted {len(comments_to_upsert)} comments")
|
||||||
|
|
||||||
|
|
||||||
|
def update_scores():
|
||||||
|
"""Re-fetch recent posts to update scores and comment counts."""
|
||||||
|
asyncio.run(_update_scores_async())
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_scores_async():
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
stmt = (
|
||||||
|
select(Post.reddit_id, Post.subreddit_id, MonitoredSubreddit.name)
|
||||||
|
.join(MonitoredSubreddit)
|
||||||
|
.where(
|
||||||
|
MonitoredSubreddit.is_active == True, # noqa: E712
|
||||||
|
Post.created_utc >= cutoff,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = db.execute(stmt)
|
||||||
|
posts_by_sub: dict[str, list[str]] = {}
|
||||||
|
for reddit_id, _, sub_name in result:
|
||||||
|
posts_by_sub.setdefault(sub_name, []).append(reddit_id)
|
||||||
|
|
||||||
|
if not posts_by_sub:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Score updates piggyback on the new/hot polls — the upsert already updates scores.
|
||||||
|
# This job explicitly re-fetches to catch score changes on older posts.
|
||||||
|
client = create_client()
|
||||||
|
async with client:
|
||||||
|
for sub_name, reddit_ids in posts_by_sub.items():
|
||||||
|
data = await fetch_json(client, f"/r/{sub_name}/new", {"limit": "100"})
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
children = data.get("data", {}).get("children", [])
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
sub = db.execute(
|
||||||
|
select(MonitoredSubreddit).where(MonitoredSubreddit.name == sub_name)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not sub:
|
||||||
|
continue
|
||||||
|
posts = [_parse_post(child, sub.id, db) for child in children]
|
||||||
|
_upsert_posts(db, posts)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Score update complete for {len(posts_by_sub)} subreddits")
|
||||||
58
backend/worker/reddit_client.py
Normal file
58
backend/worker/reddit_client.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_URL = "https://www.reddit.com"
|
||||||
|
|
||||||
|
# Simple in-process rate limiter: track request timestamps
|
||||||
|
_request_times: list[float] = []
|
||||||
|
MAX_REQUESTS_PER_MINUTE = 9 # Stay under Reddit's ~10/min limit
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_rate_limit():
|
||||||
|
"""Block until we have budget for another request."""
|
||||||
|
now = time.monotonic()
|
||||||
|
# Remove timestamps older than 60 seconds
|
||||||
|
while _request_times and _request_times[0] < now - 60:
|
||||||
|
_request_times.pop(0)
|
||||||
|
if len(_request_times) >= MAX_REQUESTS_PER_MINUTE:
|
||||||
|
wait = 60 - (now - _request_times[0]) + 0.5
|
||||||
|
logger.info(f"Rate limit: waiting {wait:.1f}s")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
_request_times.append(time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_json(client: httpx.AsyncClient, path: str, params: dict | None = None) -> dict | None:
|
||||||
|
"""Fetch a Reddit .json endpoint with rate limiting and error handling."""
|
||||||
|
await _wait_for_rate_limit()
|
||||||
|
url = f"{BASE_URL}{path}.json"
|
||||||
|
try:
|
||||||
|
response = await client.get(url, params=params)
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = int(response.headers.get("Retry-After", 60))
|
||||||
|
logger.warning(f"Rate limited, waiting {retry_after}s")
|
||||||
|
await asyncio.sleep(retry_after)
|
||||||
|
return await fetch_json(client, path, params)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
logger.warning(f"Reddit returned {response.status_code} for {path}")
|
||||||
|
return None
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(f"HTTP error fetching {path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def create_client() -> httpx.AsyncClient:
|
||||||
|
"""Create an httpx client configured for Reddit."""
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
headers={"User-Agent": settings.reddit_user_agent},
|
||||||
|
timeout=30.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
47
backend/worker/snapshot.py
Normal file
47
backend/worker/snapshot.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select, create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.models.post import Post
|
||||||
|
from backend.models.metric_snapshot import MetricSnapshot
|
||||||
|
from backend.models.subreddit import MonitoredSubreddit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_engine = create_engine(settings.database_url_sync, pool_size=2, pool_recycle=3600)
|
||||||
|
SyncSession = sessionmaker(_engine)
|
||||||
|
|
||||||
|
|
||||||
|
def take_metric_snapshots():
|
||||||
|
"""Snapshot current metrics for recent posts."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
with SyncSession() as db:
|
||||||
|
# Posts < 48h old: snapshot every run (every 30 min)
|
||||||
|
cutoff_recent = now - timedelta(hours=48)
|
||||||
|
stmt = (
|
||||||
|
select(Post.id, Post.score, Post.num_comments, Post.upvote_ratio)
|
||||||
|
.join(MonitoredSubreddit)
|
||||||
|
.where(
|
||||||
|
MonitoredSubreddit.is_active == True, # noqa: E712
|
||||||
|
Post.created_utc >= cutoff_recent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = db.execute(stmt)
|
||||||
|
snapshots = []
|
||||||
|
for post_id, score, num_comments, upvote_ratio in result:
|
||||||
|
snapshots.append(MetricSnapshot(
|
||||||
|
post_id=post_id,
|
||||||
|
score=score,
|
||||||
|
num_comments=num_comments,
|
||||||
|
upvote_ratio=upvote_ratio,
|
||||||
|
snapshot_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
if snapshots:
|
||||||
|
db.add_all(snapshots)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Took {len(snapshots)} metric snapshots")
|
||||||
12
backend/worker/summary_job.py
Normal file
12
backend/worker/summary_job.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_summaries():
|
||||||
|
"""Stub: AI summary generation. Enable when a provider is configured."""
|
||||||
|
if not settings.ai_summary_enabled:
|
||||||
|
return
|
||||||
|
logger.info("AI summary generation not yet configured")
|
||||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
sh -c "alembic upgrade head &&
|
||||||
|
uvicorn backend.main:app --host 0.0.0.0 --port 8000"
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.worker
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
command: python -m backend.worker.main
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4283
frontend/package-lock.json
generated
Normal file
4283
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.1",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
|
"tailwindcss": "^4.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
35
frontend/src/App.tsx
Normal file
35
frontend/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import SubredditManager from './pages/SubredditManager';
|
||||||
|
import PostExplorer from './pages/PostExplorer';
|
||||||
|
import UserActivity from './pages/UserActivity';
|
||||||
|
import Digests from './pages/Digests';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/subreddits" element={<SubredditManager />} />
|
||||||
|
<Route path="/posts" element={<PostExplorer />} />
|
||||||
|
<Route path="/users" element={<UserActivity />} />
|
||||||
|
<Route path="/digests" element={<Digests />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/api/client.ts
Normal file
40
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
|
export async function fetchApi<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||||
|
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && v !== '') {
|
||||||
|
url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postApi<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchApi<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApi(path: string): Promise<void> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
}
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
36
frontend/src/components/Layout.tsx
Normal file
36
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', label: 'Dashboard' },
|
||||||
|
{ to: '/subreddits', label: 'Subreddits' },
|
||||||
|
{ to: '/posts', label: 'Posts' },
|
||||||
|
{ to: '/users', label: 'Users' },
|
||||||
|
{ to: '/digests', label: 'Digests' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<nav className="w-56 bg-slate-800 p-4 flex flex-col gap-1 shrink-0">
|
||||||
|
<h1 className="text-lg font-bold text-white mb-4 px-3">Reddit Monitor</h1>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`px-3 py-2 rounded text-sm ${
|
||||||
|
isActive ? 'bg-blue-600 text-white' : 'text-slate-300 hover:bg-slate-700'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
end={item.to === '/'}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<main className="flex-1 p-6 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/src/components/StatCard.tsx
Normal file
13
frontend/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatCard({ label, value }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="bg-slate-800 rounded-lg p-4">
|
||||||
|
<div className="text-sm text-slate-400">{label}</div>
|
||||||
|
<div className="text-2xl font-bold text-white mt-1">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/TimeRangeSelector.tsx
Normal file
31
frontend/src/components/TimeRangeSelector.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = [
|
||||||
|
{ label: '24h', value: '1' },
|
||||||
|
{ label: '7d', value: '7' },
|
||||||
|
{ label: '30d', value: '30' },
|
||||||
|
{ label: '90d', value: '90' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TimeRangeSelector({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{ranges.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
onClick={() => onChange(r.value)}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${
|
||||||
|
value === r.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/index.css
Normal file
9
frontend/src/index.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
113
frontend/src/pages/Dashboard.tsx
Normal file
113
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { fetchApi } from '../api/client';
|
||||||
|
import StatCard from '../components/StatCard';
|
||||||
|
import TimeRangeSelector from '../components/TimeRangeSelector';
|
||||||
|
import type { EngagementPoint, TopPost, SubredditSummary } from '../types';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [days, setDays] = useState('30');
|
||||||
|
const since = new Date(Date.now() - Number(days) * 86400000).toISOString();
|
||||||
|
|
||||||
|
const { data: engagement } = useQuery({
|
||||||
|
queryKey: ['engagement', days],
|
||||||
|
queryFn: () => fetchApi<EngagementPoint[]>('/analytics/engagement', { since, granularity: 'day' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: topPosts } = useQuery({
|
||||||
|
queryKey: ['top-posts', days],
|
||||||
|
queryFn: () => fetchApi<TopPost[]>('/analytics/top-posts', { since, limit: 10 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: summary } = useQuery({
|
||||||
|
queryKey: ['subreddit-summary', days],
|
||||||
|
queryFn: () => fetchApi<SubredditSummary[]>('/analytics/subreddit-summary', { since }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPosts = summary?.reduce((s, r) => s + r.total_posts, 0) ?? 0;
|
||||||
|
const totalComments = summary?.reduce((s, r) => s + r.total_comments, 0) ?? 0;
|
||||||
|
const subCount = summary?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">Dashboard</h2>
|
||||||
|
<TimeRangeSelector value={days} onChange={setDays} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<StatCard label="Total Posts" value={totalPosts} />
|
||||||
|
<StatCard label="Total Comments" value={totalComments} />
|
||||||
|
<StatCard label="Monitored Subs" value={subCount} />
|
||||||
|
<StatCard
|
||||||
|
label="Avg Score"
|
||||||
|
value={summary?.length ? (summary.reduce((s, r) => s + r.avg_score, 0) / summary.length).toFixed(1) : '0'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
|
<div className="bg-slate-800 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-400 mb-3">Engagement Over Time</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<AreaChart data={engagement ?? []}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis dataKey="period" tick={{ fontSize: 11, fill: '#94a3b8' }} tickFormatter={(v) => v.slice(5, 10)} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: '#94a3b8' }} />
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: 8 }} />
|
||||||
|
<Area type="monotone" dataKey="posts" stroke="#3b82f6" fill="#3b82f6" fillOpacity={0.2} name="Posts" />
|
||||||
|
<Area type="monotone" dataKey="comments" stroke="#10b981" fill="#10b981" fillOpacity={0.2} name="Comments" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-800 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-400 mb-3">Top Posts by Score</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<BarChart data={topPosts?.slice(0, 8) ?? []} layout="vertical">
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis type="number" tick={{ fontSize: 11, fill: '#94a3b8' }} />
|
||||||
|
<YAxis
|
||||||
|
dataKey="title"
|
||||||
|
type="category"
|
||||||
|
width={150}
|
||||||
|
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||||
|
tickFormatter={(v) => (v.length > 25 ? v.slice(0, 25) + '...' : v)}
|
||||||
|
/>
|
||||||
|
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: 'none', borderRadius: 8 }} />
|
||||||
|
<Bar dataKey="score" fill="#f59e0b" name="Score" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && summary.length > 0 && (
|
||||||
|
<div className="bg-slate-800 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-slate-400 mb-3">Subreddit Summary</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 border-b border-slate-700">
|
||||||
|
<th className="text-left py-2">Subreddit</th>
|
||||||
|
<th className="text-right py-2">Posts</th>
|
||||||
|
<th className="text-right py-2">Comments</th>
|
||||||
|
<th className="text-right py-2">Avg Score</th>
|
||||||
|
<th className="text-right py-2">Top Flair</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{summary.map((s) => (
|
||||||
|
<tr key={s.subreddit_id} className="border-b border-slate-700/50">
|
||||||
|
<td className="py-2">r/{s.subreddit_name}</td>
|
||||||
|
<td className="text-right py-2">{s.total_posts}</td>
|
||||||
|
<td className="text-right py-2">{s.total_comments}</td>
|
||||||
|
<td className="text-right py-2">{s.avg_score}</td>
|
||||||
|
<td className="text-right py-2 text-slate-400">{s.top_flair ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/pages/Digests.tsx
Normal file
87
frontend/src/pages/Digests.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetchApi } from '../api/client';
|
||||||
|
import type { Digest, Subreddit } from '../types';
|
||||||
|
|
||||||
|
export default function Digests() {
|
||||||
|
const [subFilter, setSubFilter] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: subreddits } = useQuery({
|
||||||
|
queryKey: ['subreddits'],
|
||||||
|
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['digests', subFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchApi<{ data: Digest[] }>('/digests', {
|
||||||
|
subreddit_id: subFilter || undefined,
|
||||||
|
per_page: 50,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: detail } = useQuery({
|
||||||
|
queryKey: ['digest', selectedId],
|
||||||
|
queryFn: () => fetchApi<Digest>(`/digests/${selectedId}`),
|
||||||
|
enabled: !!selectedId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">Daily Digests</h2>
|
||||||
|
<select
|
||||||
|
value={subFilter}
|
||||||
|
onChange={(e) => setSubFilter(e.target.value)}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option value="">All subreddits</option>
|
||||||
|
{subreddits?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>r/{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
<div className="col-span-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-slate-400">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data?.data.map((digest) => (
|
||||||
|
<button
|
||||||
|
key={digest.id}
|
||||||
|
onClick={() => setSelectedId(digest.id)}
|
||||||
|
className={`w-full text-left p-3 rounded-lg text-sm ${
|
||||||
|
selectedId === digest.id ? 'bg-blue-600' : 'bg-slate-800 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">r/{digest.subreddit_name}</div>
|
||||||
|
<div className="text-slate-400 text-xs mt-1">{digest.digest_date}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{data?.data.length === 0 && (
|
||||||
|
<p className="text-slate-400 text-sm">No digests yet. They are generated daily.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
{detail ? (
|
||||||
|
<div className="bg-slate-800 rounded-lg p-6">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-slate-200 font-sans leading-relaxed">
|
||||||
|
{detail.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-800 rounded-lg p-6 text-slate-400 text-sm">
|
||||||
|
Select a digest to view its content.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
frontend/src/pages/PostExplorer.tsx
Normal file
146
frontend/src/pages/PostExplorer.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetchApi } from '../api/client';
|
||||||
|
import type { Post, PaginatedResponse, Subreddit } from '../types';
|
||||||
|
import TimeRangeSelector from '../components/TimeRangeSelector';
|
||||||
|
|
||||||
|
export default function PostExplorer() {
|
||||||
|
const [days, setDays] = useState('7');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortBy, setSortBy] = useState('created_utc');
|
||||||
|
const [sortOrder, setSortOrder] = useState('desc');
|
||||||
|
const [subFilter, setSubFilter] = useState('');
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - Number(days) * 86400000).toISOString();
|
||||||
|
|
||||||
|
const { data: subreddits } = useQuery({
|
||||||
|
queryKey: ['subreddits'],
|
||||||
|
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['posts', page, sortBy, sortOrder, days, subFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchApi<PaginatedResponse<Post>>('/posts', {
|
||||||
|
page,
|
||||||
|
per_page: 25,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
since,
|
||||||
|
subreddit_id: subFilter || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSort = (col: string) => {
|
||||||
|
if (sortBy === col) {
|
||||||
|
setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc');
|
||||||
|
} else {
|
||||||
|
setSortBy(col);
|
||||||
|
setSortOrder('desc');
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortIcon = (col: string) => {
|
||||||
|
if (sortBy !== col) return '';
|
||||||
|
return sortOrder === 'desc' ? ' \u25bc' : ' \u25b2';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">Posts</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={subFilter}
|
||||||
|
onChange={(e) => { setSubFilter(e.target.value); setPage(1); }}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option value="">All subreddits</option>
|
||||||
|
{subreddits?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>r/{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<TimeRangeSelector value={days} onChange={(v) => { setDays(v); setPage(1); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-slate-400">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-slate-800 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 border-b border-slate-700">
|
||||||
|
<th className="text-left py-3 px-4 cursor-pointer" onClick={() => toggleSort('created_utc')}>
|
||||||
|
Title{sortIcon('created_utc')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-2">Sub</th>
|
||||||
|
<th className="text-left py-3 px-2">Author</th>
|
||||||
|
<th className="text-right py-3 px-2 cursor-pointer" onClick={() => toggleSort('score')}>
|
||||||
|
Score{sortIcon('score')}
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-3 px-2 cursor-pointer" onClick={() => toggleSort('num_comments')}>
|
||||||
|
Comments{sortIcon('num_comments')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-3 px-2">Flair</th>
|
||||||
|
<th className="text-right py-3 px-4">Hot</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data?.data.map((post) => (
|
||||||
|
<tr key={post.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
|
||||||
|
<td className="py-2 px-4 max-w-md truncate">
|
||||||
|
{post.permalink ? (
|
||||||
|
<a href={`https://reddit.com${post.permalink}`} target="_blank" rel="noreferrer" className="text-blue-400 hover:underline">
|
||||||
|
{post.title}
|
||||||
|
</a>
|
||||||
|
) : post.title}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-2 text-slate-400">r/{post.subreddit_name}</td>
|
||||||
|
<td className="py-2 px-2 text-slate-400">{post.author_name ?? '[deleted]'}</td>
|
||||||
|
<td className="py-2 px-2 text-right">{post.score}</td>
|
||||||
|
<td className="py-2 px-2 text-right">{post.num_comments}</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
{post.flair && (
|
||||||
|
<span className="text-xs bg-slate-700 px-2 py-0.5 rounded">{post.flair}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-4 text-right text-slate-400">
|
||||||
|
{post.hot_rank ? `#${post.hot_rank}` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && data.pages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
Page {data.page} of {data.pages} ({data.total} posts)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= data.pages}
|
||||||
|
className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend/src/pages/SubredditManager.tsx
Normal file
103
frontend/src/pages/SubredditManager.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { fetchApi, postApi, patchApi, deleteApi } from '../api/client';
|
||||||
|
import type { Subreddit } from '../types';
|
||||||
|
|
||||||
|
export default function SubredditManager() {
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: subreddits, isLoading } = useQuery({
|
||||||
|
queryKey: ['subreddits'],
|
||||||
|
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => postApi('/subreddits', { name }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['subreddits'] });
|
||||||
|
setNewName('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
|
||||||
|
patchApi(`/subreddits/${id}`, { is_active }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['subreddits'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => deleteApi(`/subreddits/${id}`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['subreddits'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold mb-6">Monitored Subreddits</h2>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newName.trim()) addMutation.mutate(newName.trim());
|
||||||
|
}}
|
||||||
|
className="flex gap-2 mb-6"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="subreddit name (without r/)"
|
||||||
|
className="flex-1 bg-slate-800 border border-slate-600 rounded px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addMutation.isPending}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-slate-400">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{subreddits?.map((sub) => (
|
||||||
|
<div key={sub.id} className="bg-slate-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-medium">r/{sub.name}</h3>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
sub.is_active ? 'bg-green-900 text-green-300' : 'bg-slate-700 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sub.is_active ? 'Active' : 'Paused'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-400 mb-3">
|
||||||
|
{sub.post_count} posts collected
|
||||||
|
{sub.subscribers ? ` · ${(sub.subscribers / 1000).toFixed(0)}k subscribers` : ''}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleMutation.mutate({ id: sub.id, is_active: !sub.is_active })}
|
||||||
|
className="text-xs bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
{sub.is_active ? 'Pause' : 'Resume'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Stop monitoring r/${sub.name}?`)) deleteMutation.mutate(sub.id);
|
||||||
|
}}
|
||||||
|
className="text-xs bg-red-900/50 hover:bg-red-900 text-red-300 px-3 py-1 rounded"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/pages/UserActivity.tsx
Normal file
112
frontend/src/pages/UserActivity.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetchApi } from '../api/client';
|
||||||
|
import type { PaginatedResponse, Author, Subreddit } from '../types';
|
||||||
|
import TimeRangeSelector from '../components/TimeRangeSelector';
|
||||||
|
|
||||||
|
export default function UserActivity() {
|
||||||
|
const [days, setDays] = useState('7');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortBy, setSortBy] = useState('total_comments');
|
||||||
|
const [subFilter, setSubFilter] = useState('');
|
||||||
|
|
||||||
|
const since = new Date(Date.now() - Number(days) * 86400000).toISOString();
|
||||||
|
|
||||||
|
const { data: subreddits } = useQuery({
|
||||||
|
queryKey: ['subreddits'],
|
||||||
|
queryFn: () => fetchApi<Subreddit[]>('/subreddits'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['authors', page, sortBy, days, subFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchApi<PaginatedResponse<Author>>('/authors', {
|
||||||
|
page,
|
||||||
|
per_page: 25,
|
||||||
|
sort_by: sortBy,
|
||||||
|
sort_order: 'desc',
|
||||||
|
since,
|
||||||
|
subreddit_id: subFilter || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold">User Activity</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={subFilter}
|
||||||
|
onChange={(e) => { setSubFilter(e.target.value); setPage(1); }}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded px-3 py-1 text-sm text-white"
|
||||||
|
>
|
||||||
|
<option value="">All subreddits</option>
|
||||||
|
{subreddits?.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>r/{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSortBy('total_comments'); setPage(1); }}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${sortBy === 'total_comments' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300'}`}
|
||||||
|
>
|
||||||
|
By Comments
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSortBy('total_posts'); setPage(1); }}
|
||||||
|
className={`px-3 py-1 text-sm rounded ${sortBy === 'total_posts' ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300'}`}
|
||||||
|
>
|
||||||
|
By Posts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TimeRangeSelector value={days} onChange={(v) => { setDays(v); setPage(1); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-slate-400">Loading...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-slate-800 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 border-b border-slate-700">
|
||||||
|
<th className="text-left py-3 px-4">#</th>
|
||||||
|
<th className="text-left py-3 px-4">Username</th>
|
||||||
|
<th className="text-right py-3 px-4">Posts</th>
|
||||||
|
<th className="text-right py-3 px-4">Comments</th>
|
||||||
|
<th className="text-right py-3 px-4">Total Activity</th>
|
||||||
|
<th className="text-right py-3 px-4">First Seen</th>
|
||||||
|
<th className="text-right py-3 px-4">Last Seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data?.data.map((author, i) => (
|
||||||
|
<tr key={author.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
|
||||||
|
<td className="py-2 px-4 text-slate-400">{(page - 1) * 25 + i + 1}</td>
|
||||||
|
<td className="py-2 px-4">u/{author.username}</td>
|
||||||
|
<td className="py-2 px-4 text-right">{author.total_posts}</td>
|
||||||
|
<td className="py-2 px-4 text-right">{author.total_comments}</td>
|
||||||
|
<td className="py-2 px-4 text-right font-medium">{author.total_posts + author.total_comments}</td>
|
||||||
|
<td className="py-2 px-4 text-right text-slate-400">{new Date(author.first_seen_at).toLocaleDateString()}</td>
|
||||||
|
<td className="py-2 px-4 text-right text-slate-400">{new Date(author.last_seen_at).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && data.pages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<span className="text-sm text-slate-400">Page {data.page} of {data.pages}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setPage(page - 1)} disabled={page <= 1} className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50">Prev</button>
|
||||||
|
<button onClick={() => setPage(page + 1)} disabled={page >= data.pages} className="bg-slate-700 hover:bg-slate-600 px-3 py-1 rounded text-sm disabled:opacity-50">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/src/types/index.ts
Normal file
115
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
export interface Subreddit {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
display_name: string | null;
|
||||||
|
description: string | null;
|
||||||
|
subscribers: number | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
post_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Post {
|
||||||
|
id: number;
|
||||||
|
reddit_id: string;
|
||||||
|
subreddit_id: number;
|
||||||
|
subreddit_name: string | null;
|
||||||
|
author_id: number | null;
|
||||||
|
author_name: string | null;
|
||||||
|
title: string;
|
||||||
|
selftext: string | null;
|
||||||
|
url: string | null;
|
||||||
|
permalink: string | null;
|
||||||
|
flair: string | null;
|
||||||
|
score: number;
|
||||||
|
upvote_ratio: number | null;
|
||||||
|
num_comments: number;
|
||||||
|
is_self: boolean | null;
|
||||||
|
over_18: boolean;
|
||||||
|
hot_rank: number | null;
|
||||||
|
created_utc: string;
|
||||||
|
collected_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: number;
|
||||||
|
reddit_id: string;
|
||||||
|
post_id: number;
|
||||||
|
parent_comment_id: number | null;
|
||||||
|
author_id: number | null;
|
||||||
|
author_name: string | null;
|
||||||
|
body: string;
|
||||||
|
score: number;
|
||||||
|
created_utc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Author {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
first_seen_at: string;
|
||||||
|
last_seen_at: string;
|
||||||
|
total_posts: number;
|
||||||
|
total_comments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EngagementPoint {
|
||||||
|
period: string;
|
||||||
|
posts: number;
|
||||||
|
comments: number;
|
||||||
|
avg_score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopPost {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
score: number;
|
||||||
|
num_comments: number;
|
||||||
|
author_name: string | null;
|
||||||
|
subreddit_name: string;
|
||||||
|
created_utc: string;
|
||||||
|
permalink: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopAuthor {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
post_count: number;
|
||||||
|
comment_count: number;
|
||||||
|
total_activity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubredditSummary {
|
||||||
|
subreddit_id: number;
|
||||||
|
subreddit_name: string;
|
||||||
|
total_posts: number;
|
||||||
|
total_comments: number;
|
||||||
|
avg_score: number;
|
||||||
|
top_flair: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Digest {
|
||||||
|
id: number;
|
||||||
|
subreddit_id: number;
|
||||||
|
subreddit_name: string | null;
|
||||||
|
digest_date: string;
|
||||||
|
content: string;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricSnapshot {
|
||||||
|
score: number;
|
||||||
|
num_comments: number;
|
||||||
|
upvote_ratio: number | null;
|
||||||
|
snapshot_at: string;
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
sqlalchemy[asyncio]>=2.0.30
|
||||||
|
asyncpg>=0.29.0
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
|
alembic>=1.13.0
|
||||||
|
pydantic>=2.7.0
|
||||||
|
pydantic-settings>=2.3.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
apscheduler>=3.10.4
|
||||||
|
jinja2>=3.1.4
|
||||||
|
python-multipart>=0.0.9
|
||||||
Reference in New Issue
Block a user