Spaces:
Running
Running
AuthorBot
feat: proper visitor analytics system - New Visitor model (visitor_uid + fingerprint fallback, first_seen/last_seen/page_views, geo, device) - VisitorRepository with upsert + analytics queries - visitor_tracker.py: record_visitor() upserts on every session/init - session/init accepts visitor_uid in request body (HF-Spaces iframe-safe, no cookies) - 4 new analytics endpoints: /visitors /geo /devices /sessions/stats - geo.py: region extraction + X-Real-IP support - DB migration: visitor_uid column on chat_sessions - ARCHITECTURE.md updated
3ae7860 | """Author RAG Chatbot SaaS β Visitor Model. | |
| One row per unique visitor per author. | |
| A visitor is identified by visitor_uid (from widget localStorage) with a | |
| server-side SHA-256 fingerprint as fallback. | |
| Privacy rules: | |
| - Raw IP is NEVER stored β only country/city derived from it. | |
| - visitor_uid is a random UUID generated server-side (not derived from IP). | |
| - fingerprint is a one-way hash β not reversible. | |
| """ | |
| import datetime | |
| from sqlalchemy import DateTime, Index, Integer, String, UniqueConstraint | |
| from sqlalchemy.orm import Mapped, mapped_column | |
| from app.models.base import Base, generate_uuid | |
| class Visitor(Base): | |
| """One unique visitor per author. | |
| Created on first session/init. Updated (lastSeen, pageViews) on every | |
| subsequent session/init from the same browser. | |
| """ | |
| __tablename__ = "visitors" | |
| id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid) | |
| author_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) | |
| # Identity β primary (localStorage UUID) and fallback (hash) | |
| visitor_uid: Mapped[str] = mapped_column(String(36), nullable=False, index=True) | |
| fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, default="") | |
| # Lifecycle | |
| first_seen: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False) | |
| last_seen: Mapped[datetime.datetime] = mapped_column(DateTime, nullable=False) | |
| page_views: Mapped[int] = mapped_column(Integer, default=1, nullable=False) | |
| # Geography β derived from IP at visit time, never the raw IP | |
| country_code: Mapped[str | None] = mapped_column(String(2), nullable=True) | |
| country_name: Mapped[str | None] = mapped_column(String(100), nullable=True) | |
| region: Mapped[str | None] = mapped_column(String(100), nullable=True) | |
| city: Mapped[str | None] = mapped_column(String(100), nullable=True) | |
| # Device | |
| device_type: Mapped[str | None] = mapped_column(String(20), nullable=True) | |
| browser: Mapped[str | None] = mapped_column(String(100), nullable=True) | |
| os: Mapped[str | None] = mapped_column(String(100), nullable=True) | |
| __table_args__ = ( | |
| # Each visitor_uid is unique per author (one row per browser per author) | |
| UniqueConstraint("author_id", "visitor_uid", name="uq_visitor_author_uid"), | |
| Index("ix_visitors_author_fp", "author_id", "fingerprint"), | |
| ) | |
| def __repr__(self) -> str: | |
| return f"<Visitor uid={self.visitor_uid[:8]}... author={self.author_id[:8]}...>" | |