feat: added desktop sso endpoints

This commit is contained in:
2026-05-28 16:05:47 -07:00
parent 91a376ac0a
commit bf6171a667
11 changed files with 1077 additions and 5 deletions

View File

@@ -0,0 +1,226 @@
# 🚀 Vibn Marketing Attribution & Identity Sync SDK
A modular, highly scalable, and decoupled SDK package to link anonymous site traffic tracking (**Umami Analytics**) with registered user profiles (**Vibn Database**), providing comprehensive **First-Touch Marketing Attribution** and acquisition logs.
Developed originally for **Missinglettr** and structured specifically to be drop-in ready for the **Vibn Platform** architecture.
---
## 📁 Package Inventory
This package contains three decoupled layers designed to be copied directly into your respective repositories:
| Path | Purpose | Language / Framework |
| :--- | :--- | :--- |
| `frontend/VibnTracker.tsx` | Client-side search params listener, sessionStorage / Cookie persistence, automated Umami injection, and session identity bridging (`identify()`). | TypeScript / Next.js React |
| `backend/models.py` | Abstract Base Django Model adding UTM and Referrer fields to any User / Client record. | Python / Django |
| `backend/mixins.py` | Django REST Framework (DRF) interceptor mixin to auto-capture attribution variables from incoming signup payloads. | Python / Django REST Framework |
| `umami-bridge/umami_service.py` | Reusable bridge to pull aggregated pageviews, session timestamps, and raw click-trails from Umami (via REST API or raw DB joins). | Python / Django |
---
## 🏛️ Architecture Overview
The system bridges anonymous traffic with database records using a secure **Identity Exchange**:
```
[Anonymous Visitor Clicks Ad / Tweet]
[Lands on Website with UTMs & Referrer]
│ ├── Auto-logged in Umami (analytics.vibnai.com)
│ └── Cached client-side in sessionStorage & Cookie fallback
[Signs Up / Logs in via Google OAuth]
[Frontend POSTs Signup payload + UTM params to Backend]
[Django Backend saves user record + permanently binds UTM columns]
[Frontend calls useVibnTracker.identify(user_id)]
[Umami merges ALL previous anonymous browsing history with this new User ID!]
```
---
## 💻 Step-by-Step Implementation Guide
### 1. Frontend Integration (Vibn Next.js Web App)
Copy `frontend/VibnTracker.tsx` into your Next.js project (e.g., inside `components/metrics/VibnTracker.tsx`):
#### A. Wrap your Root Layout:
In `app/layout.tsx` (or your entry file), wrap the tree with `VibnTrackerProvider`. This will automatically inject your Umami tracking script and listen for UTM parameters across your landing and console domains:
```tsx
import { VibnTrackerProvider } from "@/components/metrics/VibnTracker";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<VibnTrackerProvider
umamiWebsiteId="your-umami-website-uuid"
umamiScriptUrl="https://analytics.vibnai.com/script.js"
>
<body>{children}</body>
</VibnTrackerProvider>
</html>
);
}
```
#### B. Capture & Send on Signup / Google OAuth exchange:
In your signup or Google OAuth exchange page (where the user completes registration), extract the cached attribution parameters using the `useVibnTracker` hook and send them in the request body to your backend:
```tsx
import { useVibnTracker } from "@/components/metrics/VibnTracker";
export default function GoogleCallbackPage() {
const { getStoredAttribution, identify, track } = useVibnTracker();
const handleOAuthSignup = async (googleToken: string) => {
// 1. Get first-touch parameters cached in browser
const attribution = getStoredAttribution();
// 2. POST to your register endpoint
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
google_token: googleToken,
...attribution // Sends utm_source, utm_medium, utm_campaign, referrer, etc.
})
});
if (response.ok) {
const user = await response.json();
// 3. BRIDGING IDENTITY: Link anonymous browser history with real user ID!
identify({
userId: user.id, // Primary key in your database
email: user.email,
name: user.name,
plan: user.plan
});
// 4. Track custom sign-up conversion in Umami
track("user_completed_signup", { plan: user.plan });
}
};
// ...
}
```
---
### 2. Backend Integration (Vibn Django API)
Copy `backend/models.py` and `backend/mixins.py` into your Django apps.
#### A. Inherit from the Abstract Model:
Add first-touch marketing attribution columns to your active User or APIClient model by inheriting from `VibnAbstractAttributionModel`:
```python
# your_app/models.py
from backend.models import VibnAbstractAttributionModel
class UserProfile(VibnAbstractAttributionModel):
# Your existing fields
email = models.EmailField(unique=True)
name = models.CharField(max_length=255)
class Meta:
db_table = "user_profile"
```
Then generate and apply your database migrations:
```bash
python manage.py makemigrations
python manage.py migrate
```
#### B. Intercept requests with the DRF View Mixin:
Add the `VibnAttributionCaptureMixin` to your Signup or Authentication API ViewSet. It will automatically intercept, extract, and write the UTM parameters into the saving model:
```python
# your_app/views.py
from rest_framework import viewsets
from backend.mixins import VibnAttributionCaptureMixin
from .models import UserProfile
from .serializers import UserProfileSerializer
class RegisterViewSet(VibnAttributionCaptureMixin, viewsets.ModelViewSet):
"""
User registration API view.
VibnAttributionCaptureMixin automatically grabs utm_source, utm_campaign,
and Referrer headers from requests and binds them onto the UserProfile on save.
"""
queryset = UserProfile.objects.all()
serializer_class = UserProfileSerializer
```
---
### 3. Expose Live Traffic Trails in Admin Consoles
Copy `umami-bridge/umami_service.py` into your Python services.
You can securely query stats directly from your Umami self-hosted database (read-only) inside your Admin panels, allowing you to render **actual pageviews, device breakdowns, and exact visitor session timelines** for any specific client.
#### A. Configure Umami DB Connection:
In Django's `settings.py`, register your Umami database:
```python
DATABASES = {
'default': {
# Your main application DB
},
'umami_db': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'umami_production_db',
'USER': 'readonly_user',
'PASSWORD': 'password',
'HOST': '34.19.250.135',
'PORT': '5432',
}
}
```
#### B. Fetch Live Session Data for Admin Console:
In your Admin API view (e.g. `admin_user_detail`), call the service to fetch raw click-trails:
```python
# your_admin_app/views.py
from rest_framework.response import Response
from umami_bridge.umami_service import VibnUmamiService
def get_admin_user_profile(request, user_id):
# Initialize service
umami = VibnUmamiService()
# 1. Fetch total web sessions, total pageviews, and first interaction timestamp
funnel = umami.get_aggregated_funnel_for_user(user_id, db_connection_name="umami_db")
# 2. Fetch the actual 50 most recent page clicks, devices, and browsers used by this client!
click_trail = umami.get_user_session_click_trail(user_id, db_connection_name="umami_db")
return Response({
"user_id": user_id,
"first_touch_web_funnel": funnel,
"live_click_trail": click_trail
})
```
---
## 🛡️ Best Practices & GDPR Compliance
1. **Self-Hosted Privacy:** Because Umami is hosted on your domain (`analytics.vibnai.com`), cookie and script blockades are minimized, and no data is shared with third parties (complying strictly with GDPR, CCPA, and PECR).
2. **First-Touch Preservation:** The mixin uses `if not client.utm_source:` checks on save, ensuring first-touch parameters (the exact ad or link that *originally* brought the user in) are preserved and never overwritten by subsequent organic logins.
3. **No Database Blockades:** The `umami_service.py` uses read-only connections and limits queries to `LIMIT 50` to make sure raw analytics querying never causes performance blocks on your main application database thread.

View File

@@ -0,0 +1,61 @@
import logging
from rest_framework import serializers
logger = logging.getLogger(__name__)
class VibnAttributionCaptureMixin:
"""
A Django REST Framework ViewSet/APIView mixin that automatically
extracts marketing attribution fields (UTMs, referrer) from incoming
request payloads or HTTP headers and binds them to the saving object.
Compatible with any Django REST Framework serializer.
"""
def extract_attribution_data(self, request):
"""
Extracts UTM and referrer keys from request data, falling back
to request headers if required.
"""
# Try request payload first
data = request.data or {}
utm_source = data.get("utm_source")
utm_medium = data.get("utm_medium")
utm_campaign = data.get("utm_campaign")
utm_content = data.get("utm_content")
utm_term = data.get("utm_term")
# Capture Referrer from payload or fallback to HTTP headers
referrer = data.get("referrer")
if not referrer:
referrer = request.META.get("HTTP_REFERER")
return {
"utm_source": utm_source,
"utm_medium": utm_medium,
"utm_campaign": utm_campaign,
"utm_content": utm_content,
"utm_term": utm_term,
"referrer": referrer,
}
def perform_create(self, serializer):
"""
In DRF ViewSets, overrides perform_create to automatically inject
attribution fields directly into the saved model instance.
"""
attrib_data = self.extract_attribution_data(self.request)
# Save serializer with attribution params injected as overrides
instance = serializer.save(
**{k: v for k, v in attrib_data.items() if v is not None}
)
logger.info(
f"VibnTracker: Saved attribution data to {instance.__class__.__name__} "
f"(Source: {attrib_data['utm_source']}, Campaign: {attrib_data['utm_campaign']})"
)
return instance

View File

@@ -0,0 +1,164 @@
from django.db import models
class VibnAbstractAttributionModel(models.Model):
"""
An abstract Django model that adds standard marketing attribution
(first-touch UTM parameters & referrers) to any model (e.g. User, APIClient, Customer).
To implement:
class APIClient(VibnAbstractAttributionModel):
name = models.CharField(max_length=255)
# ... other fields ...
"""
utm_source = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Marketing channel source (e.g. twitter, google, newsletter)",
)
utm_medium = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Marketing medium (e.g. cpc, social, organic, email)",
)
utm_campaign = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Marketing campaign name (e.g. mcp-launch, spring-discount)",
)
utm_content = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Specific content links or ad identifiers clicked",
)
utm_term = models.CharField(
max_length=255,
null=True,
blank=True,
help_text="Keywords searched or paid terms clicked",
)
referrer = models.CharField(
max_length=1000,
null=True,
blank=True,
help_text="First-touch browser referrer URL (e.g. https://t.co/, https://google.com)",
)
class Meta:
abstract = True
def save_attribution(
self,
utm_source,
utm_medium=None,
utm_campaign=None,
utm_content=None,
utm_term=None,
referrer=None,
overwrite=False,
):
"""
Helper method to securely save marketing attribution data if it hasn't been set yet.
"""
if overwrite or not self.utm_source:
self.utm_source = utm_source
self.utm_medium = utm_medium
self.utm_campaign = utm_campaign
self.utm_content = utm_content
self.utm_term = utm_term
self.referrer = referrer
self.save(
update_fields=[
"utm_source",
"utm_medium",
"utm_campaign",
"utm_content",
"utm_term",
"referrer",
]
if self.pk
else None
)
class VibnPageview(models.Model):
"""
Vibn Pageview Tracking Model.
Tracks active user interactions and page loads in real-time,
providing fully dynamic traffic data and click timelines.
"""
user = models.ForeignKey(
"UserProfile", # Replace with your custom User model name
on_delete=models.CASCADE,
related_name="pageviews",
null=True,
blank=True,
)
session_id = models.CharField(
max_length=255, db_index=True, help_text="Unique browser session tracker ID"
)
url_path = models.CharField(
max_length=500, db_index=True, help_text="Tracked page path, e.g. /pricing"
)
referrer = models.CharField(
max_length=1000, null=True, blank=True, help_text="Traffic referrer URL"
)
# Captured UTM variables
utm_source = models.CharField(max_length=255, null=True, blank=True)
utm_medium = models.CharField(max_length=255, null=True, blank=True)
utm_campaign = models.CharField(max_length=255, null=True, blank=True)
utm_content = models.CharField(max_length=255, null=True, blank=True)
utm_term = models.CharField(max_length=255, null=True, blank=True)
# Device/Geo details
device = models.CharField(max_length=100, default="desktop")
browser = models.CharField(max_length=100, default="chrome")
country = models.CharField(max_length=100, default="US")
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
db_table = "vibn_pageview"
verbose_name = "Vibn Pageview"
verbose_name_plural = "Vibn Pageviews"
ordering = ["-created_at"]
class VibnEvent(models.Model):
"""
Vibn Product Event Tracking Model.
Tracks custom user conversion events (e.g. signup, connect_channel, create_campaign)
and binds them to their sessions and profiles.
"""
user = models.ForeignKey(
"UserProfile", # Replace with your custom User model name
on_delete=models.CASCADE,
related_name="events",
null=True,
blank=True,
)
session_id = models.CharField(max_length=255, db_index=True)
event_name = models.CharField(
max_length=255,
db_index=True,
help_text="Custom event name, e.g. connect_social",
)
properties = models.JSONField(
default=dict, blank=True, help_text="Custom event properties JSON"
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
db_table = "vibn_event"
verbose_name = "Vibn Event"
verbose_name_plural = "Vibn Events"
ordering = ["-created_at"]

View File

@@ -0,0 +1,185 @@
"use client";
import React, { useEffect, createContext, useContext } from "react";
// ── TYPES & INTERFACES ────────────────────────────────────────────────────────
export interface UTMParams {
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
utm_content: string | null;
utm_term: string | null;
referrer: string | null;
}
export interface IdentifyTraits {
userId: string | number;
email: string;
name?: string;
plan?: string;
[key: string]: any;
}
export interface VibnTrackerContextType {
getStoredAttribution: () => UTMParams;
clearAttribution: () => void;
identify: (traits: IdentifyTraits) => void;
track: (eventName: string, properties?: Record<string, any>) => void;
}
declare global {
interface Window {
umami?: {
identify: (traits: Record<string, any>) => void;
track: (eventName: string, properties?: Record<string, any>) => void;
};
}
}
// ── UTILITY FUNCTIONS ─────────────────────────────────────────────────────────
const COOKIE_EXPIRY_DAYS = 30;
function setCookie(name: string, value: string, days: number) {
if (typeof document === "undefined") return;
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = "; expires=" + date.toUTCString();
document.cookie = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax";
}
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const nameEQ = name + "=";
const ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
function deleteCookie(name: string) {
if (typeof document === "undefined") return;
document.cookie = name + "=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
}
// ── CONTEXT PROVIDER ──────────────────────────────────────────────────────────
const VibnTrackerContext = createContext<VibnTrackerContextType | undefined>(undefined);
export function VibnTrackerProvider({
children,
umamiWebsiteId,
umamiScriptUrl = "https://analytics.vibnai.com/script.js",
}: {
children: React.ReactNode;
umamiWebsiteId?: string;
umamiScriptUrl?: string;
}) {
useEffect(() => {
if (typeof window === "undefined") return;
// 1. Extract UTMs from active search string
const urlParams = new URLSearchParams(window.location.search);
const keys: Array<keyof UTMParams> = [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_content",
"utm_term",
];
keys.forEach((key) => {
const val = urlParams.get(key);
if (val) {
// Persist to sessionStorage (for current window lifespans)
sessionStorage.setItem(key, val);
// Persist to cookie (cross-session backup, used if user closes window and signs up later)
setCookie(`vibn_${key}`, val, COOKIE_EXPIRY_DAYS);
}
});
// 2. Persist browser referrer
if (!sessionStorage.getItem("referrer") && !getCookie("vibn_referrer")) {
const ref = document.referrer || "direct";
sessionStorage.setItem("referrer", ref);
setCookie("vibn_referrer", ref, COOKIE_EXPIRY_DAYS);
}
// 3. Inject Umami analytics script automatically if website ID provided
if (umamiWebsiteId && !document.querySelector(`script[data-website-id="${umamiWebsiteId}"]`)) {
const script = document.createElement("script");
script.src = umamiScriptUrl;
script.setAttribute("data-website-id", umamiWebsiteId);
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
}, [umamiWebsiteId, umamiScriptUrl]);
const getStoredAttribution = (): UTMParams => {
if (typeof window === "undefined") {
return {
utm_source: null,
utm_medium: null,
utm_campaign: null,
utm_content: null,
utm_term: null,
referrer: null,
};
}
return {
utm_source: sessionStorage.getItem("utm_source") || getCookie("vibn_utm_source"),
utm_medium: sessionStorage.getItem("utm_medium") || getCookie("vibn_utm_medium"),
utm_campaign: sessionStorage.getItem("utm_campaign") || getCookie("vibn_utm_campaign"),
utm_content: sessionStorage.getItem("utm_content") || getCookie("vibn_utm_content"),
utm_term: sessionStorage.getItem("utm_term") || getCookie("vibn_utm_term"),
referrer: sessionStorage.getItem("referrer") || getCookie("vibn_referrer"),
};
};
const clearAttribution = () => {
if (typeof window === "undefined") return;
const keys = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "referrer"];
keys.forEach((key) => {
sessionStorage.removeItem(key);
deleteCookie(`vibn_${key}`);
});
};
const identify = (traits: IdentifyTraits) => {
if (typeof window !== "undefined" && window.umami) {
window.umami.identify(traits);
console.log(`[VibnTracker] User identified in Umami: ${traits.email}`);
}
};
const track = (eventName: string, properties?: Record<string, any>) => {
if (typeof window !== "undefined" && window.umami) {
window.umami.track(eventName, properties);
console.log(`[VibnTracker] Event tracked: "${eventName}"`, properties || "");
}
};
return (
<VibnTrackerContext.Provider
value={{ getStoredAttribution, clearAttribution, identify, track }}
>
{children}
</VibnTrackerContext.Provider>
);
}
// ── CUSTOM HOOK ───────────────────────────────────────────────────────────────
export function useVibnTracker() {
const context = useContext(VibnTrackerContext);
if (!context) {
throw new Error("useVibnTracker must be used within a VibnTrackerProvider");
}
return context;
}

View File

@@ -0,0 +1,139 @@
import logging
import requests
from django.db import connections
from django.utils import timezone
logger = logging.getLogger(__name__)
class VibnUmamiService:
"""
Vibn Umami Bridge Service.
Supports querying Umami analytics via either its official REST API or
performing direct, high-performance database JOINs against your self-hosted Postgres tables.
"""
def __init__(self, api_url=None, token=None, website_id=None):
self.api_url = api_url or "https://analytics.vibnai.com/api"
self.token = token
self.website_id = website_id
# ── DRIVER 1: REST API INTEGRATION ────────────────────────────────────────
def _get_headers(self):
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
def fetch_website_stats(self, start_at, end_at):
"""
Query website aggregated statistics (pageviews, visitors, bounce_rate)
via Umami REST API.
"""
if not self.website_id or not self.token:
logger.warning("Umami API connection details missing.")
return None
url = f"{self.api_url}/websites/{self.website_id}/stats"
params = {
"startAt": int(start_at.timestamp() * 1000),
"endAt": int(end_at.timestamp() * 1000),
}
try:
response = requests.get(
url, headers=self._get_headers(), params=params, timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to fetch Umami API stats: {e}")
return None
# ── DRIVER 2: DIRECT POSTGRESQL READ-ONLY QUERIES ─────────────────────────
def get_user_session_click_trail(self, user_id, db_connection_name="umami_db"):
"""
Retrieves raw browsing timeline for a identified user directly from
the self-hosted Umami DB instance using Django cross-database routing.
"""
if db_connection_name not in connections:
logger.warning(
f"Database connection '{db_connection_name}' is not configured."
)
return []
query = """
SELECT
we.created_at,
we.event_name,
we.url_path,
we.url_query,
s.device,
s.browser,
s.country
FROM website_event we
JOIN session s ON we.session_id = s.session_id
WHERE s.user_id = %s
ORDER BY we.created_at DESC
LIMIT 50;
"""
try:
with connections[db_connection_name].cursor() as cursor:
cursor.execute(query, [str(user_id)])
rows = cursor.fetchall()
trail = []
for r in rows:
trail.append(
{
"timestamp": r[0].isoformat()
if hasattr(r[0], "isoformat")
else r[0],
"event": r[1],
"path": r[2],
"query": r[3],
"device": r[4],
"browser": r[5],
"country": r[6],
}
)
return trail
except Exception as e:
logger.error(f"Direct Umami DB query failed: {e}")
return []
def get_aggregated_funnel_for_user(self, user_id, db_connection_name="umami_db"):
"""
Retrieves high-level counts for pageviews, unique sessions, and first-seen dates
directly from the raw Umami tables.
"""
if db_connection_name not in connections:
return None
query = """
SELECT
COUNT(*),
COUNT(DISTINCT we.session_id),
MIN(we.created_at)
FROM website_event we
JOIN session s ON we.session_id = s.session_id
WHERE s.user_id = %s;
"""
try:
with connections[db_connection_name].cursor() as cursor:
cursor.execute(query, [str(user_id)])
row = cursor.fetchone()
if row:
return {
"total_interactions": row[0],
"total_sessions": row[1],
"first_seen_at": row[2].isoformat()
if hasattr(row[2], "isoformat")
else row[2],
}
return None
except Exception as e:
logger.error(f"Failed to fetch aggregated user funnel: {e}")
return None