Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions services/flask-backend/app/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from werkzeug.exceptions import BadRequest, NotFound

from .auth import auth_required
from .features import feature_enabled, get_license_tier
from .models import get_db
from .rbac import require_scope

Expand Down Expand Up @@ -372,3 +373,193 @@ async def get_tenant_analytics_summary():
}

return jsonify({"data": response_data}), 200


@analytics_bp.route("/urls/<int:url_id>/advanced", methods=["GET"])
@auth_required
@require_scope("analytics:read")
async def get_url_analytics_advanced(url_id: int):
"""
Get advanced analytics for a specific URL (Enterprise only).

Requires Enterprise tier + 'current.advanced-analytics' feature flag.

Query params:
- from: ISO date (YYYY-MM-DD)
- to: ISO date (YYYY-MM-DD)

ADVANCED analytics (Enterprise):
- Geo breakdown (country/region/city) — currently placeholder
- Device type breakdown — currently placeholder
- Browser breakdown — currently placeholder
- OS breakdown — currently placeholder

Returns:
{
"data": {
"url_id": 123,
"short_code": "abc123",
...basic fields...,
"geo": [{"country": "US", "clicks": 50}, ...],
"devices": [{"device": "mobile", "clicks": 40}, ...],
"browsers": [{"browser": "Chrome", "clicks": 60}, ...],
"os": [{"os": "iOS", "clicks": 30}, ...]
}
}

Requires: analytics:read scope (tenant-scoped) + Enterprise license
"""
db = get_db()
tenant_id = g.current_user.get("tenant_id")
user_id = g.current_user["id"]

if not tenant_id:
default_tenant = db(db.tenants.is_default == True).select().first() # noqa
if not default_tenant:
raise BadRequest("No tenant found for user")
tenant_id = default_tenant.id

# Check Enterprise tier requirement
tier = get_license_tier()
if tier != "enterprise":
return (
jsonify(
{
"error": "Feature not available",
"message": "Advanced analytics require an Enterprise license",
"required_tier": "enterprise",
"current_tier": tier,
}
),
402,
)

# Check feature flag
flag_enabled = feature_enabled("current.advanced-analytics", str(user_id))
if not flag_enabled:
return (
jsonify(
{
"error": "Feature not enabled",
"message": "Advanced analytics are not currently enabled",
"flag_key": "current.advanced-analytics",
}
),
402,
)

# Verify URL exists and belongs to tenant
url = (
db((db.urls.id == url_id) & (db.urls.tenant_id == tenant_id))
.select()
.first()
)
if not url:
raise NotFound("URL not found")

# Parse date filters
from_date = None
to_date = None

from_str = request.args.get("from")
if from_str:
try:
from_date = datetime.fromisoformat(from_str).replace(tzinfo=timezone.utc)
except ValueError:
raise BadRequest("Invalid 'from' date format (use YYYY-MM-DD)")

to_str = request.args.get("to")
if to_str:
try:
to_date = datetime.fromisoformat(to_str).replace(tzinfo=timezone.utc)
except ValueError:
raise BadRequest("Invalid 'to' date format (use YYYY-MM-DD)")

# Query clicks for this URL with date filters
query = db.link_clicks.url_id == url_id
if from_date:
query &= db.link_clicks.clicked_at >= from_date
if to_date:
query &= db.link_clicks.clicked_at <= to_date

clicks = db(query).select().as_list()
total_clicks = len(clicks)

# BASIC ANALYTICS (include basic fields)
clicks_by_day_map: dict[str, int] = {}
for click in clicks:
if click["clicked_at"]:
day = click["clicked_at"].strftime("%Y-%m-%d")
clicks_by_day_map[day] = clicks_by_day_map.get(day, 0) + 1

clicks_by_day = [
ClickDayBucket(date=day, clicks=count)
for day, count in sorted(clicks_by_day_map.items())
]

referer_map: dict[str | None, int] = {}
for click in clicks:
referer = click.get("referer") or "direct"
referer_map[referer] = referer_map.get(referer, 0) + 1

top_referers = sorted(
[RefererStat(referer=r, clicks=c) for r, c in referer_map.items()],
key=lambda x: x.clicks,
reverse=True,
)[:10]

# ADVANCED ANALYTICS (placeholders for now)
# In production, these would extract geo/device/browser/os from click records
geo_data = [
{"country": "US", "clicks": max(0, total_clicks // 2)},
{"country": "GB", "clicks": max(0, total_clicks // 4)},
{"country": "DE", "clicks": max(0, total_clicks - (total_clicks // 2) - (total_clicks // 4))},
] if total_clicks > 0 else []

device_data = [
{"device": "mobile", "clicks": max(0, int(total_clicks * 0.6))},
{"device": "desktop", "clicks": max(0, int(total_clicks * 0.35))},
{"device": "tablet", "clicks": max(0, total_clicks - int(total_clicks * 0.6) - int(total_clicks * 0.35))},
] if total_clicks > 0 else []

browser_data = [
{"browser": "Chrome", "clicks": max(0, int(total_clicks * 0.7))},
{"browser": "Safari", "clicks": max(0, int(total_clicks * 0.2))},
{"browser": "Firefox", "clicks": max(0, total_clicks - int(total_clicks * 0.7) - int(total_clicks * 0.2))},
] if total_clicks > 0 else []

os_data = [
{"os": "iOS", "clicks": max(0, int(total_clicks * 0.35))},
{"os": "Android", "clicks": max(0, int(total_clicks * 0.4))},
{"os": "Windows", "clicks": max(0, int(total_clicks * 0.2))},
{"os": "macOS", "clicks": max(0, total_clicks - int(total_clicks * 0.35) - int(total_clicks * 0.4) - int(total_clicks * 0.2))},
] if total_clicks > 0 else []

analytics = PerLinkAnalytics(
url_id=url.id,
short_code=url.short_code,
long_url=url.long_url,
total_clicks=total_clicks,
clicks_by_day=clicks_by_day,
top_referers=top_referers,
)

response_data = {
"url_id": analytics.url_id,
"short_code": analytics.short_code,
"long_url": analytics.long_url,
"total_clicks": analytics.total_clicks,
"clicks_by_day": [
{"date": cdb.date, "clicks": cdb.clicks} for cdb in analytics.clicks_by_day
],
"top_referers": [
{"referer": rs.referer, "clicks": rs.clicks} for rs in analytics.top_referers
],
# Advanced analytics
"geo": geo_data,
"devices": device_data,
"browsers": browser_data,
"os": os_data,
}

return jsonify({"data": response_data}), 200
22 changes: 22 additions & 0 deletions services/flask-backend/app/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from werkzeug.exceptions import BadRequest, Forbidden, NotFound

from .auth import auth_required
from .features import feature_enabled, get_license_tier
from .models import get_db
from .rbac import get_user_scopes, require_scope
from .schemas.collections import (
Expand Down Expand Up @@ -158,6 +159,27 @@ async def create_collection():
if existing:
return jsonify({"error": "Collection name already exists in this location"}), 409

# Check Free tier 5-collection cap (behind feature flag 'current.collections')
tier = get_license_tier()
flag_enabled = feature_enabled("current.collections", str(user_id))

if tier == "free" and flag_enabled:
# Free tier limited to 5 collections
collection_count = db(db.collections.tenant_id == tenant_id).count()
if collection_count >= 5:
return (
jsonify(
{
"error": "Collection limit reached",
"message": "Free tier is limited to 5 collections. Upgrade to Professional for unlimited collections.",
"limit": 5,
"current_count": collection_count,
"required_tier": "professional",
}
),
402,
)

collection_id = db.collections.insert(
tenant_id=tenant_id,
team_id=req.team_id,
Expand Down
4 changes: 4 additions & 0 deletions services/flask-backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ class Config:
APP_DOMAIN = os.getenv("APP_DOMAIN", "")
PREMIUM_BYPASS_DOMAINS = ["current.penguintech.cloud", "currenturl.app"]

# Feature Flags (PostHog)
POSTHOG_KEY = os.getenv("POSTHOG_KEY", "")
POSTHOG_HOST = os.getenv("POSTHOG_HOST", "https://license.penguintech.io")

# Redirect and Analytics
SHORT_DOMAIN = os.getenv("SHORT_DOMAIN", "http://localhost:5000")
CLICK_IP_SALT = os.getenv("CLICK_IP_SALT", "dev-salt-change-in-production")
Expand Down
Loading