feat: added desktop sso endpoints
This commit is contained in:
161
VIBNCODE_PLAN.md
Normal file
161
VIBNCODE_PLAN.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# VibnCode: Cloud-Powered Agent Desktop IDE Architecture & Implementation Plan
|
||||||
|
|
||||||
|
**Project Name:** `vibncode` (formerly TalkCody)
|
||||||
|
**Target Architecture:** Desktop Thin Client with Monaco + Native Cloud Hosting Integration
|
||||||
|
**Backend Platform:** Vibnai Cloud Infrastructure (`vibn-frontend`, `vibn-agent-runner`, Gitea, Coolify)
|
||||||
|
**Status File Location:** `master-ai/VIBNCODE_PLAN.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System High-Level Architecture Diagram
|
||||||
|
|
||||||
|
`vibncode` functions as a high-fidelity window into your cloud-hosted workspace, routing all actions, skills, and terminal commands to the `vibn-agent-runner` and Gitea/Coolify backend.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
%% Styling
|
||||||
|
classDef client fill:#E1F5FE,stroke:#0288D1,stroke-width:2px;
|
||||||
|
classDef runner fill:#EDE7F6,stroke:#5E35B1,stroke-width:2px;
|
||||||
|
classDef infra fill:#E8F5E9,stroke:#2E7D32,stroke-width:2px;
|
||||||
|
|
||||||
|
subgraph Client [Desktop client - vibncode]
|
||||||
|
UI[React 19 / Monaco Editor UI]
|
||||||
|
State[Zustand Stores / Local SQLite DB Cache]
|
||||||
|
Tauri[Tauri v2 App Wrapper]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CloudRunner [Cloud Workspace - vibn-agent-runner]
|
||||||
|
Runner[Agent Session Runner Engine]
|
||||||
|
Workspace[Sandboxed Project Dir: /workspaces]
|
||||||
|
Tools[Vibn Platform & Sentry Tools]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Infrastructure [Vibnai Platform]
|
||||||
|
DB[(PostgreSQL Database)]
|
||||||
|
WebAPI[Next.js API Server: vibn-frontend]
|
||||||
|
Gitea[(Gitea Git Server)]
|
||||||
|
Coolify[Coolify Server Hosting]
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Connections
|
||||||
|
UI <-->|1. Event Streams & File Sync| WebAPI
|
||||||
|
UI -->|2. Start/Cancel Execution| WebAPI
|
||||||
|
WebAPI -->|3. Route Exec Request| Runner
|
||||||
|
Runner <-->|4. Code Edits & Shell Runs| Workspace
|
||||||
|
Runner -->|5. Save Output & File Changes| WebAPI
|
||||||
|
WebAPI <-->|6. Write State & Logs| DB
|
||||||
|
Runner <-->|7. Push/Pull/Clone Code| Gitea
|
||||||
|
Runner -->|8. Manage & Deploy Apps| Coolify
|
||||||
|
Workspace -->|9. Pull Guidelines| Gitea
|
||||||
|
|
||||||
|
class UI,State,Tauri client;
|
||||||
|
class Runner,Workspace,Tools runner;
|
||||||
|
class DB,WebAPI,Gitea,Coolify,Workspace infra;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Structural Requirements & Feature Specification
|
||||||
|
|
||||||
|
### 2.1 Rebranding to `vibncode`
|
||||||
|
* All codebase identifiers, folder structures, logs, config registries, and binary product properties are renamed from `talkcody` to `vibncode` / `VibnCode` to prevent trademark leaks.
|
||||||
|
* The application bundle identifier is modified to `com.vibnai.vibncode`.
|
||||||
|
|
||||||
|
### 2.2 Preservation of the Desktop UI Experience
|
||||||
|
* The Monaco Editor, collapsible multi-panel grid, dark theme, and rich agent step logs are preserved exactly.
|
||||||
|
* All compiler execution and file system checks are offloaded to your secure cloud container runner.
|
||||||
|
|
||||||
|
### 2.3 Cloud-Hosted Live Browser Preview Pane
|
||||||
|
* **Split-Screen Interface**: Adjacent to the Monaco code editor, we inject a live webview/iframe tab.
|
||||||
|
* **Wildcard DNS Mapping**: The pane points directly to your dynamic wildcard URL generated during a dev server boot:
|
||||||
|
`https://[projectSlug]-[devServerPort].preview.vibnai.com`
|
||||||
|
* **Instant Hot-Module Replacement (HMR)**: As the cloud agent or the developer edits files, Vite or Next.js compiles the changes inside the cloud container in milliseconds. Traefik reverse-proxies the stream, causing the embedded browser pane to update instantly on-screen without requiring a manual refresh.
|
||||||
|
|
||||||
|
### 2.4 Autonomous Gitea & Coolify Environment Administration
|
||||||
|
* We add tabs to the left navigation panel matching the modules of your Vibnai Web Dashboard:
|
||||||
|
1. **Code [📝]**: Monaco editor, virtual filesystem browser.
|
||||||
|
2. **Plan [📋]**: Synchronized feature checklist showing requirements directly tied to `fs_projects.phase_data`.
|
||||||
|
3. **Host [🚀]**: Lists running Coolify application containers, database states, environment credentials (`.env`), and displays live resource monitors (RAM, CPU, Build Logs).
|
||||||
|
4. **Market [📊]**: Suggests GBP categories, researches competitor domains (DataForSEO), and performs technical site scrapes.
|
||||||
|
5. **Preview [🌐]**: Embedded browser preview tab rendering the wildcard live deployment URL.
|
||||||
|
* **Autonomous Agent Tools**: The AI is equipped with Gitea and Coolify administration commands (e.g. `git_commit_and_push`, `apps_create_database`, `apps_deploy`, `hosting_set_env`, `project_recent_errors`). The AI can provision databases, modify env keys, verify compiling logs, and fix its own code bugs inside the sandboxed cloud workspaces autonomously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database & Database Synchronization Specifications
|
||||||
|
|
||||||
|
### 3.1 Mapping Local Client States to PostgreSQL
|
||||||
|
We will link `vibncode`'s local state structures with your `vibn-frontend` PostgreSQL databases:
|
||||||
|
|
||||||
|
* **Project Lists**: Retrieved via `GET /api/projects` querying the `fs_projects` and `fs_users` tables.
|
||||||
|
* **Active Agent Session**: Instantiated via `POST /api/projects/[projectId]/agent/sessions` and updated by `vibn-agent-runner` sending `PATCH /api/projects/[projectId]/agent/sessions/[sessionId]` with `outputLine` and `changedFile` updates.
|
||||||
|
* **Virtual File Tree**: Read via `GET /api/workspace/tree` which performs a directory scrape of `/workspaces/[giteaRepo]` and renders a visual file browser in the desktop client's sidebar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Code Paths & Planned Refactoring
|
||||||
|
|
||||||
|
To implement this architecture elegantly, the following specific files will be refactored:
|
||||||
|
|
||||||
|
### 4.1 Client-Side Refactoring (Tauri / React)
|
||||||
|
1. **`src/services/file-service.ts`**: Replace `@tauri-apps/plugin-fs` reads/writes with authorized cloud workspace endpoints:
|
||||||
|
* `GET /api/workspace/tree` $\rightarrow$ Scrapes Gitea directory tree.
|
||||||
|
* `POST /api/workspace/file/read` $\rightarrow$ Fetches content for Monaco.
|
||||||
|
* `POST /api/workspace/file/write` $\rightarrow$ Writes edits back to the cloud.
|
||||||
|
2. **`src/services/execution-service.ts`**:
|
||||||
|
* Redirect `startExecution()` to trigger `POST /api/projects/[projectId]/agent/execute` on the server instead of running the loop inside the local app process.
|
||||||
|
* Connect to `vibn-frontend`'s SSE or session poll endpoint to map logs, changed file notifications, and status streams directly to Zustand.
|
||||||
|
3. **`src/components/chat/embedded-preview.tsx`** (New Component):
|
||||||
|
* Incorporate a split-screen panel with an `<iframe src="https://[subdomain].preview.vibnai.com" />` which shows/hides adjacent to the Monaco Editor when a dev server is booted.
|
||||||
|
|
||||||
|
### 4.2 Server-Side Porting (`vibn-agent-runner`)
|
||||||
|
1. **Incorporate Parallel Agent Brain**:
|
||||||
|
* Extract TalkCody's `tool-dependency-analyzer.ts` and `tool-executor.ts` (TypeScript) and port them inside `vibn-agent-runner/src/orchestration/`.
|
||||||
|
* This equips your cloud runner with **4-level Smart Concurrency** (batching parallel reads and serializing write reviews inside Docker).
|
||||||
|
2. **Enable the Ralph Loop Autonomy**:
|
||||||
|
* Inject `ralph-loop-service.ts` into your `runSessionAgent()` background executor. This ensures that when the agent outputs self-reflection keywords, the cloud-runner automatically spawns a continuation cycle until the task is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Component-Level Service Redirections (The Vibn Integration Bridge)
|
||||||
|
|
||||||
|
To preserve the rich desktop UI completely intact and prevent breaking any complex React views, we map all native local OS dependencies inside `src/components/` to go over our cloud services instead of rewriting the files individually:
|
||||||
|
|
||||||
|
### 5.1 Virtualizing file-editor-content.tsx & file-tree.tsx
|
||||||
|
* **The Component**: The Monaco Editor panel and the Sidebar File Tree.
|
||||||
|
* **Current State**: Calls `repositoryService.readFileContent(path)` and `fastDirectoryTreeService.buildDirectoryTree(rootPath)` which invoke local Rust file-readers and Tauri FS plugins.
|
||||||
|
* **Re-engineered Bridge**: We modify `repositoryService` and `fastDirectoryTreeService` to POST to your secure Next.js `POST /api/mcp` endpoint with the actions `"fs.read"` and `"fs.tree"`. This fetches the file content and directories list directly from Gitea inside the `/workspaces` Docker environment and populates Monaco seamlessly.
|
||||||
|
|
||||||
|
### 5.2 Redirecting empty-repository-state.tsx
|
||||||
|
* **The Component**: The welcoming dashboard panel shown when no local repository folder is opened.
|
||||||
|
* **Current State**: Prompts the developer to select a folder on their local hard drive.
|
||||||
|
* **Re-engineered Bridge**: When the developer logs in and selects a project on the `projects-page.tsx`, we automatically set the active project `root_path` to the cloud repository path (derived from the database) and immediately trigger the virtual directory tree, bypassing local disk queries entirely.
|
||||||
|
|
||||||
|
### 5.3 Bridging file-preview.tsx (Chat Attachments)
|
||||||
|
* **The Component**: The attachment downloader inside your chat messages (saving images/videos locally).
|
||||||
|
* **Current State**: Uses Tauri's `save` dialog and `plugin-fs` to write bytes to the local Downloads folder.
|
||||||
|
* **Re-engineered Bridge**: We modify this to download attachments directly from your GCP Cloud Storage bucket / R2 CDN (`https://cdn.vibnai.com/attachments/...`) over HTTP using the native browser download manager.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Phased Implementation Roadmap
|
||||||
|
|
||||||
|
```
|
||||||
|
PHASE 1: System Rebranding & Desktop App Clean-up
|
||||||
|
├── Rebrand 'talkcody' to 'vibncode' globally across folders & package assets.
|
||||||
|
├── Modify Tauri config JSONs with the bundle ID 'com.vibnai.vibncode'.
|
||||||
|
└── Disable local compilers and mock local SQLite caches.
|
||||||
|
|
||||||
|
PHASE 2: Backend API Development in Next.js
|
||||||
|
├── Implement virtual file endpoints (POST /api/workspace/file/read, GET /tree).
|
||||||
|
└── Establish SSE / WebSockets stream routes for agent output lines.
|
||||||
|
|
||||||
|
PHASE 3: UI Layout & Cloud Connection Setup
|
||||||
|
├── Implement left-nav tabs: Plan, Host, Market, and Code.
|
||||||
|
├── Hook file explorer & Monaco to read/write cloud files.
|
||||||
|
└── Embed wildcard preview tab pointing to *.preview.vibnai.com.
|
||||||
|
|
||||||
|
PHASE 4: Orchestrating the Cloud Agent Brain
|
||||||
|
├── Migrate TalkCody's parallel analyzer & Ralph Loop to vibn-agent-runner.
|
||||||
|
└── Enable full, autonomous environment management (Gitea, Sentry, Coolify API).
|
||||||
|
```
|
||||||
1
talkcody
Submodule
1
talkcody
Submodule
Submodule talkcody added at 5543bf9264
226
vibn-attribution-package/README.md
Normal file
226
vibn-attribution-package/README.md
Normal 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.
|
||||||
61
vibn-attribution-package/backend/mixins.py
Normal file
61
vibn-attribution-package/backend/mixins.py
Normal 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
|
||||||
164
vibn-attribution-package/backend/models.py
Normal file
164
vibn-attribution-package/backend/models.py
Normal 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"]
|
||||||
185
vibn-attribution-package/frontend/VibnTracker.tsx
Normal file
185
vibn-attribution-package/frontend/VibnTracker.tsx
Normal 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;
|
||||||
|
}
|
||||||
139
vibn-attribution-package/umami-bridge/umami_service.py
Normal file
139
vibn-attribution-package/umami-bridge/umami_service.py
Normal 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
|
||||||
1
vibn-code
Submodule
1
vibn-code
Submodule
Submodule vibn-code added at 5543bf9264
46
vibn-frontend/app/api/auth/me/route.ts
Normal file
46
vibn-frontend/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/auth/me
|
||||||
|
*
|
||||||
|
* Exposes a profile endpoint for the VibnCode desktop app.
|
||||||
|
* Resolves the Bearer vibn_sk_... Workspace API key, queries the database,
|
||||||
|
* and returns the corresponding owner's user details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
|
||||||
|
import { queryOne } from "@/lib/db-postgres";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
// 1. Authenticate the Workspace API key or Browser Session
|
||||||
|
const principal = await requireWorkspacePrincipal(request);
|
||||||
|
if (principal instanceof NextResponse) return principal;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Query user details from fs_users
|
||||||
|
const user = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
data: any;
|
||||||
|
}>(
|
||||||
|
`SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`,
|
||||||
|
[principal.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Return user profile compatible with the desktop client's User expectations
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
name: user.data?.name || user.data?.display_name || "Vibn Owner",
|
||||||
|
email: user.data?.email || "",
|
||||||
|
photoUrl: user.data?.image || user.data?.photo_url || null,
|
||||||
|
workspace: principal.workspace.slug,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[api/auth/me GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to resolve profile" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
61
vibn-frontend/app/api/auth/token/route.ts
Normal file
61
vibn-frontend/app/api/auth/token/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/auth/token
|
||||||
|
*
|
||||||
|
* Secure endpoint called by the browser during desktop SSO.
|
||||||
|
* Verifies the user has a valid NextAuth browser session, resolves or mints
|
||||||
|
* a Workspace API key, and returns it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authSession } from "@/lib/auth/session-server";
|
||||||
|
import { queryOne } from "@/lib/db-postgres";
|
||||||
|
import { getWorkspaceByOwner } from "@/lib/workspaces";
|
||||||
|
import { mintWorkspaceApiKey, listWorkspaceApiKeys, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
// 1. Verify caller has an active NextAuth browser session cookie
|
||||||
|
const session = await authSession();
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fetch the corresponding Postgres user
|
||||||
|
const user = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
|
||||||
|
[session.user.email]
|
||||||
|
);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get the user's active workspace
|
||||||
|
const workspace = await getWorkspaceByOwner(user.id);
|
||||||
|
if (!workspace) {
|
||||||
|
return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. Try to reuse their existing, active workspace API key to avoid key bloating
|
||||||
|
const keys = await listWorkspaceApiKeys(workspace.id);
|
||||||
|
const activeKey = keys.find((k) => !k.revoked_at);
|
||||||
|
|
||||||
|
if (activeKey) {
|
||||||
|
const revealed = await revealWorkspaceApiKey(workspace.id, activeKey.id);
|
||||||
|
if (revealed) {
|
||||||
|
return NextResponse.json({ token: revealed.token });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Otherwise, mint a fresh key for the desktop client
|
||||||
|
const minted = await mintWorkspaceApiKey({
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name: "VibnCode Desktop SSO",
|
||||||
|
createdBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ token: minted.token });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[api/auth/token GET]", err);
|
||||||
|
return NextResponse.json({ error: "Failed to resolve workspace token" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,14 +21,39 @@ function AuthPageInner() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [ssoProcessing, setSsoProcessing] = React.useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === "authenticated" && session?.user?.email) {
|
if (status === "authenticated" && session?.user?.email) {
|
||||||
|
const isVibnCodeSSO = searchParams?.get("vibncode") === "true";
|
||||||
|
|
||||||
|
if (isVibnCodeSSO) {
|
||||||
|
setSsoProcessing(true);
|
||||||
|
// Call our new secure token endpoint
|
||||||
|
fetch("/api/auth/token")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.token) {
|
||||||
|
// Deep-link redirect back to the VibnCode desktop app
|
||||||
|
window.location.href = `vibncode://auth/callback?token=${data.token}`;
|
||||||
|
} else {
|
||||||
|
console.error("SSO Token missing from response", data);
|
||||||
|
setSsoProcessing(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Desktop SSO failed:", err);
|
||||||
|
setSsoProcessing(false);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const workspace = deriveWorkspace(session.user.email);
|
const workspace = deriveWorkspace(session.user.email);
|
||||||
|
|
||||||
// Check if user has projects. If 0, go to onboarding, else go to projects.
|
// Check if user has projects. If 0, go to onboarding, else go to projects.
|
||||||
fetch("/api/projects")
|
fetch("/api/projects")
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(d => {
|
.then((d) => {
|
||||||
if (d.projects && d.projects.length > 0) {
|
if (d.projects && d.projects.length > 0) {
|
||||||
router.push(`/${workspace}/projects`);
|
router.push(`/${workspace}/projects`);
|
||||||
} else {
|
} else {
|
||||||
@@ -39,7 +64,7 @@ function AuthPageInner() {
|
|||||||
}
|
}
|
||||||
}, [status, session, router, searchParams]);
|
}, [status, session, router, searchParams]);
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading" || ssoProcessing) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="new-site-wrapper"
|
className="new-site-wrapper"
|
||||||
@@ -80,7 +105,9 @@ function AuthPageInner() {
|
|||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Checking session
|
{ssoProcessing
|
||||||
|
? "Authorizing VibnCode Desktop..."
|
||||||
|
: "Checking session"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user