Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created February 6, 2026 14:13
Show Gist options
  • Select an option

  • Save shricodev/91b1544958ffdbaa107e43ff8fd30e85 to your computer and use it in GitHub Desktop.

Select an option

Save shricodev/91b1544958ffdbaa107e43ff8fd30e85 to your computer and use it in GitHub Desktop.
Test 2: Claude Opus 4.5 (Patch file)
From b4626e127e7fcfcbb0c7711f36b3e5f5cdb7b20c Mon Sep 17 00:00:00 2001
From: Shrijal Acharya <shrijal.acharya@gmail.com>
Date: Fri, 6 Feb 2026 13:38:55 +0545
Subject: [PATCH] final composio added as well
---
.env.example | 49 --
app/api/active-users/route.ts | 105 ++--
components/globe/GlobeViewer.tsx | 285 ++++++++--
components/providers/AnalyticsProvider.tsx | 18 +-
lib/composio.ts | 597 +++++++++++----------
store/useActiveUsersStore.ts | 26 +-
6 files changed, 654 insertions(+), 426 deletions(-)
delete mode 100644 .env.example
diff --git a/.env.example b/.env.example
deleted file mode 100644
index 36cc795..0000000
--- a/.env.example
+++ /dev/null
@@ -1,49 +0,0 @@
-# TerraView Environment Variables
-
-# Optional: Cesium Ion Access Token
-# Get a free token at: https://cesium.com/ion/tokens
-# This enables Cesium World Terrain and premium imagery
-# The app works without this, using free OpenStreetMap tiles
-NEXT_PUBLIC_CESIUM_ION_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI0OTM4NTBkYy1iZmFjLTQ1Y2ItOWE5ZS1kYzM1MjhhNTFkNzIiLCJpZCI6Mzg2NjMzLCJpYXQiOjE3NzAxMzUyMjZ9.YQ6I5xZJ8LV4SoEIYEYUbBHXb2wqUdwlJghJjErkHaI
-
-# Optional: Custom base URL for Cesium assets
-# Defaults to /cesium if not set
-NEXT_PUBLIC_CESIUM_BASE_URL=
-
-# ============================================
-# PostHog Analytics (via Composio)
-# ============================================
-
-# PostHog Project API Key (client-side, public)
-# Get from: PostHog Settings > Project > Project API Key
-NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-
-# PostHog Host (defaults to US cloud)
-NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
-
-# PostHog Personal API Key (server-side, private)
-# Get from: PostHog Settings > Personal API Keys
-POSTHOG_API_KEY=phx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-
-# PostHog Project ID (for API queries)
-# Get from: PostHog Settings > Project > Project ID
-POSTHOG_PROJECT_ID=12345
-
-# Same as NEXT_PUBLIC_POSTHOG_HOST for server-side use
-POSTHOG_HOST=https://us.i.posthog.com
-
-# ============================================
-# Composio Configuration
-# ============================================
-
-# Composio API Key
-# Get from: https://platform.composio.dev/settings
-COMPOSIO_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
-
-# ============================================
-# OpenAI Configuration (for Composio tool execution)
-# ============================================
-
-# OpenAI API Key
-# Get from: https://platform.openai.com/api-keys
-OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
diff --git a/app/api/active-users/route.ts b/app/api/active-users/route.ts
index 7df729c..82d2ade 100644
--- a/app/api/active-users/route.ts
+++ b/app/api/active-users/route.ts
@@ -1,5 +1,8 @@
-import { NextResponse } from 'next/server';
-import { getPostHogComposioService, type ActiveUser } from '@/lib/composio';
+import { NextResponse } from "next/server";
+import {
+ getPostHogComposioService,
+ type ActiveUser,
+} from "@/lib/composio";
// Refresh interval for fetching users (5 minutes)
const CACHE_DURATION_MS = 5 * 60 * 1000;
@@ -21,35 +24,44 @@ export async function GET() {
});
}
- // Fetch fresh data from PostHog via Composio
+ // Fetch fresh data from PostHog (via Composio or direct fallback)
const service = getPostHogComposioService();
- const users = await service.getActiveUsers(100);
-
- // Update cache
- cachedUsers = users;
+ const posthogUsers = await service.getActiveUsers(100);
+
+ // Merge PostHog results with existing cache instead of replacing.
+ // Users added via POST (location updates) should persist even if
+ // PostHog hasn't indexed them yet.
+ const merged = new Map<string, ActiveUser>();
+ for (const u of cachedUsers) {
+ merged.set(u.distinctId, u);
+ }
+ for (const u of posthogUsers) {
+ merged.set(u.distinctId, u);
+ }
+ cachedUsers = Array.from(merged.values());
lastFetchTime = now;
return NextResponse.json({
- users,
+ users: cachedUsers,
cached: false,
lastFetched: new Date(now).toISOString(),
});
} catch (error) {
- console.error('Error fetching active users:', error);
+ console.error("Error fetching active users:", error);
// Return cached data on error if available
if (cachedUsers.length > 0) {
return NextResponse.json({
users: cachedUsers,
cached: true,
- error: 'Failed to fetch fresh data, returning cached',
+ error: "Failed to fetch fresh data, returning cached",
lastFetched: new Date(lastFetchTime).toISOString(),
});
}
return NextResponse.json(
- { error: 'Failed to fetch active users', users: [] },
- { status: 500 }
+ { error: "Failed to fetch active users", users: [] },
+ { status: 500 },
);
}
}
@@ -58,61 +70,56 @@ export async function GET() {
export async function POST(request: Request) {
try {
const body = await request.json();
- const { distinctId, latitude, longitude, accuracy } = body;
+ const { distinctId, latitude, longitude, accuracy, name, email } = body;
if (!distinctId || latitude === undefined || longitude === undefined) {
return NextResponse.json(
- { error: 'Missing required fields: distinctId, latitude, longitude' },
- { status: 400 }
+ {
+ error:
+ "Missing required fields: distinctId, latitude, longitude",
+ },
+ { status: 400 },
);
}
const service = getPostHogComposioService();
- // Update user location in PostHog
- const success = await service.updateUserLocation(
- distinctId,
- latitude,
- longitude,
- accuracy
- );
-
- if (!success) {
- return NextResponse.json(
- { error: 'Failed to update location' },
- { status: 500 }
+ // Send location to PostHog (capture event + identify).
+ // This is best-effort; we still update the in-memory cache so the UI
+ // shows the user immediately even if PostHog ingestion is slow.
+ service
+ .updateUserLocation(distinctId, latitude, longitude, accuracy)
+ .catch((err) =>
+ console.error("Background location update failed:", err),
);
- }
- // Update in-memory cache for immediate reflection
- const existingUserIndex = cachedUsers.findIndex(
- (u) => u.distinctId === distinctId
+ // Update in-memory cache for immediate UI reflection
+ const existingIndex = cachedUsers.findIndex(
+ (u) => u.distinctId === distinctId,
);
- if (existingUserIndex >= 0) {
- cachedUsers[existingUserIndex] = {
- ...cachedUsers[existingUserIndex],
- location: { latitude, longitude },
- lastSeen: new Date().toISOString(),
- isOnline: true,
- };
+ const userEntry: ActiveUser = {
+ id: distinctId,
+ distinctId,
+ location: { latitude, longitude },
+ lastSeen: new Date().toISOString(),
+ isOnline: true,
+ email: email || (existingIndex >= 0 ? cachedUsers[existingIndex].email : undefined),
+ name: name || (existingIndex >= 0 ? cachedUsers[existingIndex].name : undefined),
+ };
+
+ if (existingIndex >= 0) {
+ cachedUsers[existingIndex] = userEntry;
} else {
- // Add new user to cache
- cachedUsers.push({
- id: distinctId,
- distinctId,
- location: { latitude, longitude },
- lastSeen: new Date().toISOString(),
- isOnline: true,
- });
+ cachedUsers.push(userEntry);
}
return NextResponse.json({ success: true });
} catch (error) {
- console.error('Error updating user location:', error);
+ console.error("Error updating user location:", error);
return NextResponse.json(
- { error: 'Failed to update location' },
- { status: 500 }
+ { error: "Failed to update location" },
+ { status: 500 },
);
}
}
diff --git a/components/globe/GlobeViewer.tsx b/components/globe/GlobeViewer.tsx
index 4767e67..d57ea46 100644
--- a/components/globe/GlobeViewer.tsx
+++ b/components/globe/GlobeViewer.tsx
@@ -13,11 +13,14 @@ import {
Cartesian2,
Entity,
Ion,
+ VerticalOrigin,
} from 'cesium';
+import { X, Mail, MapPin, Clock } from 'lucide-react';
import { CameraControls } from '@/components/globe/CameraControls';
import { CoordinateDisplay } from '@/components/globe/CoordinateDisplay';
import { MeasurementOverlay } from '@/components/globe/MeasurementOverlay';
+import { formatCoordinate } from '@/lib/formatting';
import {
useGlobeStore,
useLayersStore,
@@ -44,6 +47,80 @@ if (typeof window !== 'undefined') {
}
}
+// ---------- Pin marker rendering ----------
+
+const markerImageCache = new Map<string, HTMLCanvasElement>();
+
+function createPinMarker(
+ initial: string,
+ color: string,
+ isCurrentUser: boolean,
+): HTMLCanvasElement {
+ const key = `${initial}-${color}-${isCurrentUser}`;
+ const cached = markerImageCache.get(key);
+ if (cached) return cached;
+
+ const w = isCurrentUser ? 46 : 38;
+ const h = isCurrentUser ? 58 : 48;
+ const canvas = document.createElement('canvas');
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d')!;
+
+ const cx = w / 2;
+ const r = w / 2 - 4;
+ const cy = r + 4;
+ const tipY = h - 2;
+ const gap = 0.4;
+
+ // Drop shadow
+ ctx.shadowColor = 'rgba(0, 0, 0, 0.45)';
+ ctx.shadowBlur = 5;
+ ctx.shadowOffsetY = 2;
+
+ // Pin shape: arc (most of the circle) → line to tip → close back
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, Math.PI / 2 + gap, Math.PI / 2 - gap, false);
+ ctx.lineTo(cx, tipY);
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.fill();
+
+ // White border
+ ctx.shadowColor = 'transparent';
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.92)';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ // Subtle inner highlight
+ ctx.beginPath();
+ ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
+ ctx.fill();
+
+ // Initial letter
+ ctx.fillStyle = 'white';
+ ctx.font = `600 ${Math.round(r * 0.88)}px system-ui, -apple-system, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(initial.toUpperCase(), cx, cy + 1);
+
+ markerImageCache.set(key, canvas);
+ return canvas;
+}
+
+function getUserMarkerColor(
+ userId: string,
+ distinctId: string,
+ isOnline: boolean,
+ currentUserId: string | null,
+): string {
+ const isCurrent = userId === currentUserId || distinctId === currentUserId;
+ if (isCurrent) return '#22c55e';
+ if (isOnline) return '#a855f7';
+ return '#6b7280';
+}
+
export function GlobeViewer() {
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer | null>(null);
@@ -63,6 +140,18 @@ export function GlobeViewer() {
const { places } = usePlacesStore();
const { users: activeUsers, currentUserId } = useActiveUsersStore();
+ // User marker popup
+ const [popup, setPopup] = useState<{
+ userId: string;
+ screenX: number;
+ screenY: number;
+ } | null>(null);
+ const popupRef = useRef<typeof popup>(null);
+ popupRef.current = popup;
+
+ const popupUser = popup
+ ? activeUsers.find((u) => u.id === popup.userId) ?? null
+ : null;
const flyTo = useCallback(
(lat: number, lon: number, height = 50000, duration = CAMERA_SETTINGS.flyToDuration) => {
@@ -235,8 +324,25 @@ export function GlobeViewer() {
}
}, ScreenSpaceEventType.MOUSE_MOVE);
- // Click handler
+ // Click handler (with user-marker entity picking)
handler.setInputAction((click: { position: Cartesian2 }) => {
+ // Check if a user marker entity was clicked
+ const picked = viewer.scene.pick(click.position);
+ if (picked?.id instanceof Entity) {
+ const entityId = picked.id.id;
+ if (entityId && entityId.startsWith('active-user-')) {
+ const userId = entityId.replace('active-user-', '');
+ setPopup({ userId, screenX: click.position.x, screenY: click.position.y });
+ return;
+ }
+ }
+
+ // Close popup if one was open
+ if (popupRef.current) {
+ setPopup(null);
+ return;
+ }
+
const cartesian = viewer.camera.pickEllipsoid(
click.position,
viewer.scene.globe.ellipsoid
@@ -395,59 +501,50 @@ export function GlobeViewer() {
});
toRemove.forEach((entity) => viewer.entities.remove(entity));
- // Add active user markers
+ // Add active user pin markers
activeUsers.forEach((user) => {
- // Determine if this is the current user
- const isCurrentUser = user.id === currentUserId || user.distinctId === currentUserId;
+ // Skip users without a real location (0,0 = not yet geolocated)
+ if (user.location.latitude === 0 && user.location.longitude === 0) return;
- // Use different colors for current user vs other users
- // Current user: green, Online users: purple, Offline users: gray
- let markerColor: string;
- if (isCurrentUser) {
- markerColor = '#22c55e'; // Green for current user
- } else if (user.isOnline) {
- markerColor = '#a855f7'; // Purple for online users
- } else {
- markerColor = '#6b7280'; // Gray for offline users
- }
-
- // Create user label
+ const isCurrentUser = user.id === currentUserId || user.distinctId === currentUserId;
+ const color = getUserMarkerColor(user.id, user.distinctId, user.isOnline, currentUserId);
const displayName = user.name || user.email?.split('@')[0] || 'Anonymous';
- const statusText = isCurrentUser ? 'You' : user.isOnline ? 'Online' : 'Offline';
+ const initial = displayName.charAt(0);
viewer.entities.add({
id: `active-user-${user.id}`,
position: Cartesian3.fromDegrees(
user.location.longitude,
- user.location.latitude
+ user.location.latitude,
),
- point: {
- pixelSize: isCurrentUser ? 14 : 10,
- color: Color.fromCssColorString(markerColor),
- outlineColor: Color.WHITE,
- outlineWidth: 2,
+ billboard: {
+ image: createPinMarker(initial, color, isCurrentUser),
+ verticalOrigin: VerticalOrigin.BOTTOM,
+ width: isCurrentUser ? 46 : 38,
+ height: isCurrentUser ? 58 : 48,
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: {
text: displayName,
- font: '12px sans-serif',
+ font: '13px system-ui, -apple-system, sans-serif',
fillColor: Color.WHITE,
outlineColor: Color.BLACK,
- outlineWidth: 2,
+ outlineWidth: 3,
style: 2, // FILL_AND_OUTLINE
- verticalOrigin: 1, // BOTTOM
- pixelOffset: new Cartesian2(0, -16),
- show: false, // Will show on hover via billboard picking
+ verticalOrigin: VerticalOrigin.BOTTOM,
+ pixelOffset: new Cartesian2(0, -(isCurrentUser ? 62 : 52)),
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
name: displayName,
- description: `<div style="padding: 8px;">
- <strong>${displayName}</strong><br/>
- <span style="color: ${markerColor};">${statusText}</span><br/>
- <small>Last seen: ${new Date(user.lastSeen).toLocaleString()}</small>
- </div>`,
});
});
}, [activeUsers, currentUserId, isReady]);
+ // Close popup if the selected user no longer exists
+ useEffect(() => {
+ if (popup && !popupUser) setPopup(null);
+ }, [popup, popupUser]);
+
return (
<div className="fixed inset-0 pt-14">
<div
@@ -462,6 +559,128 @@ export function GlobeViewer() {
<CoordinateDisplay />
<CameraControls />
{isMeasuring && <MeasurementOverlay />}
+
+ {/* User marker info popup */}
+ {popup && popupUser && (() => {
+ const isNearTop = popup.screenY < 220;
+ const isCurrent =
+ popupUser.id === currentUserId ||
+ popupUser.distinctId === currentUserId;
+ const color = getUserMarkerColor(
+ popupUser.id,
+ popupUser.distinctId,
+ popupUser.isOnline,
+ currentUserId,
+ );
+ const displayName =
+ popupUser.name ||
+ popupUser.email?.split('@')[0] ||
+ 'Anonymous';
+
+ return (
+ <div
+ className="absolute z-50"
+ style={{
+ left: popup.screenX,
+ top: popup.screenY,
+ transform: isNearTop
+ ? 'translate(-50%, 16px)'
+ : 'translate(-50%, calc(-100% - 16px))',
+ }}
+ >
+ {/* Caret pointing toward marker (top or bottom) */}
+ {isNearTop && (
+ <div className="flex justify-center mb-[-1px]">
+ <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-[rgba(15,23,42,0.88)]" />
+ </div>
+ )}
+
+ {/* Card */}
+ <div className="w-72 bg-slate-900/[0.88] backdrop-blur-xl rounded-2xl border border-white/[0.08] shadow-2xl overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center gap-3 p-4">
+ <div
+ className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold text-sm shrink-0 shadow-lg"
+ style={{ backgroundColor: color }}
+ >
+ {displayName.charAt(0).toUpperCase()}
+ </div>
+ <div className="flex-1 min-w-0">
+ <p className="text-white font-medium text-sm truncate">
+ {displayName}
+ {isCurrent && (
+ <span className="text-green-400 text-xs ml-1.5 font-normal">
+ (You)
+ </span>
+ )}
+ </p>
+ <div className="flex items-center gap-1.5 mt-0.5">
+ <span
+ className={`w-1.5 h-1.5 rounded-full ${
+ popupUser.isOnline
+ ? 'bg-green-400 shadow-[0_0_4px_rgba(74,222,128,0.6)]'
+ : 'bg-gray-500'
+ }`}
+ />
+ <span className="text-[11px] text-white/50">
+ {isCurrent
+ ? 'You'
+ : popupUser.isOnline
+ ? 'Online'
+ : 'Offline'}
+ </span>
+ </div>
+ </div>
+ <button
+ onClick={() => setPopup(null)}
+ className="text-white/30 hover:text-white/70 transition-colors p-1.5 rounded-lg hover:bg-white/[0.06]"
+ >
+ <X className="w-4 h-4" />
+ </button>
+ </div>
+
+ {/* Divider */}
+ <div className="border-t border-white/[0.06] mx-4" />
+
+ {/* Details */}
+ <div className="p-4 space-y-2.5">
+ {popupUser.email && (
+ <div className="flex items-center gap-2.5">
+ <Mail className="w-3.5 h-3.5 text-white/30 shrink-0" />
+ <span className="text-xs text-white/60 truncate">
+ {popupUser.email}
+ </span>
+ </div>
+ )}
+ <div className="flex items-center gap-2.5">
+ <MapPin className="w-3.5 h-3.5 text-white/30 shrink-0" />
+ <span className="text-xs text-white/60">
+ {formatCoordinate(popupUser.location.latitude, 'lat')}
+ ,{' '}
+ {formatCoordinate(
+ popupUser.location.longitude,
+ 'lon',
+ )}
+ </span>
+ </div>
+ <div className="flex items-center gap-2.5">
+ <Clock className="w-3.5 h-3.5 text-white/30 shrink-0" />
+ <span className="text-xs text-white/60">
+ {new Date(popupUser.lastSeen).toLocaleString()}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* Caret at bottom when popup is above marker */}
+ {!isNearTop && (
+ <div className="flex justify-center mt-[-1px]">
+ <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-[rgba(15,23,42,0.88)]" />
+ </div>
+ )}
+ </div>
+ );
+ })()}
</>
)}
</div>
diff --git a/components/providers/AnalyticsProvider.tsx b/components/providers/AnalyticsProvider.tsx
index 151b6a4..eb666dc 100644
--- a/components/providers/AnalyticsProvider.tsx
+++ b/components/providers/AnalyticsProvider.tsx
@@ -72,6 +72,8 @@ export function AnalyticsProvider({ children }: AnalyticsProviderProps) {
latitude: location.coordinates.latitude,
longitude: location.coordinates.longitude,
accuracy: location.accuracy,
+ name: user?.name,
+ email: user?.email,
}),
});
@@ -101,9 +103,23 @@ export function AnalyticsProvider({ children }: AnalyticsProviderProps) {
identifyUser(user);
setCurrentUserId(user.id);
- // Get initial location
+ // Add user to local store immediately so they appear on the globe
+ // even before geolocation resolves. Location will be updated once available.
+ addUser({
+ id: user.id,
+ distinctId: user.id,
+ email: user.email,
+ name: user.name,
+ location: { latitude: 0, longitude: 0 },
+ lastSeen: new Date().toISOString(),
+ isOnline: true,
+ });
+
+ // Get initial location and update the user entry
getUserLocation().then((result) => {
if (result.success) {
+ // Force the initial update through (bypass rate limiter)
+ lastLocationUpdateRef.current = 0;
handleLocationUpdate(result);
}
});
diff --git a/lib/composio.ts b/lib/composio.ts
index 80dc59e..9357b40 100644
--- a/lib/composio.ts
+++ b/lib/composio.ts
@@ -1,25 +1,33 @@
/**
- * Composio Integration for PostHog Analytics
+ * Composio + PostHog Integration for TerraView
*
- * This module provides server-side integration with PostHog through Composio.
- * Composio is used to manage the PostHog connection and execute analytics operations.
+ * Architecture:
+ * - Event capture & user identification → Direct PostHog Capture API
+ * (PostHog's capture endpoint is purpose-built for ingestion; Composio's
+ * PostHog toolkit wraps the management/query REST API, not the capture API.)
*
- * The integration supports:
- * - Capturing events with user context
- * - Updating user properties
- * - Querying user data for visualization
+ * - Querying persons, events, dashboards → Composio PostHog toolkit
+ * (Composio manages the PostHog connected account and provides typed tool
+ * access to PostHog's REST API for data retrieval.)
+ *
+ * Key concept: The `COMPOSIO_USER_ID` env var identifies the entity in Composio
+ * that owns the connected PostHog account. This is NOT the same as the app user
+ * or the PostHog distinct_id. It's the Composio entity (often "default" or an
+ * email) that was used when the PostHog connection was set up on the Composio
+ * dashboard.
*/
-import { Composio, OpenAIProvider } from '@composio/core';
-import OpenAI from 'openai';
-import type { Coordinates } from '@/types';
+import { Composio, OpenAIProvider } from "@composio/core";
+import OpenAI from "openai";
+import type { Coordinates } from "@/types";
// Environment configuration
-const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || '';
-const OPENAI_API_KEY = process.env.OPENAI_API_KEY || '';
-const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || '';
-const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID || '';
-const POSTHOG_HOST = process.env.POSTHOG_HOST || 'https://us.i.posthog.com';
+const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || "";
+const COMPOSIO_USER_ID = process.env.COMPOSIO_USER_ID || "default";
+const OPENAI_API_KEY = process.env.OPENAI_API_KEY || "";
+const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || "";
+const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID || "";
+const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
// Active user data structure
export interface ActiveUser {
@@ -35,15 +43,7 @@ export interface ActiveUser {
// PostHog API response types
interface PostHogPerson {
distinct_ids: string[];
- properties: {
- email?: string;
- name?: string;
- last_known_latitude?: number;
- last_known_longitude?: number;
- location_updated_at?: string;
- $geoip_latitude?: number;
- $geoip_longitude?: number;
- };
+ properties: Record<string, unknown>;
}
interface PostHogPersonsResponse {
@@ -51,15 +51,16 @@ interface PostHogPersonsResponse {
next?: string;
}
+// ---------- Composio Client for PostHog Queries ----------
+
/**
- * ComposioPostHogClient manages the connection to Composio and provides
- * methods to interact with PostHog through Composio's tool system.
+ * Wraps the Composio SDK to query PostHog data via the management API.
+ * All calls use COMPOSIO_USER_ID as the entity that owns the connected account.
*/
export class ComposioPostHogClient {
private composio: Composio | null = null;
private openai: OpenAI | null = null;
- private provider: OpenAIProvider | null = null;
- private isInitialized = false;
+ private _isInitialized = false;
constructor() {
this.initializeClients();
@@ -67,392 +68,404 @@ export class ComposioPostHogClient {
private initializeClients() {
if (!COMPOSIO_API_KEY) {
- console.warn('Composio API key not configured. Composio features will be disabled.');
+ console.warn(
+ "Composio API key not configured. Composio features disabled.",
+ );
return;
}
-
if (!OPENAI_API_KEY) {
- console.warn('OpenAI API key not configured. AI-powered features will be disabled.');
+ console.warn(
+ "OpenAI API key not configured. Composio query features disabled.",
+ );
return;
}
try {
- // Initialize OpenAI client
- this.openai = new OpenAI({
- apiKey: OPENAI_API_KEY,
- });
+ this.openai = new OpenAI({ apiKey: OPENAI_API_KEY });
- // Initialize Composio with OpenAI provider from core
- this.provider = new OpenAIProvider();
+ const provider = new OpenAIProvider();
this.composio = new Composio({
apiKey: COMPOSIO_API_KEY,
- provider: this.provider,
+ provider,
});
-
- this.isInitialized = true;
+ this._isInitialized = true;
} catch (error) {
- console.error('Failed to initialize Composio client:', error);
+ console.error("Failed to initialize Composio client:", error);
}
}
+ get isInitialized(): boolean {
+ return this._isInitialized;
+ }
+
/**
- * Get available PostHog tools from Composio
+ * Get PostHog tools from Composio for the connected entity.
*/
- async getPostHogTools(userId: string) {
- if (!this.composio || !this.isInitialized) {
- return [];
- }
-
+ async getPostHogTools() {
+ if (!this.composio) return [];
try {
- const tools = await this.composio.tools.get(userId, {
- toolkits: ['posthog'],
+ return await this.composio.tools.get(COMPOSIO_USER_ID, {
+ toolkits: ["POSTHOG"],
});
- return tools;
} catch (error) {
- console.error('Failed to get PostHog tools:', error);
+ console.error("Failed to get PostHog tools:", error);
return [];
}
}
/**
- * Execute a PostHog action using Composio and OpenAI
+ * Execute a PostHog management action via Composio.
+ * Uses OpenAI to decide which tool(s) to call, then executes them through
+ * Composio which holds the authenticated PostHog connection.
*/
- async executePostHogAction(
- userId: string,
+ async executeAction(
action: string,
- parameters: Record<string, unknown>
+ parameters: Record<string, unknown>,
): Promise<unknown> {
- if (!this.composio || !this.openai || !this.provider || !this.isInitialized) {
- console.warn('Composio not initialized, skipping action:', action);
+ if (!this.composio || !this.openai || !this._isInitialized) {
return null;
}
try {
- // Get PostHog tools
- const tools = await this.getPostHogTools(userId);
+ const tools = await this.getPostHogTools();
+ if (tools.length === 0) return null;
- if (tools.length === 0) {
- console.warn('No PostHog tools available');
- return null;
- }
-
- // Create a prompt for the action
const prompt = `Execute the PostHog action "${action}" with these parameters: ${JSON.stringify(parameters)}`;
- // Use OpenAI Chat Completions API with Composio tools
const response = await this.openai.chat.completions.create({
- model: 'gpt-4o-mini',
+ model: "gpt-4o-mini",
messages: [
{
- role: 'system',
- content: 'You are an analytics assistant. Execute the requested PostHog action using the available tools.',
- },
- {
- role: 'user',
- content: prompt,
+ role: "system",
+ content:
+ "You are an analytics assistant. Execute the requested PostHog action using the available tools. Always include the project_id parameter when required.",
},
+ { role: "user", content: prompt },
],
tools: tools as unknown as OpenAI.ChatCompletionTool[],
- tool_choice: 'auto',
+ tool_choice: "auto",
});
- // Handle tool calls if any
const toolCalls = response.choices[0]?.message?.tool_calls;
- if (toolCalls && toolCalls.length > 0) {
- // Execute each tool call using the composio SDK
- const results = await Promise.all(
- toolCalls.map(async (toolCall) => {
- try {
- // Use composio to execute the tool directly
- const result = await this.composio!.tools.execute(toolCall.function.name, {
- userId,
- arguments: JSON.parse(toolCall.function.arguments),
- dangerouslySkipVersionCheck: true, // For simplicity in this demo
- });
- return result;
- } catch (err) {
- console.error('Tool execution error:', err);
- return null;
- }
- })
- );
- return results;
+ if (!toolCalls || toolCalls.length === 0) {
+ return response.choices[0]?.message?.content;
}
- return response.choices[0]?.message?.content;
+ // Execute each tool call via Composio using the correct entity
+ const results = await Promise.all(
+ toolCalls.map(async (tc) => {
+ try {
+ return await this.composio!.tools.execute(tc.function.name, {
+ userId: COMPOSIO_USER_ID,
+ arguments: JSON.parse(tc.function.arguments),
+ dangerouslySkipVersionCheck: true,
+ });
+ } catch (err) {
+ console.error(`Tool execution error [${tc.function.name}]:`, err);
+ return null;
+ }
+ }),
+ );
+ return results;
} catch (error) {
- console.error('Failed to execute PostHog action:', error);
+ console.error("Failed to execute Composio action:", error);
return null;
}
}
+}
- /**
- * Check if Composio is properly initialized
- */
- isReady(): boolean {
- return this.isInitialized;
+// ---------- Direct PostHog Capture API ----------
+
+/**
+ * Capture an event directly to PostHog's ingestion endpoint.
+ * This does NOT go through Composio because PostHog's capture API is separate
+ * from the management REST API that Composio wraps.
+ */
+async function posthogCapture(
+ distinctId: string,
+ event: string,
+ properties?: Record<string, unknown>,
+): Promise<boolean> {
+ if (!POSTHOG_API_KEY) {
+ console.warn("PostHog API key not configured");
+ return false;
+ }
+
+ try {
+ const res = await fetch(`${POSTHOG_HOST}/capture/`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ api_key: POSTHOG_API_KEY,
+ distinct_id: distinctId,
+ event,
+ properties: {
+ ...properties,
+ $lib: "terraview-server",
+ timestamp: new Date().toISOString(),
+ },
+ }),
+ });
+ return res.ok;
+ } catch (error) {
+ console.error("PostHog capture failed:", error);
+ return false;
}
}
/**
- * PostHogComposioService provides high-level methods for PostHog operations
- * using the Composio client for tool management.
+ * Identify / set person properties directly via PostHog's capture endpoint.
+ */
+async function posthogIdentify(
+ distinctId: string,
+ properties: Record<string, unknown>,
+): Promise<boolean> {
+ if (!POSTHOG_API_KEY) return false;
+
+ try {
+ const res = await fetch(`${POSTHOG_HOST}/capture/`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ api_key: POSTHOG_API_KEY,
+ distinct_id: distinctId,
+ event: "$identify",
+ properties: { $lib: "terraview-server" },
+ $set: properties,
+ }),
+ });
+ return res.ok;
+ } catch (error) {
+ console.error("PostHog identify failed:", error);
+ return false;
+ }
+}
+
+// ---------- High-Level Service ----------
+
+/**
+ * PostHogComposioService combines:
+ * - Direct PostHog capture API for event ingestion
+ * - Composio for querying/managing PostHog data
*/
export class PostHogComposioService {
private composioClient: ComposioPostHogClient;
- private posthogApiKey: string;
- private posthogHost: string;
- private projectId: string;
constructor() {
this.composioClient = new ComposioPostHogClient();
- this.posthogApiKey = POSTHOG_API_KEY;
- this.posthogHost = POSTHOG_HOST;
- this.projectId = POSTHOG_PROJECT_ID;
}
- /**
- * Initialize the service for a specific user
- */
- async initialize(userId: string): Promise<void> {
- // Composio initializes lazily, nothing to do here
- }
-
- /**
- * Capture an event via Composio/PostHog
- */
+ /** Capture an event for a user. */
async captureEvent(
distinctId: string,
event: string,
- properties?: Record<string, unknown>
+ properties?: Record<string, unknown>,
): Promise<boolean> {
- // First, try using Composio if available
- if (this.composioClient.isReady()) {
- const result = await this.composioClient.executePostHogAction(
- distinctId,
- 'capture_event',
- {
- distinct_id: distinctId,
- event,
- properties: {
- ...properties,
- $lib: 'composio-terraview',
- timestamp: new Date().toISOString(),
- },
- }
- );
-
- if (result !== null) {
- return true;
- }
- }
+ return posthogCapture(distinctId, event, properties);
+ }
- // Fallback to direct PostHog API
- return this.captureEventDirect(distinctId, event, properties);
+ /** Set person properties for a user. */
+ async setUserProperties(
+ distinctId: string,
+ properties: Record<string, unknown>,
+ ): Promise<boolean> {
+ return posthogIdentify(distinctId, properties);
}
- /**
- * Direct PostHog API call for event capture (fallback)
- */
- private async captureEventDirect(
+ /** Capture a location_update event and set location as person properties. */
+ async updateUserLocation(
distinctId: string,
- event: string,
- properties?: Record<string, unknown>
+ latitude: number,
+ longitude: number,
+ accuracy?: number,
): Promise<boolean> {
- if (!this.posthogApiKey) {
- console.warn('PostHog API key not configured');
- return false;
+ const props: Record<string, unknown> = {
+ last_known_latitude: latitude,
+ last_known_longitude: longitude,
+ location_updated_at: new Date().toISOString(),
+ };
+ if (accuracy !== undefined) {
+ props.location_accuracy_meters = accuracy;
}
- try {
- const response = await fetch(`${this.posthogHost}/capture/`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- api_key: this.posthogApiKey,
- distinct_id: distinctId,
- event,
- properties: {
- ...properties,
- $lib: 'composio-terraview',
- timestamp: new Date().toISOString(),
- },
- }),
- });
-
- return response.ok;
- } catch (error) {
- console.error('Failed to capture event:', error);
- return false;
- }
+ // Fire both in parallel
+ const [captureOk, identifyOk] = await Promise.all([
+ posthogCapture(distinctId, "location_update", {
+ latitude,
+ longitude,
+ accuracy,
+ source: "browser_geolocation",
+ }),
+ posthogIdentify(distinctId, props),
+ ]);
+
+ return captureOk || identifyOk;
}
/**
- * Update user properties via Composio/PostHog
+ * Fetch active users with location data.
+ * Strategy:
+ * 1. Try Composio (queries PostHog management API via connected account)
+ * 2. Fallback to direct PostHog REST API with the personal API key
*/
- async setUserProperties(
- distinctId: string,
- properties: Record<string, unknown>
- ): Promise<boolean> {
- // Try Composio if available
- if (this.composioClient.isReady()) {
- const result = await this.composioClient.executePostHogAction(
- distinctId,
- 'identify_user',
- {
- distinct_id: distinctId,
- properties,
- }
- );
+ async getActiveUsers(limit = 100): Promise<ActiveUser[]> {
+ // Attempt via Composio first
+ if (this.composioClient.isInitialized && POSTHOG_PROJECT_ID) {
+ try {
+ const result = await this.composioClient.executeAction(
+ "list_persons_with_location",
+ {
+ project_id: POSTHOG_PROJECT_ID,
+ properties: JSON.stringify([
+ {
+ key: "last_known_latitude",
+ type: "person",
+ operator: "is_set",
+ },
+ ]),
+ limit,
+ },
+ );
- if (result !== null) {
- return true;
+ // If Composio returned usable data, parse it
+ if (result && Array.isArray(result) && result.length > 0) {
+ const parsed = this.parsePersonsResult(result);
+ if (parsed.length > 0) return parsed;
+ }
+ } catch (error) {
+ console.error(
+ "Composio active users query failed, using fallback:",
+ error,
+ );
}
}
- // Fallback to direct API
- return this.setUserPropertiesDirect(distinctId, properties);
+ // Fallback: direct PostHog REST API
+ return this.getActiveUsersDirect(limit);
}
- /**
- * Direct PostHog API call for setting user properties (fallback)
- */
- private async setUserPropertiesDirect(
- distinctId: string,
- properties: Record<string, unknown>
- ): Promise<boolean> {
- if (!this.posthogApiKey) {
- return false;
- }
+ /** Parse Composio tool execution results into ActiveUser[]. */
+ private parsePersonsResult(results: unknown[]): ActiveUser[] {
+ const users: ActiveUser[] = [];
- try {
- const response = await fetch(`${this.posthogHost}/capture/`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- api_key: this.posthogApiKey,
- distinct_id: distinctId,
- event: '$identify',
- properties,
- $set: properties,
- }),
- });
+ for (const r of results) {
+ if (!r || typeof r !== "object") continue;
- return response.ok;
- } catch (error) {
- console.error('Failed to set user properties:', error);
- return false;
- }
- }
+ // The result may be nested; try to find a results array
+ const data = r as Record<string, unknown>;
+ let persons: PostHogPerson[] = [];
- /**
- * Update user location via Composio/PostHog
- */
- async updateUserLocation(
- distinctId: string,
- latitude: number,
- longitude: number,
- accuracy?: number
- ): Promise<boolean> {
- const locationProperties: Record<string, unknown> = {
- last_known_latitude: latitude,
- last_known_longitude: longitude,
- location_updated_at: new Date().toISOString(),
- };
+ if (Array.isArray(data.results)) {
+ persons = data.results as PostHogPerson[];
+ } else if (data.data && typeof data.data === "object") {
+ const inner = data.data as Record<string, unknown>;
+ if (Array.isArray(inner.results)) {
+ persons = inner.results as PostHogPerson[];
+ }
+ }
- if (accuracy !== undefined) {
- locationProperties.location_accuracy_meters = accuracy;
+ for (const person of persons) {
+ const props = person.properties || {};
+ const lat =
+ (props.last_known_latitude as number) ??
+ (props.$geoip_latitude as number);
+ const lon =
+ (props.last_known_longitude as number) ??
+ (props.$geoip_longitude as number);
+
+ if (lat === undefined || lon === undefined) continue;
+
+ const lastSeen =
+ (props.location_updated_at as string) || new Date().toISOString();
+ const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+
+ users.push({
+ id: person.distinct_ids?.[0] || "unknown",
+ distinctId: person.distinct_ids?.[0] || "unknown",
+ email: props.email as string | undefined,
+ name: props.name as string | undefined,
+ location: { latitude: lat, longitude: lon },
+ lastSeen,
+ isOnline: lastSeen > hourAgo,
+ });
+ }
}
- // Capture location event
- await this.captureEvent(distinctId, 'location_update', {
- latitude,
- longitude,
- accuracy,
- source: 'browser_geolocation',
- });
-
- // Update user properties
- return this.setUserProperties(distinctId, locationProperties);
+ return users;
}
- /**
- * Fetch active users with their locations from PostHog
- * This uses the PostHog API to query persons with recent location data
- */
- async getActiveUsers(limit = 100): Promise<ActiveUser[]> {
- if (!this.posthogApiKey || !this.projectId) {
- console.warn('PostHog API key or project ID not configured');
+ /** Direct PostHog REST API fallback for fetching persons. */
+ private async getActiveUsersDirect(limit: number): Promise<ActiveUser[]> {
+ if (!POSTHOG_API_KEY || !POSTHOG_PROJECT_ID) {
+ console.warn("PostHog API key or project ID not configured");
return [];
}
try {
- // Query persons with location data from PostHog
- const response = await fetch(
- `${this.posthogHost}/api/projects/${this.projectId}/persons/?properties=[{"key":"last_known_latitude","type":"person","operator":"is_set"}]&limit=${limit}`,
- {
- headers: {
- Authorization: `Bearer ${this.posthogApiKey}`,
- },
- }
+ const url = new URL(
+ `/api/projects/${POSTHOG_PROJECT_ID}/persons/`,
+ POSTHOG_HOST,
+ );
+ url.searchParams.set(
+ "properties",
+ JSON.stringify([
+ { key: "last_known_latitude", type: "person", operator: "is_set" },
+ ]),
);
+ url.searchParams.set("limit", String(limit));
- if (!response.ok) {
- throw new Error(`Failed to fetch persons: ${response.statusText}`);
+ const res = await fetch(url.toString(), {
+ headers: { Authorization: `Bearer ${POSTHOG_API_KEY}` },
+ });
+
+ if (!res.ok) {
+ throw new Error(`PostHog API error: ${res.status} ${res.statusText}`);
}
- const data: PostHogPersonsResponse = await response.json();
+ const data: PostHogPersonsResponse = await res.json();
- // Transform to ActiveUser format
- const activeUsers: ActiveUser[] = data.results
- .filter((person) => {
+ return data.results
+ .filter((p) => {
const lat =
- person.properties.last_known_latitude ??
- person.properties.$geoip_latitude;
+ (p.properties.last_known_latitude as number) ??
+ (p.properties.$geoip_latitude as number);
const lon =
- person.properties.last_known_longitude ??
- person.properties.$geoip_longitude;
+ (p.properties.last_known_longitude as number) ??
+ (p.properties.$geoip_longitude as number);
return lat !== undefined && lon !== undefined;
})
- .map((person) => {
+ .map((p) => {
const lat =
- person.properties.last_known_latitude ??
- person.properties.$geoip_latitude ??
+ (p.properties.last_known_latitude as number) ??
+ (p.properties.$geoip_latitude as number) ??
0;
const lon =
- person.properties.last_known_longitude ??
- person.properties.$geoip_longitude ??
+ (p.properties.last_known_longitude as number) ??
+ (p.properties.$geoip_longitude as number) ??
0;
- const lastSeen = person.properties.location_updated_at || new Date().toISOString();
+ const lastSeen =
+ (p.properties.location_updated_at as string) ||
+ new Date().toISOString();
const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
return {
- id: person.distinct_ids[0],
- distinctId: person.distinct_ids[0],
- email: person.properties.email,
- name: person.properties.name,
- location: {
- latitude: lat,
- longitude: lon,
- },
+ id: p.distinct_ids[0],
+ distinctId: p.distinct_ids[0],
+ email: p.properties.email as string | undefined,
+ name: p.properties.name as string | undefined,
+ location: { latitude: lat, longitude: lon },
lastSeen,
isOnline: lastSeen > hourAgo,
};
});
-
- return activeUsers;
} catch (error) {
- console.error('Failed to fetch active users:', error);
+ console.error("Failed to fetch active users (direct):", error);
return [];
}
}
}
-// Singleton instance for the service
+// Singleton
let serviceInstance: PostHogComposioService | null = null;
export function getPostHogComposioService(): PostHogComposioService {
diff --git a/store/useActiveUsersStore.ts b/store/useActiveUsersStore.ts
index 1e13e06..8049ac2 100644
--- a/store/useActiveUsersStore.ts
+++ b/store/useActiveUsersStore.ts
@@ -73,7 +73,7 @@ export const useActiveUsersStore = create<ActiveUsersState>((set, get) => ({
setError: (error) => set({ error }),
fetchUsers: async () => {
- const { setUsers, setLoading, setError } = get();
+ const { setLoading, setError } = get();
setLoading(true);
setError(null);
@@ -85,7 +85,29 @@ export const useActiveUsersStore = create<ActiveUsersState>((set, get) => ({
}
const data = await response.json();
- setUsers(data.users || []);
+ const serverUsers: ActiveUser[] = data.users || [];
+
+ // Merge server users with local users instead of replacing.
+ // Local users (added via addUser on login/location update) should
+ // persist even if the server hasn't indexed them yet.
+ set((state) => {
+ const merged = new Map<string, ActiveUser>();
+
+ // Start with existing local users
+ for (const u of state.users) {
+ merged.set(u.id, u);
+ }
+
+ // Layer server users on top (server is source of truth for data it has)
+ for (const u of serverUsers) {
+ merged.set(u.id, u);
+ }
+
+ return {
+ users: Array.from(merged.values()),
+ lastFetched: new Date().toISOString(),
+ };
+ });
} catch (error) {
console.error('Failed to fetch active users:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch users');
--
2.52.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment