From b33d474fd1f66ca79de690d204fadb2573624c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 24 Mar 2026 21:55:55 +0100 Subject: [PATCH 01/18] wip --- apps/api/package.json | 1 + apps/api/src/controllers/export.controller.ts | 4 +- apps/api/src/controllers/manage.controller.ts | 12 +- apps/api/src/controllers/track.controller.ts | 2 +- apps/api/src/index.ts | 3 + apps/api/src/routes/mcp.router.ts | 67 +++++ apps/api/src/utils/graceful-shutdown.ts | 17 +- packages/importer/src/providers/mixpanel.ts | 4 +- packages/mcp/index.ts | 5 + packages/mcp/package.json | 26 ++ packages/mcp/src/auth.ts | 140 +++++++++ packages/mcp/src/handler.ts | 104 +++++++ packages/mcp/src/server.ts | 30 ++ packages/mcp/src/session-manager.ts | 109 +++++++ .../mcp/src/tools/analytics/active-users.ts | 54 ++++ .../mcp/src/tools/analytics/engagement.ts | 51 ++++ .../mcp/src/tools/analytics/event-names.ts | 44 +++ packages/mcp/src/tools/analytics/events.ts | 160 +++++++++++ packages/mcp/src/tools/analytics/funnel.ts | 137 +++++++++ packages/mcp/src/tools/analytics/groups.ts | 97 +++++++ packages/mcp/src/tools/analytics/overview.ts | 78 +++++ .../src/tools/analytics/page-performance.ts | 85 ++++++ packages/mcp/src/tools/analytics/pages.ts | 87 ++++++ .../src/tools/analytics/profile-metrics.ts | 48 ++++ packages/mcp/src/tools/analytics/profiles.ts | 271 ++++++++++++++++++ .../src/tools/analytics/property-values.ts | 79 +++++ packages/mcp/src/tools/analytics/retention.ts | 26 ++ packages/mcp/src/tools/analytics/sessions.ts | 130 +++++++++ packages/mcp/src/tools/analytics/traffic.ts | 132 +++++++++ packages/mcp/src/tools/analytics/user-flow.ts | 100 +++++++ packages/mcp/src/tools/gsc/cannibalization.ts | 30 ++ packages/mcp/src/tools/gsc/overview.ts | 62 ++++ packages/mcp/src/tools/gsc/pages.ts | 57 ++++ packages/mcp/src/tools/gsc/queries.ts | 166 +++++++++++ packages/mcp/src/tools/index.ts | 61 ++++ packages/mcp/src/tools/shared.ts | 93 ++++++ packages/mcp/tsconfig.json | 10 + packages/trpc/src/trpc.ts | 4 +- packages/validation/src/index.ts | 24 +- packages/validation/src/track.validation.ts | 4 +- pnpm-lock.yaml | 254 ++++++++++++---- pnpm-workspace.yaml | 2 +- 42 files changed, 2784 insertions(+), 86 deletions(-) create mode 100644 apps/api/src/routes/mcp.router.ts create mode 100644 packages/mcp/index.ts create mode 100644 packages/mcp/package.json create mode 100644 packages/mcp/src/auth.ts create mode 100644 packages/mcp/src/handler.ts create mode 100644 packages/mcp/src/server.ts create mode 100644 packages/mcp/src/session-manager.ts create mode 100644 packages/mcp/src/tools/analytics/active-users.ts create mode 100644 packages/mcp/src/tools/analytics/engagement.ts create mode 100644 packages/mcp/src/tools/analytics/event-names.ts create mode 100644 packages/mcp/src/tools/analytics/events.ts create mode 100644 packages/mcp/src/tools/analytics/funnel.ts create mode 100644 packages/mcp/src/tools/analytics/groups.ts create mode 100644 packages/mcp/src/tools/analytics/overview.ts create mode 100644 packages/mcp/src/tools/analytics/page-performance.ts create mode 100644 packages/mcp/src/tools/analytics/pages.ts create mode 100644 packages/mcp/src/tools/analytics/profile-metrics.ts create mode 100644 packages/mcp/src/tools/analytics/profiles.ts create mode 100644 packages/mcp/src/tools/analytics/property-values.ts create mode 100644 packages/mcp/src/tools/analytics/retention.ts create mode 100644 packages/mcp/src/tools/analytics/sessions.ts create mode 100644 packages/mcp/src/tools/analytics/traffic.ts create mode 100644 packages/mcp/src/tools/analytics/user-flow.ts create mode 100644 packages/mcp/src/tools/gsc/cannibalization.ts create mode 100644 packages/mcp/src/tools/gsc/overview.ts create mode 100644 packages/mcp/src/tools/gsc/pages.ts create mode 100644 packages/mcp/src/tools/gsc/queries.ts create mode 100644 packages/mcp/src/tools/index.ts create mode 100644 packages/mcp/src/tools/shared.ts create mode 100644 packages/mcp/tsconfig.json diff --git a/apps/api/package.json b/apps/api/package.json index 5f63bc5d9..e00b1d85a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,6 +21,7 @@ "@fastify/websocket": "^11.2.0", "@node-rs/argon2": "^2.0.2", "@openpanel/auth": "workspace:^", + "@openpanel/mcp": "workspace:*", "@openpanel/common": "workspace:*", "@openpanel/constants": "workspace:*", "@openpanel/db": "workspace:*", diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts index d568439ba..2dc8d4144 100644 --- a/apps/api/src/controllers/export.controller.ts +++ b/apps/api/src/controllers/export.controller.ts @@ -102,7 +102,7 @@ export async function events( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid query parameters', - details: query.error.errors, + details: query.error.issues, }); } @@ -195,7 +195,7 @@ export async function charts( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid query parameters', - details: query.error.errors, + details: query.error.issues, }); } diff --git a/apps/api/src/controllers/manage.controller.ts b/apps/api/src/controllers/manage.controller.ts index 1d162851a..8df3bee28 100644 --- a/apps/api/src/controllers/manage.controller.ts +++ b/apps/api/src/controllers/manage.controller.ts @@ -100,7 +100,7 @@ export async function createProject( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid request body', - details: parsed.error.errors, + details: parsed.error.issues, }); } @@ -170,7 +170,7 @@ export async function updateProject( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid request body', - details: parsed.error.errors, + details: parsed.error.issues, }); } @@ -320,7 +320,7 @@ export async function createClient( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid request body', - details: parsed.error.errors, + details: parsed.error.issues, }); } @@ -376,7 +376,7 @@ export async function updateClient( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid request body', - details: parsed.error.errors, + details: parsed.error.issues, }); } @@ -518,7 +518,7 @@ export async function createReference( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid request body', - details: parsed.error.errors, + details: parsed.error.issues, }); } @@ -561,7 +561,7 @@ export async function updateReference( return reply.status(400).send({ error: 'Bad Request', message: 'Invalid request body', - details: parsed.error.errors, + details: parsed.error.issues, }); } diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 8c7e6ceb1..42ef4879f 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -380,7 +380,7 @@ export async function handler( status: 400, error: 'Bad Request', message: 'Validation failed', - errors: validationResult.error.errors, + errors: validationResult.error.issues, }); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cb5cdece3..9c5a54a8c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -34,6 +34,7 @@ import { requestIdHook } from './hooks/request-id.hook'; import { requestLoggingHook } from './hooks/request-logging.hook'; import { timestampHook } from './hooks/timestamp.hook'; import aiRouter from './routes/ai.router'; +import mcpRouter, { mcpSessionManager } from './routes/mcp.router'; import eventRouter from './routes/event.router'; import exportRouter from './routes/export.router'; import gscCallbackRouter from './routes/gsc-callback.router'; @@ -94,6 +95,7 @@ const startServer = async () => { '/oauth', '/misc', '/ai', + '/mcp', ]; const isPrivatePath = corsPaths.some((path) => @@ -198,6 +200,7 @@ const startServer = async () => { instance.register(gscCallbackRouter, { prefix: '/gsc' }); instance.register(miscRouter, { prefix: '/misc' }); instance.register(aiRouter, { prefix: '/ai' }); + instance.register(mcpRouter, { prefix: '/mcp' }); }); // Public API diff --git a/apps/api/src/routes/mcp.router.ts b/apps/api/src/routes/mcp.router.ts new file mode 100644 index 000000000..5350eddd4 --- /dev/null +++ b/apps/api/src/routes/mcp.router.ts @@ -0,0 +1,67 @@ +import { + SessionManager, + handleMcpGet, + handleMcpPost, +} from '@openpanel/mcp'; +import type { FastifyPluginAsync } from 'fastify'; + +/** + * Singleton session manager — lives for the lifetime of the API process. + * Exported so graceful shutdown can clean it up. + */ +export const mcpSessionManager = new SessionManager(); + +const mcpRouter: FastifyPluginAsync = async (fastify) => { + /** + * POST /mcp + * + * Handles both session initialization (no Mcp-Session-Id header) and + * subsequent JSON-RPC messages within an existing session. + * + * First request: authenticate via ?token= query param or Authorization: Bearer. + * Subsequent requests: route by Mcp-Session-Id header. + */ + fastify.post('/', async (req, reply) => { + // Hand off full response control to the MCP transport + reply.hijack(); + await handleMcpPost( + mcpSessionManager, + req.raw, + reply.raw, + req.body, + req.query as Record, + ); + }); + + /** + * GET /mcp + * + * Establishes an SSE stream for server-to-client notifications. + * Requires Mcp-Session-Id header from a previously initialized session. + */ + fastify.get('/', async (req, reply) => { + reply.hijack(); + await handleMcpGet(mcpSessionManager, req.raw, reply.raw); + }); + + /** + * DELETE /mcp + * + * Explicitly close an MCP session and free its resources. + */ + fastify.delete('/', async (req, reply) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + return reply + .status(400) + .send({ error: 'Mcp-Session-Id header is required' }); + } + if (!mcpSessionManager.has(sessionId)) { + return reply.status(404).send({ error: 'Session not found' }); + } + await mcpSessionManager.close(sessionId); + return reply.status(200).send({ ok: true }); + }); +}; + +export default mcpRouter; diff --git a/apps/api/src/utils/graceful-shutdown.ts b/apps/api/src/utils/graceful-shutdown.ts index 276762ae6..ce0461eab 100644 --- a/apps/api/src/utils/graceful-shutdown.ts +++ b/apps/api/src/utils/graceful-shutdown.ts @@ -1,4 +1,5 @@ import { ch, db } from '@openpanel/db'; +import { mcpSessionManager } from '@/routes/mcp.router'; import { cronQueue, eventsGroupQueues, @@ -52,7 +53,15 @@ export async function shutdown( logger.error('Error closing Fastify server', error); } - // Step 4: Close database connections + // Step 4: Destroy MCP sessions + try { + await mcpSessionManager.destroy(); + logger.info('MCP sessions closed'); + } catch (error) { + logger.error('Error closing MCP sessions', error); + } + + // Step 6: Close database connections try { await db.$disconnect(); logger.info('Database connection closed'); @@ -60,7 +69,7 @@ export async function shutdown( logger.error('Error closing database connection', error); } - // Step 5: Close ClickHouse connections + // Step 7: Close ClickHouse connections try { await ch.close(); logger.info('ClickHouse connections closed'); @@ -68,7 +77,7 @@ export async function shutdown( logger.error('Error closing ClickHouse connections', error); } - // Step 6: Close Bull queues (graceful shutdown of queue state) + // Step 8: Close Bull queues (graceful shutdown of queue state) try { await Promise.all([ ...eventsGroupQueues.map((queue) => queue.close()), @@ -82,7 +91,7 @@ export async function shutdown( logger.error('Error closing queue state', error); } - // Step 7: Close Redis connections + // Step 9: Close Redis connections try { const redisConnections = [ getRedisCache(), diff --git a/packages/importer/src/providers/mixpanel.ts b/packages/importer/src/providers/mixpanel.ts index 43cf6eb82..0b4a02b02 100644 --- a/packages/importer/src/providers/mixpanel.ts +++ b/packages/importer/src/providers/mixpanel.ts @@ -15,7 +15,7 @@ import { BaseImportProvider } from '../base-provider'; export const zMixpanelRawEvent = z.object({ event: z.string(), - properties: z.record(z.unknown()), + properties: z.record(z.string(), z.unknown()), }); export type MixpanelRawEvent = z.infer; @@ -23,7 +23,7 @@ export type MixpanelRawEvent = z.infer; /** Engage API profile: https://docs.mixpanel.com/docs/export-methods#exporting-profiles */ export const zMixpanelRawProfile = z.object({ $distinct_id: z.union([z.string(), z.number()]), - $properties: z.record(z.unknown()).optional().default({}), + $properties: z.record(z.string(), z.unknown()).optional().default({}), }); export type MixpanelRawProfile = z.infer; diff --git a/packages/mcp/index.ts b/packages/mcp/index.ts new file mode 100644 index 000000000..5f1afe12b --- /dev/null +++ b/packages/mcp/index.ts @@ -0,0 +1,5 @@ +export { createMcpServer } from './src/server'; +export { SessionManager } from './src/session-manager'; +export { authenticateToken, McpAuthError } from './src/auth'; +export { handleMcpGet, handleMcpPost } from './src/handler'; +export type { McpAuthContext } from './src/auth'; diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 000000000..be1fdecaf --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,26 @@ +{ + "name": "@openpanel/mcp", + "version": "0.0.1", + "type": "module", + "main": "index.ts", + "exports": { + ".": "./index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@openpanel/common": "workspace:*", + "@openpanel/db": "workspace:*", + "@openpanel/logger": "workspace:*", + "@openpanel/redis": "workspace:*", + "@openpanel/validation": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@openpanel/tsconfig": "workspace:*", + "@types/node": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts new file mode 100644 index 000000000..54fa68402 --- /dev/null +++ b/packages/mcp/src/auth.ts @@ -0,0 +1,140 @@ +import { verifyPassword } from '@openpanel/common/server'; +import { ClientType, getClientByIdCached } from '@openpanel/db'; +import { getCache } from '@openpanel/redis'; + +export interface McpAuthContext { + /** + * Fixed project ID for read clients. + * null for root clients — they can query any project in their organization. + */ + projectId: string | null; + organizationId: string; + clientType: 'read' | 'root'; +} + +export class McpAuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'McpAuthError'; + } +} + +/** + * Authenticate an MCP token. + * + * Token format: base64(clientId:clientSecret) + * Accepted via ?token= query param or Authorization: Bearer header. + * + * - write-only clients are rejected (no read access) + * - read clients get a fixed projectId + * - root clients get null projectId + organizationId (multi-project access) + */ +export async function authenticateToken( + token: string | undefined, +): Promise { + if (!token) { + throw new McpAuthError('Missing authentication token'); + } + + let decoded: string; + try { + decoded = Buffer.from(token, 'base64').toString('utf-8'); + } catch { + throw new McpAuthError('Invalid token encoding'); + } + + const colonIndex = decoded.indexOf(':'); + if (colonIndex === -1) { + throw new McpAuthError( + 'Invalid token format — expected base64(clientId:clientSecret)', + ); + } + + const clientId = decoded.slice(0, colonIndex); + const clientSecret = decoded.slice(colonIndex + 1); + + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + clientId, + ) + ) { + throw new McpAuthError('Invalid client ID format'); + } + + if (!clientSecret) { + throw new McpAuthError('Client secret is required'); + } + + const client = await getClientByIdCached(clientId); + if (!client) { + throw new McpAuthError('Invalid credentials'); + } + + if (!client.secret) { + throw new McpAuthError( + 'This client has no secret — only clients with a secret can use MCP', + ); + } + + if (client.type === ClientType.write) { + throw new McpAuthError( + 'Write-only clients cannot use MCP — use a read or root client', + ); + } + + const cacheKey = `mcp:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`; + const isVerified = await getCache( + cacheKey, + 60 * 5, + async () => await verifyPassword(clientSecret, client.secret!), + true, + ); + + if (!isVerified) { + throw new McpAuthError('Invalid credentials'); + } + + const isRoot = client.type === ClientType.root; + + return { + projectId: isRoot ? null : (client.projectId ?? null), + organizationId: client.organizationId, + clientType: isRoot ? 'root' : 'read', + }; +} + +/** + * Extract the MCP token from a request. + * Checks ?token= query param first, then Authorization: Bearer header. + */ +export function extractToken( + query: Record, + authHeader: string | undefined, +): string | undefined { + if (typeof query['token'] === 'string') { + return query['token']; + } + if (authHeader?.startsWith('Bearer ')) { + return authHeader.slice(7); + } + return undefined; +} + +/** + * Resolve the effective projectId for a tool call. + * For read clients the projectId is fixed; for root clients it must be supplied. + */ +export function resolveProjectId( + context: McpAuthContext, + inputProjectId: string | undefined, +): string { + if (context.projectId !== null) { + return context.projectId; + } + if (!inputProjectId) { + throw new Error( + 'projectId is required when using a root (organization-level) client', + ); + } + return inputProjectId; +} diff --git a/packages/mcp/src/handler.ts b/packages/mcp/src/handler.ts new file mode 100644 index 000000000..6db01db33 --- /dev/null +++ b/packages/mcp/src/handler.ts @@ -0,0 +1,104 @@ +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createLogger } from '@openpanel/logger'; +import { McpAuthError, authenticateToken, extractToken } from './auth'; +import { createMcpServer } from './server'; +import type { SessionManager } from './session-manager'; + +const logger = createLogger({ name: 'mcp:handler' }); + +/** + * Handle a POST /mcp request. + * + * - If Mcp-Session-Id is present, routes to the existing session. + * - Otherwise authenticates via token, creates a new session, and handles. + * + * Writes directly to `res` (caller must have hijacked the Fastify reply). + */ +export async function handleMcpPost( + sessionManager: SessionManager, + req: IncomingMessage, + res: ServerResponse, + body: unknown, + query: Record, +): Promise { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId) { + const session = sessionManager.get(sessionId); + if (!session) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found or expired' })); + return; + } + await session.transport.handleRequest(req, res, body); + return; + } + + // New session — authenticate first + const token = extractToken(query, req.headers.authorization); + + try { + const context = await authenticateToken(token); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => sessionManager.generateId(), + onsessioninitialized: (id: string) => { + sessionManager.set(id, { + server, + transport, + context, + lastActivity: Date.now(), + }); + logger.info('MCP session initialized', { + sessionId: id, + clientType: context.clientType, + organizationId: context.organizationId, + projectId: context.projectId, + }); + }, + }); + + const server = createMcpServer(context); + await server.connect(transport); + await transport.handleRequest(req, res, body); + } catch (err) { + if (err instanceof McpAuthError) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + } else { + logger.error('MCP session creation error', { err }); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + } +} + +/** + * Handle a GET /mcp request (SSE stream for an existing session). + */ +export async function handleMcpGet( + sessionManager: SessionManager, + req: IncomingMessage, + res: ServerResponse, +): Promise { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Mcp-Session-Id header is required' })); + return; + } + + const session = sessionManager.get(sessionId); + if (!session) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found or expired' })); + return; + } + + try { + await session.transport.handleRequest(req, res); + } catch (err) { + logger.error('MCP SSE stream error', { err, sessionId }); + } +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 000000000..c1ccae70a --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,30 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from './auth'; +import { registerAllTools } from './tools/index'; + +const SERVER_NAME = 'OpenPanel'; +const SERVER_VERSION = '1.0.0'; + +/** + * Create a fully configured McpServer instance for a given auth context. + * + * Each authenticated session gets its own server instance with tools + * pre-bound to the session's project/organization context. + */ +export function createMcpServer(context: McpAuthContext): McpServer { + const server = new McpServer( + { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + registerAllTools(server, context); + + return server; +} diff --git a/packages/mcp/src/session-manager.ts b/packages/mcp/src/session-manager.ts new file mode 100644 index 000000000..e5abca65b --- /dev/null +++ b/packages/mcp/src/session-manager.ts @@ -0,0 +1,109 @@ +import { randomUUID } from 'node:crypto'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createLogger } from '@openpanel/logger'; +import type { McpAuthContext } from './auth'; + +const logger = createLogger({ name: 'mcp:sessions' }); + +interface McpSession { + server: McpServer; + transport: StreamableHTTPServerTransport; + context: McpAuthContext; + lastActivity: number; +} + +const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // check every 5 minutes + +export class SessionManager { + private sessions = new Map(); + private cleanupTimer: ReturnType | null = null; + + constructor() { + this.cleanupTimer = setInterval( + () => this.cleanup(), + CLEANUP_INTERVAL_MS, + ); + // Don't keep the process alive just for session cleanup + this.cleanupTimer.unref(); + } + + generateId(): string { + return randomUUID(); + } + + set(id: string, session: McpSession): void { + this.sessions.set(id, session); + logger.info('MCP session created', { + sessionId: id, + clientType: session.context.clientType, + organizationId: session.context.organizationId, + projectId: session.context.projectId, + }); + } + + get(id: string): McpSession | undefined { + const session = this.sessions.get(id); + if (session) { + session.lastActivity = Date.now(); + } + return session; + } + + has(id: string): boolean { + return this.sessions.has(id); + } + + async close(id: string): Promise { + const session = this.sessions.get(id); + if (!session) return; + + this.sessions.delete(id); + + try { + await session.transport.close(); + } catch (err) { + logger.warn('Error closing MCP transport', { sessionId: id, err }); + } + + logger.info('MCP session closed', { sessionId: id }); + } + + get size(): number { + return this.sessions.size; + } + + private async cleanup(): Promise { + const now = Date.now(); + const expired: string[] = []; + + for (const [id, session] of this.sessions) { + if (now - session.lastActivity > SESSION_TTL_MS) { + expired.push(id); + } + } + + for (const id of expired) { + logger.info('MCP session expired', { sessionId: id }); + await this.close(id); + } + + if (expired.length > 0) { + logger.info('MCP session cleanup complete', { + expired: expired.length, + remaining: this.sessions.size, + }); + } + } + + async destroy(): Promise { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + for (const id of [...this.sessions.keys()]) { + await this.close(id); + } + } +} diff --git a/packages/mcp/src/tools/analytics/active-users.ts b/packages/mcp/src/tools/analytics/active-users.ts new file mode 100644 index 000000000..01523b4b5 --- /dev/null +++ b/packages/mcp/src/tools/analytics/active-users.ts @@ -0,0 +1,54 @@ +import { + getRollingActiveUsers, + getRetentionSeries, +} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveProjectId, + withErrorHandling, +} from '../shared'; + +export function registerActiveUserTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_rolling_active_users', + 'Get a time series of unique active users using a rolling window. Use days=1 for DAU, days=7 for WAU, days=30 for MAU. Shows how your active user count trends over time.', + { + projectId: projectIdSchema(context), + days: z + .number() + .int() + .min(1) + .max(90) + .describe('Rolling window in days. 1 = DAU, 7 = WAU, 30 = MAU.'), + }, + async ({ projectId: inputProjectId, days }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const data = await getRollingActiveUsers({ projectId, days }); + return { + window_days: days, + label: days === 1 ? 'DAU' : days === 7 ? 'WAU' : days === 30 ? 'MAU' : `${days}d active`, + series: data, + }; + }), + ); + + server.tool( + 'get_weekly_retention_series', + 'Get week-over-week user retention as a time series. For each week, shows how many users were active that week and how many returned the following week. Useful for understanding whether your product retains users.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + return getRetentionSeries({ projectId }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/engagement.ts b/packages/mcp/src/tools/analytics/engagement.ts new file mode 100644 index 000000000..b9b9b0822 --- /dev/null +++ b/packages/mcp/src/tools/analytics/engagement.ts @@ -0,0 +1,51 @@ +import { getRetentionLastSeenSeries } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../../auth'; +import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; + +export function registerEngagementTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_user_last_seen_distribution', + 'Get a histogram of how many users were last active N days ago. Shows the distribution of user recency — how many users are still fresh (0-7 days), somewhat stale (8-30 days), or churned (30+ days). Great for churn analysis and understanding overall engagement health.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const raw = await getRetentionLastSeenSeries({ projectId }); + + // Bucket into meaningful segments for easier reading + let active_0_7 = 0; + let active_8_14 = 0; + let active_15_30 = 0; + let active_31_60 = 0; + let churned_60_plus = 0; + + for (const row of raw) { + if (row.days <= 7) active_0_7 += row.users; + else if (row.days <= 14) active_8_14 += row.users; + else if (row.days <= 30) active_15_30 += row.users; + else if (row.days <= 60) active_31_60 += row.users; + else churned_60_plus += row.users; + } + + const total = active_0_7 + active_8_14 + active_15_30 + active_31_60 + churned_60_plus; + + return { + summary: { + total_identified_users: total, + active_last_7_days: active_0_7, + active_8_to_14_days: active_8_14, + active_15_to_30_days: active_15_30, + inactive_31_to_60_days: active_31_60, + churned_60_plus_days: churned_60_plus, + }, + distribution: raw, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/event-names.ts b/packages/mcp/src/tools/analytics/event-names.ts new file mode 100644 index 000000000..08e39784c --- /dev/null +++ b/packages/mcp/src/tools/analytics/event-names.ts @@ -0,0 +1,44 @@ +import { TABLE_NAMES, ch, clix } from '@openpanel/db'; +import type { IClickhouseEvent } from '@openpanel/db'; +import { getCache } from '@openpanel/redis'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveProjectId, + withErrorHandling, +} from '../shared'; + +export async function getTopEventNames(projectId: string): Promise { + return getCache(`mcp:event-names:${projectId}`, 60 * 10, async () => { + const rows = await clix(ch) + .select(['name', 'count() as count']) + .from(TABLE_NAMES.event_names_mv) + .where('project_id', '=', projectId) + .groupBy(['name']) + .orderBy('count', 'DESC') + .limit(50) + .execute(); + + return rows.map((r) => r.name); + }); +} + +export function registerEventNameTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_event_names', + 'Get the top 50 most common event names tracked in this project. Always call this before querying events if you are unsure of the exact event name.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const names = await getTopEventNames(projectId); + return { event_names: names }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/events.ts b/packages/mcp/src/tools/analytics/events.ts new file mode 100644 index 000000000..6d8366bce --- /dev/null +++ b/packages/mcp/src/tools/analytics/events.ts @@ -0,0 +1,160 @@ +import { TABLE_NAMES, ch, clix } from '@openpanel/db'; +import type { IClickhouseEvent } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +export interface QueryEventsInput { + projectId: string; + startDate?: string; + endDate?: string; + eventNames?: string[]; + path?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + os?: string; + referrer?: string; + referrerName?: string; + referrerType?: string; + profileId?: string; + properties?: Record; + limit?: number; +} + +export async function queryEventsCore( + input: QueryEventsInput, +): Promise { + const builder = clix(ch) + .select([]) + .from(TABLE_NAMES.events) + .where('project_id', '=', input.projectId); + + if (input.profileId) { + builder.where('profile_id', '=', input.profileId); + } + + if (input.eventNames?.length) { + builder.where('name', 'IN', input.eventNames); + } + + if (input.path) { + builder.where('path', '=', input.path); + } + + if (input.referrer) { + builder.where('referrer', '=', input.referrer); + } + + if (input.referrerName) { + builder.where('referrer_name', '=', input.referrerName); + } + + if (input.referrerType) { + builder.where('referrer_type', '=', input.referrerType); + } + + if (input.device) { + builder.where('device', '=', input.device); + } + + if (input.country) { + builder.where('country', '=', input.country); + } + + if (input.city) { + builder.where('city', '=', input.city); + } + + if (input.os) { + builder.where('os', '=', input.os); + } + + if (input.browser) { + builder.where('browser', '=', input.browser); + } + + if (input.properties) { + for (const [key, value] of Object.entries(input.properties)) { + builder.where(`properties['${key}']`, '=', value); + } + } + + const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); + + builder.where('created_at', 'BETWEEN', [ + clix.datetime(start), + clix.datetime(end), + ]); + + return builder.limit(input.limit ?? 20).execute(); +} + +export function registerEventTools(server: McpServer, context: McpAuthContext) { + server.tool( + 'query_events', + 'Query raw analytics events with optional filters. Returns individual event records including path, device, country, referrer, and custom properties. Defaults to the last 30 days.', + { + projectId: projectIdSchema(context), + ...zDateRange, + eventNames: z + .array(z.string()) + .optional() + .describe( + 'Filter by event names (e.g. ["screen_view", "session_start"])', + ), + path: z.string().optional().describe('Filter by exact page path'), + country: z + .string() + .optional() + .describe('Filter by ISO 3166-1 alpha-2 country code (e.g. US, GB)'), + city: z.string().optional().describe('Filter by city name'), + device: z + .string() + .optional() + .describe('Filter by device type (e.g. desktop, mobile, tablet)'), + browser: z + .string() + .optional() + .describe('Filter by browser name (e.g. Chrome, Firefox)'), + os: z.string().optional().describe('Filter by OS name (e.g. Windows, macOS)'), + referrer: z.string().optional().describe('Filter by referrer URL'), + referrerName: z + .string() + .optional() + .describe('Filter by referrer name (e.g. Google, Twitter)'), + referrerType: z + .string() + .optional() + .describe('Filter by referrer type (e.g. search, social, email)'), + profileId: z + .string() + .optional() + .describe('Filter events for a specific user profile ID'), + properties: z + .record(z.string(), z.string()) + .optional() + .describe('Filter by custom event properties (key-value pairs)'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of events to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, ...input }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + return queryEventsCore({ projectId, ...input }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/funnel.ts b/packages/mcp/src/tools/analytics/funnel.ts new file mode 100644 index 000000000..b46852a90 --- /dev/null +++ b/packages/mcp/src/tools/analytics/funnel.ts @@ -0,0 +1,137 @@ +import { FunnelService, ch, getSettingsForProject } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +const funnelService = new FunnelService(ch); + +export async function getFunnelCore(input: { + projectId: string; + startDate: string; + endDate: string; + steps: string[]; + windowHours?: number; + groupBy?: 'session_id' | 'profile_id'; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + const eventSeries = input.steps.map((name, index) => ({ + id: String(index + 1), + type: 'event' as const, + name, + displayName: name, + segment: 'user' as const, + filters: [], + })); + + const result = await funnelService.getFunnel({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + series: eventSeries, + breakdowns: [], + chartType: 'funnel', + interval: 'day', + range: 'custom', + previous: false, + metric: 'sum', + options: { + type: 'funnel', + funnelWindow: input.windowHours ?? 24, + funnelGroup: input.groupBy ?? 'session_id', + }, + timezone, + }); + + // Take the first (unbreakdown) series and map steps to a readable format + const primarySeries = result[0]; + if (!primarySeries) { + return { + steps: [], + totalUsers: 0, + completedUsers: 0, + overallConversionRate: 0, + }; + } + + const steps = primarySeries.steps.map((step, index) => ({ + step: index + 1, + eventName: step.event.displayName || step.event.name, + users: step.count, + conversionRateFromStart: Math.round(step.percent * 100) / 100, + dropoffPercent: + step.dropoffPercent != null + ? Math.round(step.dropoffPercent * 100) / 100 + : null, + isHighestDropoff: step.isHighestDropoff, + })); + + const totalUsers = steps[0]?.users ?? 0; + const completedUsers = steps[steps.length - 1]?.users ?? 0; + + return { + steps, + totalUsers, + completedUsers, + overallConversionRate: + totalUsers > 0 + ? Math.round((completedUsers / totalUsers) * 10000) / 100 + : 0, + }; +} + +export function registerFunnelTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_funnel', + 'Analyze a conversion funnel between 2 or more events. Returns step-by-step conversion rates and drop-off percentages. For example, analyze sign-up flows, checkout funnels, or onboarding sequences.', + { + projectId: projectIdSchema(context), + ...zDateRange, + steps: z + .array(z.string()) + .min(2) + .max(10) + .describe( + 'Ordered list of event names forming the funnel steps (minimum 2, maximum 10)', + ), + windowHours: z + .number() + .min(1) + .max(720) + .default(24) + .optional() + .describe( + 'Time window in hours within which all steps must occur (default: 24 hours)', + ), + groupBy: z + .enum(['session_id', 'profile_id']) + .default('session_id') + .optional() + .describe( + '"session_id" counts within-session completions, "profile_id" counts cross-session completions (default: session_id)', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, steps, windowHours, groupBy }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getFunnelCore({ + projectId, + startDate, + endDate, + steps, + windowHours, + groupBy, + }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/groups.ts b/packages/mcp/src/tools/analytics/groups.ts new file mode 100644 index 000000000..fac855358 --- /dev/null +++ b/packages/mcp/src/tools/analytics/groups.ts @@ -0,0 +1,97 @@ +import { + getGroupById, + getGroupList, + getGroupMemberProfiles, + getGroupTypes, +} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; + +export function registerGroupTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_group_types', + 'List all group types defined in this project (e.g. "company", "team", "account"). Groups represent B2B entities. Call this first to discover what group types exist before querying groups.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const types = await getGroupTypes(projectId); + return { types }; + }), + ); + + server.tool( + 'find_groups', + 'Search for groups (companies, teams, accounts) by name, ID, or type. Groups are B2B entities that profiles (users) belong to.', + { + projectId: projectIdSchema(context), + type: z + .string() + .optional() + .describe('Filter by group type (e.g. "company", "team"). Use list_group_types to discover available types.'), + search: z + .string() + .optional() + .describe('Partial match against group name or ID'), + limit: z + .number() + .int() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of groups to return (default 20)'), + }, + async ({ projectId: inputProjectId, type, search, limit }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + return getGroupList({ projectId, type, search, take: limit ?? 20 }); + }), + ); + + server.tool( + 'get_group', + 'Get a specific group by ID including its properties, and fetch the member profiles (users) that belong to it.', + { + projectId: projectIdSchema(context), + groupId: z.string().describe('The group ID to look up'), + memberLimit: z + .number() + .int() + .min(1) + .max(50) + .default(10) + .optional() + .describe('Max number of member profiles to include (default 10)'), + }, + async ({ projectId: inputProjectId, groupId, memberLimit }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const [group, members] = await Promise.all([ + getGroupById(groupId, projectId), + getGroupMemberProfiles({ + projectId, + groupId, + take: memberLimit ?? 10, + }), + ]); + + if (!group) { + return { error: 'Group not found', groupId }; + } + + return { + group, + member_count: members.count, + members: members.data, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/overview.ts b/packages/mcp/src/tools/analytics/overview.ts new file mode 100644 index 000000000..9ce25c059 --- /dev/null +++ b/packages/mcp/src/tools/analytics/overview.ts @@ -0,0 +1,78 @@ +import { + OverviewService, + ch, + getSettingsForProject, +} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +const overviewService = new OverviewService(ch); + +export interface GetAnalyticsOverviewInput { + projectId: string; + startDate: string; + endDate: string; + interval?: 'hour' | 'day' | 'week' | 'month'; +} + +export async function getAnalyticsOverviewCore( + input: GetAnalyticsOverviewInput, +) { + const { timezone } = await getSettingsForProject(input.projectId); + const interval = input.interval ?? 'day'; + + const result = await overviewService.getMetrics({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + interval, + timezone, + }); + + return { + summary: result.metrics, + series: result.series, + interval, + startDate: input.startDate, + endDate: input.endDate, + }; +} + +export function registerOverviewTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_analytics_overview', + 'Get key analytics metrics for a date range: unique visitors, total pageviews, sessions, bounce rate, average session duration, and views per session. Optionally includes a time-series breakdown by interval.', + { + projectId: projectIdSchema(context), + ...zDateRange, + interval: z + .enum(['hour', 'day', 'week', 'month']) + .default('day') + .optional() + .describe('Time interval for the series breakdown (default: day)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, interval }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getAnalyticsOverviewCore({ + projectId, + startDate, + endDate, + interval, + }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/page-performance.ts b/packages/mcp/src/tools/analytics/page-performance.ts new file mode 100644 index 000000000..86e4db5bd --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-performance.ts @@ -0,0 +1,85 @@ +import { PagesService, ch, getSettingsForProject } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +const pagesService = new PagesService(ch); + +export function registerPagePerformanceTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_page_performance', + 'Get per-page performance metrics including bounce rate, avg session duration, sessions, and pageviews. Sort by bounce_rate to find high-bounce landing pages, or by avg_duration to find low-engagement content. Essential for SEO and CRO analysis.', + { + projectId: projectIdSchema(context), + ...zDateRange, + search: z + .string() + .optional() + .describe('Filter pages by path or title (partial match)'), + sortBy: z + .enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']) + .default('sessions') + .optional() + .describe('Sort results by this metric (default: sessions)'), + sortOrder: z + .enum(['asc', 'desc']) + .default('desc') + .optional() + .describe('Sort direction (default: desc)'), + limit: z + .number() + .int() + .min(1) + .max(500) + .default(50) + .optional() + .describe('Maximum number of pages to return (default 50)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, search, sortBy, sortOrder, limit }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const { timezone } = await getSettingsForProject(projectId); + + const pages = await pagesService.getTopPages({ + projectId, + startDate, + endDate, + timezone, + search, + limit: 1000, // fetch more, sort+slice in memory for flexibility + }); + + const col = sortBy ?? 'sessions'; + const dir = sortOrder === 'asc' ? 1 : -1; + const sorted = [...pages].sort((a, b) => dir * ((a[col] ?? 0) < (b[col] ?? 0) ? -1 : 1)); + const results = sorted.slice(0, limit ?? 50); + + // Annotate with SEO signals + const annotated = results.map((p) => ({ + ...p, + seo_signals: { + high_bounce: p.bounce_rate > 70, + low_engagement: p.avg_duration < 1, + good_landing_page: p.bounce_rate < 40 && p.avg_duration > 2, + }, + })); + + return { + total_pages: pages.length, + shown: annotated.length, + pages: annotated, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/pages.ts b/packages/mcp/src/tools/analytics/pages.ts new file mode 100644 index 000000000..799d44e95 --- /dev/null +++ b/packages/mcp/src/tools/analytics/pages.ts @@ -0,0 +1,87 @@ +import { + OverviewService, + ch, + getSettingsForProject, +} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +const overviewService = new OverviewService(ch); + +export async function getTopPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return overviewService.getTopPages({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + timezone, + }); +} + +export async function getEntryExitPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + mode: 'entry' | 'exit'; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return overviewService.getTopEntryExit({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + mode: input.mode, + timezone, + }); +} + +export function registerPageTools(server: McpServer, context: McpAuthContext) { + server.tool( + 'get_top_pages', + 'Get the most visited pages ranked by page views, with unique visitor counts and other engagement metrics.', + { + projectId: projectIdSchema(context), + ...zDateRange, + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTopPagesCore({ projectId, startDate, endDate }); + }), + ); + + server.tool( + 'get_entry_exit_pages', + 'Get the most common entry pages (first page in a session) or exit pages (last page in a session).', + { + projectId: projectIdSchema(context), + ...zDateRange, + mode: z + .enum(['entry', 'exit']) + .describe( + '"entry" for pages visitors land on first, "exit" for pages they leave from', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, mode }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getEntryExitPagesCore({ projectId, startDate, endDate, mode }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts new file mode 100644 index 000000000..6bf2c4ff3 --- /dev/null +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -0,0 +1,48 @@ +import { getProfileMetrics } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveProjectId, + withErrorHandling, +} from '../shared'; + +export function registerProfileMetricTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_profile_metrics', + 'Get computed lifetime metrics for a specific user: sessions, screen views, total events, avg session duration (p50/p90), bounce rate, unique active days, conversion events, avg time between sessions, and total revenue. Useful for understanding individual user health at a glance.', + { + projectId: projectIdSchema(context), + profileId: z.string().describe('The profile ID to get metrics for'), + }, + async ({ projectId: inputProjectId, profileId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const rows = await getProfileMetrics(profileId, projectId); + const raw = rows[0]; + if (!raw) { + return { error: 'Profile not found or has no events', profileId }; + } + return { + profileId, + firstSeen: raw.firstSeen, + lastSeen: raw.lastSeen, + sessions: raw.sessions, + screenViews: raw.screenViews, + totalEvents: raw.totalEvents, + conversionEvents: raw.conversionEvents, + uniqueDaysActive: raw.uniqueDaysActive, + avgSessionDurationMin: raw.durationAvg, + p90SessionDurationMin: raw.durationP90, + avgEventsPerSession: raw.avgEventsPerSession, + avgTimeBetweenSessionsSec: raw.avgTimeBetweenSessions, + bounceRate: raw.bounceRate, + revenue: raw.revenue, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/profiles.ts b/packages/mcp/src/tools/analytics/profiles.ts new file mode 100644 index 000000000..9e1ad4418 --- /dev/null +++ b/packages/mcp/src/tools/analytics/profiles.ts @@ -0,0 +1,271 @@ +import { TABLE_NAMES, ch, chQuery, clix } from '@openpanel/db'; +import type { + IClickhouseEvent, + IClickhouseProfile, + IClickhouseSession, +} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveProjectId, + withErrorHandling, +} from '../shared'; + +/** Safely escape a string value for use in a ClickHouse SQL literal. */ +function esc(value: string): string { + return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; +} + +const PROFILE_COLUMNS = + 'id, first_name, last_name, email, avatar, properties, project_id, is_external, created_at, groups'; + +export interface FindProfilesInput { + projectId: string; + /** Partial match against first_name OR last_name */ + name?: string; + email?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + /** Profiles with no activity (events) in the last N days */ + inactiveDays?: number; + /** Profiles with at least N total sessions */ + minSessions?: number; + /** Only profiles that have performed this event at least once */ + performedEvent?: string; + sortBy?: 'created_at'; + sortOrder?: 'asc' | 'desc'; + limit?: number; +} + +export async function findProfilesCore( + input: FindProfilesInput, +): Promise { + const pid = esc(input.projectId); + const conditions: string[] = [`project_id = ${pid}`]; + + if (input.email) { + conditions.push(`email LIKE ${esc('%' + input.email + '%')}`); + } + if (input.name) { + const escaped = esc('%' + input.name + '%'); + conditions.push(`(first_name LIKE ${escaped} OR last_name LIKE ${escaped})`); + } + if (input.country) { + conditions.push(`properties['country'] = ${esc(input.country)}`); + } + if (input.city) { + conditions.push(`properties['city'] = ${esc(input.city)}`); + } + if (input.device) { + conditions.push(`properties['device'] = ${esc(input.device)}`); + } + if (input.browser) { + conditions.push(`properties['browser'] = ${esc(input.browser)}`); + } + + if (input.inactiveDays !== undefined) { + const days = Math.floor(input.inactiveDays); + conditions.push(`id NOT IN ( + SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} + WHERE project_id = ${pid} + AND profile_id != '' + AND created_at >= now() - INTERVAL ${days} DAY + )`); + } + + if (input.minSessions !== undefined) { + const min = Math.floor(input.minSessions); + conditions.push(`id IN ( + SELECT profile_id FROM ${TABLE_NAMES.sessions} + WHERE project_id = ${pid} + AND sign = 1 + AND profile_id != '' + GROUP BY profile_id + HAVING count() >= ${min} + )`); + } + + if (input.performedEvent) { + conditions.push(`id IN ( + SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} + WHERE project_id = ${pid} + AND name = ${esc(input.performedEvent)} + )`); + } + + const orderDir = input.sortOrder === 'asc' ? 'ASC' : 'DESC'; + const limit = Math.min(input.limit ?? 20, 100); + + const sql = ` + SELECT ${PROFILE_COLUMNS} + FROM ${TABLE_NAMES.profiles} + WHERE ${conditions.join(' AND ')} + ORDER BY created_at ${orderDir} + LIMIT ${limit} + `; + + return chQuery(sql); +} + +export async function getProfileWithEvents( + projectId: string, + profileId: string, + eventLimit = 10, +): Promise<{ + profile: IClickhouseProfile | null; + recent_events: IClickhouseEvent[]; +}> { + const [profiles, recent_events] = await Promise.all([ + chQuery(` + SELECT ${PROFILE_COLUMNS} + FROM ${TABLE_NAMES.profiles} + WHERE project_id = ${esc(projectId)} AND id = ${esc(profileId)} + LIMIT 1 + `), + clix(ch) + .select([]) + .from(TABLE_NAMES.events) + .where('project_id', '=', projectId) + .where('profile_id', '=', profileId) + .orderBy('created_at', 'DESC') + .limit(eventLimit) + .execute(), + ]); + + return { profile: profiles[0] ?? null, recent_events }; +} + +export async function getProfileSessionsCore( + projectId: string, + profileId: string, + limit = 20, +): Promise { + return clix(ch) + .select([]) + .from(TABLE_NAMES.sessions) + .where('project_id', '=', projectId) + .where('profile_id', '=', profileId) + .where('sign', '=', 1) + .orderBy('created_at', 'DESC') + .limit(limit) + .execute(); +} + +export function registerProfileTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'find_profiles', + 'Search and filter user profiles. Supports filtering by name, email, location, inactivity, session count, and whether they performed a specific event. Defaults to the 20 most recently created profiles.', + { + projectId: projectIdSchema(context), + name: z + .string() + .optional() + .describe('Partial match against first name or last name (e.g. "Carl")'), + email: z + .string() + .optional() + .describe('Partial email match'), + country: z + .string() + .optional() + .describe('Filter by ISO 3166-1 alpha-2 country code (e.g. US, SE)'), + city: z.string().optional().describe('Filter by city name'), + device: z + .string() + .optional() + .describe('Filter by device type (desktop, mobile, tablet)'), + browser: z.string().optional().describe('Filter by browser name'), + inactiveDays: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Return only profiles with no activity (events) in the last N days. E.g. 14 = inactive for 2+ weeks.', + ), + minSessions: z + .number() + .int() + .min(1) + .optional() + .describe('Return only profiles with at least N total sessions'), + performedEvent: z + .string() + .optional() + .describe( + 'Return only profiles that have performed this event at least once (e.g. "purchase", "sign_up")', + ), + sortOrder: z + .enum(['asc', 'desc']) + .default('desc') + .optional() + .describe('Sort direction for created_at (default: desc = newest first)'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of profiles to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, ...input }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + return findProfilesCore({ projectId, ...input }); + }), + ); + + server.tool( + 'get_profile', + 'Get a specific user profile by ID along with their most recent events. Useful for understanding an individual user journey.', + { + projectId: projectIdSchema(context), + profileId: z.string().describe('The profile ID to look up'), + eventLimit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Number of recent events to include (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, profileId, eventLimit }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const result = await getProfileWithEvents(projectId, profileId, eventLimit); + if (!result.profile) { + return { error: 'Profile not found', profileId }; + } + return result; + }), + ); + + server.tool( + 'get_profile_sessions', + 'Get all sessions for a specific user profile, ordered by most recent first. Each session includes duration, entry/exit pages, device info, and referrer.', + { + projectId: projectIdSchema(context), + profileId: z.string().describe('The profile ID to fetch sessions for'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of sessions to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, profileId, limit }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const sessions = await getProfileSessionsCore(projectId, profileId, limit); + return { profileId, session_count: sessions.length, sessions }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/property-values.ts b/packages/mcp/src/tools/analytics/property-values.ts new file mode 100644 index 000000000..23b03b769 --- /dev/null +++ b/packages/mcp/src/tools/analytics/property-values.ts @@ -0,0 +1,79 @@ +import { TABLE_NAMES, ch, clix } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveProjectId, + withErrorHandling, +} from '../shared'; + +export function registerPropertyValueTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_event_properties', + 'List all property keys that have been tracked for a specific event (or across all events). Use this to discover what data is available before filtering or breaking down by a property.', + { + projectId: projectIdSchema(context), + eventName: z + .string() + .optional() + .describe('Filter to a specific event name. Omit to list properties across all events.'), + }, + async ({ projectId: inputProjectId, eventName }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const builder = clix(ch) + .select<{ property_key: string; event_name: string }>([ + 'distinct property_key', + 'name as event_name', + ]) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', projectId) + .orderBy('property_key', 'ASC') + .limit(500); + + if (eventName) { + builder.where('name', '=', eventName); + } + + const rows = await builder.execute(); + return { properties: rows }; + }), + ); + + server.tool( + 'get_event_property_values', + 'Get all distinct values for a specific event property. Use this to understand what values exist before filtering (e.g. what plans exist in "plan" property, what countries, what status values).', + { + projectId: projectIdSchema(context), + eventName: z + .string() + .describe('The event name to look up property values for (e.g. "subscription_created")'), + propertyKey: z + .string() + .describe('The property key to get values for (e.g. "plan", "country", "status")'), + }, + async ({ projectId: inputProjectId, eventName, propertyKey }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const rows = await clix(ch) + .select<{ value: string }>(['property_value as value']) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', projectId) + .where('name', '=', eventName) + .where('property_key', '=', propertyKey) + .orderBy('created_at', 'DESC') + .limit(200) + .execute(); + + return { + event: eventName, + property: propertyKey, + values: rows.map((r) => r.value), + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/retention.ts b/packages/mcp/src/tools/analytics/retention.ts new file mode 100644 index 000000000..6e9b27543 --- /dev/null +++ b/packages/mcp/src/tools/analytics/retention.ts @@ -0,0 +1,26 @@ +import { getRetentionCohortTable } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveProjectId, + withErrorHandling, +} from '../shared'; + +export function registerRetentionTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_retention_cohort', + 'Get a weekly user retention cohort table. Shows what percentage of users who first visited in a given week returned in subsequent weeks. Useful for understanding long-term user engagement and product stickiness.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + return getRetentionCohortTable({ projectId }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/sessions.ts b/packages/mcp/src/tools/analytics/sessions.ts new file mode 100644 index 000000000..99711ad40 --- /dev/null +++ b/packages/mcp/src/tools/analytics/sessions.ts @@ -0,0 +1,130 @@ +import { TABLE_NAMES, ch, clix } from '@openpanel/db'; +import type { IClickhouseSession } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +export interface QuerySessionsInput { + projectId: string; + startDate?: string; + endDate?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + os?: string; + referrer?: string; + referrerName?: string; + referrerType?: string; + profileId?: string; + limit?: number; +} + +export async function querySessionsCore( + input: QuerySessionsInput, +): Promise { + const builder = clix(ch) + .select([]) + .from(TABLE_NAMES.sessions) + .where('project_id', '=', input.projectId) + .where('sign', '=', 1); + + if (input.profileId) { + builder.where('profile_id', '=', input.profileId); + } + + if (input.referrer) { + builder.where('referrer', '=', input.referrer); + } + + if (input.referrerName) { + builder.where('referrer_name', '=', input.referrerName); + } + + if (input.referrerType) { + builder.where('referrer_type', '=', input.referrerType); + } + + if (input.device) { + builder.where('device', '=', input.device); + } + + if (input.country) { + builder.where('country', '=', input.country); + } + + if (input.city) { + builder.where('city', '=', input.city); + } + + if (input.os) { + builder.where('os', '=', input.os); + } + + if (input.browser) { + builder.where('browser', '=', input.browser); + } + + const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); + + builder.where('created_at', 'BETWEEN', [ + clix.datetime(start), + clix.datetime(end), + ]); + + return builder.limit(input.limit ?? 20).execute(); +} + +export function registerSessionTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'query_sessions', + 'Query user sessions with optional filters. Each session represents a single visit with duration, entry/exit pages, bounce status, and attribution data. Defaults to the last 30 days.', + { + projectId: projectIdSchema(context), + ...zDateRange, + country: z + .string() + .optional() + .describe('Filter by ISO 3166-1 alpha-2 country code'), + city: z.string().optional().describe('Filter by city name'), + device: z + .string() + .optional() + .describe('Filter by device type (desktop, mobile, tablet)'), + browser: z.string().optional().describe('Filter by browser name'), + os: z.string().optional().describe('Filter by OS name'), + referrer: z.string().optional().describe('Filter by referrer URL'), + referrerName: z.string().optional().describe('Filter by referrer name'), + referrerType: z + .string() + .optional() + .describe('Filter by referrer type (search, social, email, direct)'), + profileId: z + .string() + .optional() + .describe('Filter sessions for a specific user profile ID'), + limit: z + .number() + .min(1) + .max(100) + .default(20) + .optional() + .describe('Maximum number of sessions to return (1-100, default 20)'), + }, + async ({ projectId: inputProjectId, ...input }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + return querySessionsCore({ projectId, ...input }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/traffic.ts b/packages/mcp/src/tools/analytics/traffic.ts new file mode 100644 index 000000000..2009e79df --- /dev/null +++ b/packages/mcp/src/tools/analytics/traffic.ts @@ -0,0 +1,132 @@ +import { + OverviewService, + ch, + getSettingsForProject, +} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +const overviewService = new OverviewService(ch); + +type TrafficColumn = + | 'referrer' + | 'referrer_name' + | 'referrer_type' + | 'utm_source' + | 'utm_medium' + | 'utm_campaign' + | 'country' + | 'region' + | 'city' + | 'device' + | 'browser' + | 'os'; + +async function getTopGeneric(input: { + projectId: string; + startDate: string; + endDate: string; + column: TrafficColumn; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return overviewService.getTopGeneric({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + column: input.column, + timezone, + }); +} + +export function registerTrafficTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_top_referrers', + 'Get the top traffic sources driving visitors to the site, broken down by referrer name and type.', + { + projectId: projectIdSchema(context), + ...zDateRange, + breakdown: z + .enum(['referrer_name', 'referrer_type', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign']) + .default('referrer_name') + .optional() + .describe( + 'How to group referrers: by name (Google, Twitter), type (search, social), full URL, or UTM params', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTopGeneric({ + projectId, + startDate, + endDate, + column: (breakdown ?? 'referrer_name') as TrafficColumn, + }); + }), + ); + + server.tool( + 'get_country_breakdown', + 'Get visitor counts broken down by country, region, or city.', + { + projectId: projectIdSchema(context), + ...zDateRange, + breakdown: z + .enum(['country', 'region', 'city']) + .default('country') + .optional() + .describe('Geographic grouping level (default: country)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTopGeneric({ + projectId, + startDate, + endDate, + column: (breakdown ?? 'country') as TrafficColumn, + }); + }), + ); + + server.tool( + 'get_device_breakdown', + 'Get visitor counts broken down by device type, browser, or operating system.', + { + projectId: projectIdSchema(context), + ...zDateRange, + breakdown: z + .enum(['device', 'browser', 'os']) + .default('device') + .optional() + .describe( + 'Device dimension: "device" (desktop/mobile/tablet), "browser" (Chrome/Firefox), or "os" (Windows/macOS)', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getTopGeneric({ + projectId, + startDate, + endDate, + column: (breakdown ?? 'device') as TrafficColumn, + }); + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/user-flow.ts b/packages/mcp/src/tools/analytics/user-flow.ts new file mode 100644 index 000000000..34a58fea0 --- /dev/null +++ b/packages/mcp/src/tools/analytics/user-flow.ts @@ -0,0 +1,100 @@ +import { SankeyService, ch, getSettingsForProject } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +const sankeyService = new SankeyService(ch); + +function toChartEvent(name: string) { + return { + id: name, + name, + displayName: name, + type: 'event' as const, + segment: 'event' as const, + filters: [], + }; +} + +export function registerUserFlowTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_user_flow', + 'Visualize user navigation flows as a Sankey diagram. Shows what events/pages users visit in sequence. Use mode "after" to see what happens after an event, "before" to see what leads up to it, or "between" to map paths from one event to another.', + { + projectId: projectIdSchema(context), + ...zDateRange, + startEvent: z + .string() + .describe('The anchor event name. For "after"/"before" mode this is the pivot event; for "between" it is the start.'), + endEvent: z + .string() + .optional() + .describe('Required for "between" mode: the destination event name.'), + mode: z + .enum(['after', 'before', 'between']) + .default('after') + .describe( + '"after" = what users do after startEvent; "before" = what leads up to startEvent; "between" = paths from startEvent to endEvent.', + ), + steps: z + .number() + .int() + .min(2) + .max(10) + .default(5) + .optional() + .describe('Number of steps to show in the flow (2-10, default 5)'), + exclude: z + .array(z.string()) + .optional() + .describe('Event names to exclude from the flow (e.g. noisy system events)'), + include: z + .array(z.string()) + .optional() + .describe('If set, only show these event names in the flow'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, startEvent, endEvent, mode, steps, exclude, include }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const { timezone } = await getSettingsForProject(projectId); + + if (mode === 'between' && !endEvent) { + return { error: 'endEvent is required when mode is "between"' }; + } + + const result = await sankeyService.getSankey({ + projectId, + startDate, + endDate, + steps: steps ?? 5, + mode, + startEvent: toChartEvent(startEvent), + endEvent: endEvent ? toChartEvent(endEvent) : undefined, + exclude: exclude ?? [], + include, + timezone, + }); + + return { + mode, + startEvent, + endEvent, + node_count: result.nodes.length, + link_count: result.links.length, + nodes: result.nodes, + links: result.links, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/gsc/cannibalization.ts b/packages/mcp/src/tools/gsc/cannibalization.ts new file mode 100644 index 000000000..c945c8bd2 --- /dev/null +++ b/packages/mcp/src/tools/gsc/cannibalization.ts @@ -0,0 +1,30 @@ +import { getGscCannibalization } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +export function registerGscCannibalizationTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'gsc_get_cannibalization', + 'Identify keyword cannibalization: search queries where multiple pages on your site compete against each other in Google. Returns queries where 2+ pages rank, sorted by total impressions. High cannibalization can hurt rankings.', + { + projectId: projectIdSchema(context), + ...zDateRange, + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscCannibalization(projectId, startDate, endDate); + }), + ); +} diff --git a/packages/mcp/src/tools/gsc/overview.ts b/packages/mcp/src/tools/gsc/overview.ts new file mode 100644 index 000000000..3dd2b04d5 --- /dev/null +++ b/packages/mcp/src/tools/gsc/overview.ts @@ -0,0 +1,62 @@ +import { getGscOverview } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +export function registerGscOverviewTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'gsc_get_overview', + 'Get Google Search Console performance over time: clicks, impressions, CTR, and average position. Requires GSC to be connected for the project.', + { + projectId: projectIdSchema(context), + ...zDateRange, + interval: z + .enum(['day', 'week', 'month']) + .default('day') + .optional() + .describe('Time interval for aggregation (default: day)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, interval }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const data = await getGscOverview( + projectId, + startDate, + endDate, + interval ?? 'day', + ); + return { + data, + summary: { + total_clicks: data.reduce((s, r) => s + r.clicks, 0), + total_impressions: data.reduce((s, r) => s + r.impressions, 0), + avg_ctr: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.ctr, 0) / data.length) * + 10000, + ) / 100 + : 0, + avg_position: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.position, 0) / data.length) * + 10, + ) / 10 + : 0, + }, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/gsc/pages.ts b/packages/mcp/src/tools/gsc/pages.ts new file mode 100644 index 000000000..d2f3124c2 --- /dev/null +++ b/packages/mcp/src/tools/gsc/pages.ts @@ -0,0 +1,57 @@ +import { getGscPageDetails, getGscPages } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +export function registerGscPageTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'gsc_get_top_pages', + 'Get the top-performing pages from Google Search Console, ranked by clicks. Includes impressions, CTR, and average position for each page.', + { + projectId: projectIdSchema(context), + ...zDateRange, + limit: z + .number() + .min(1) + .max(1000) + .default(100) + .optional() + .describe('Maximum number of pages to return (1-1000, default 100)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscPages(projectId, startDate, endDate, limit ?? 100); + }), + ); + + server.tool( + 'gsc_get_page_details', + 'Get detailed Search Console performance for a specific page: time-series of clicks/impressions/CTR/position plus all queries that drive traffic to that page.', + { + projectId: projectIdSchema(context), + ...zDateRange, + page: z + .string() + .url() + .describe('The full page URL to get details for (e.g. https://example.com/blog/post)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, page }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscPageDetails(projectId, page, startDate, endDate); + }), + ); +} diff --git a/packages/mcp/src/tools/gsc/queries.ts b/packages/mcp/src/tools/gsc/queries.ts new file mode 100644 index 000000000..a01acf49c --- /dev/null +++ b/packages/mcp/src/tools/gsc/queries.ts @@ -0,0 +1,166 @@ +import { getGscQueryDetails, getGscQueries } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + resolveProjectId, + withErrorHandling, + zDateRange, +} from '../shared'; + +export interface GscQueryOpportunity { + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + opportunity_score: number; + reason: string; +} + +/** + * Identify low-hanging-fruit queries: + * - Position between 4-20 (ranking but not on page 1 top 3) + * - Reasonable impression volume (signal of real search demand) + * - CTR below benchmark for that position (room to improve) + * + * Opportunity score = impressions * (1 / position) — higher is better + */ +function computeOpportunities( + queries: Array<{ + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }>, +): GscQueryOpportunity[] { + // Expected CTR benchmarks by position bucket + const ctrBenchmarks: Record = { + '1': 0.28, + '2': 0.15, + '3': 0.11, + '4-6': 0.065, + '7-10': 0.035, + '11-20': 0.012, + }; + + function getBenchmark(position: number): number { + if (position <= 1) return ctrBenchmarks['1'] ?? 0.28; + if (position <= 2) return ctrBenchmarks['2'] ?? 0.15; + if (position <= 3) return ctrBenchmarks['3'] ?? 0.11; + if (position <= 6) return ctrBenchmarks['4-6'] ?? 0.065; + if (position <= 10) return ctrBenchmarks['7-10'] ?? 0.035; + return ctrBenchmarks['11-20'] ?? 0.012; + } + + return queries + .filter((q) => q.position >= 4 && q.position <= 20 && q.impressions >= 50) + .map((q) => { + const benchmark = getBenchmark(q.position); + const ctrGap = Math.max(0, benchmark - q.ctr); + const opportunity_score = + Math.round(q.impressions * (1 / q.position) * (1 + ctrGap) * 100) / + 100; + + let reason: string; + if (q.position <= 6) { + reason = `Position ${q.position.toFixed(1)} — one rank improvement could significantly boost clicks`; + } else if (q.ctr < benchmark * 0.5) { + reason = `CTR (${(q.ctr * 100).toFixed(1)}%) is well below expected ${(benchmark * 100).toFixed(1)}% — title/meta optimization may help`; + } else { + reason = `Position ${q.position.toFixed(1)} with ${q.impressions} impressions — push to page 1 for major gains`; + } + + return { + query: q.query, + clicks: q.clicks, + impressions: q.impressions, + ctr: Math.round(q.ctr * 10000) / 100, + position: Math.round(q.position * 10) / 10, + opportunity_score, + reason, + }; + }) + .sort((a, b) => b.opportunity_score - a.opportunity_score) + .slice(0, 50); +} + +export function registerGscQueryTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'gsc_get_top_queries', + 'Get the top search queries driving traffic from Google Search, ranked by clicks. Includes impressions, CTR, and average position for each query.', + { + projectId: projectIdSchema(context), + ...zDateRange, + limit: z + .number() + .min(1) + .max(1000) + .default(100) + .optional() + .describe('Maximum number of queries to return (1-1000, default 100)'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscQueries(projectId, startDate, endDate, limit ?? 100); + }), + ); + + server.tool( + 'gsc_get_query_opportunities', + 'Identify low-hanging-fruit SEO opportunities: queries ranking on positions 4-20 with meaningful search volume where small improvements could yield significant traffic gains. Ranked by opportunity score.', + { + projectId: projectIdSchema(context), + ...zDateRange, + minImpressions: z + .number() + .min(1) + .default(50) + .optional() + .describe( + 'Minimum impression threshold to filter out low-volume queries (default: 50)', + ), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, minImpressions }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const queries = await getGscQueries(projectId, startDate, endDate, 5000); + const filtered = queries.filter( + (q) => q.impressions >= (minImpressions ?? 50), + ); + const opportunities = computeOpportunities(filtered); + return { + opportunities, + total_analyzed: filtered.length, + min_impressions: minImpressions ?? 50, + }; + }), + ); + + server.tool( + 'gsc_get_query_details', + 'Get detailed Search Console data for a specific search query: time-series performance plus all pages that rank for that query.', + { + projectId: projectIdSchema(context), + ...zDateRange, + query: z + .string() + .describe('The search query to get details for (e.g. "best analytics tools")'), + }, + async ({ projectId: inputProjectId, startDate: sd, endDate: ed, query }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + return getGscQueryDetails(projectId, query, startDate, endDate); + }), + ); +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts new file mode 100644 index 000000000..e94b1ca2d --- /dev/null +++ b/packages/mcp/src/tools/index.ts @@ -0,0 +1,61 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpAuthContext } from '../auth'; +import { registerActiveUserTools } from './analytics/active-users'; +import { registerEngagementTools } from './analytics/engagement'; +import { registerEventNameTools } from './analytics/event-names'; +import { registerEventTools } from './analytics/events'; +import { registerFunnelTools } from './analytics/funnel'; +import { registerGroupTools } from './analytics/groups'; +import { registerOverviewTools } from './analytics/overview'; +import { registerPagePerformanceTools } from './analytics/page-performance'; +import { registerPageTools } from './analytics/pages'; +import { registerProfileMetricTools } from './analytics/profile-metrics'; +import { registerProfileTools } from './analytics/profiles'; +import { registerPropertyValueTools } from './analytics/property-values'; +import { registerRetentionTools } from './analytics/retention'; +import { registerSessionTools } from './analytics/sessions'; +import { registerTrafficTools } from './analytics/traffic'; +import { registerUserFlowTools } from './analytics/user-flow'; +import { registerGscCannibalizationTools } from './gsc/cannibalization'; +import { registerGscOverviewTools } from './gsc/overview'; +import { registerGscPageTools } from './gsc/pages'; +import { registerGscQueryTools } from './gsc/queries'; + +export function registerAllTools( + server: McpServer, + context: McpAuthContext, +): void { + // Analytics — discovery (call these first to understand the data) + registerEventNameTools(server, context); + registerPropertyValueTools(server, context); + + // Analytics — event data + registerEventTools(server, context); + registerSessionTools(server, context); + + // Analytics — profiles + registerProfileTools(server, context); + registerProfileMetricTools(server, context); + + // Analytics — groups (B2B) + registerGroupTools(server, context); + + // Analytics — aggregated metrics + registerOverviewTools(server, context); + registerActiveUserTools(server, context); + registerPageTools(server, context); + registerPagePerformanceTools(server, context); + registerTrafficTools(server, context); + + // Analytics — user behavior + registerFunnelTools(server, context); + registerRetentionTools(server, context); + registerEngagementTools(server, context); + registerUserFlowTools(server, context); + + // Google Search Console + registerGscOverviewTools(server, context); + registerGscPageTools(server, context); + registerGscQueryTools(server, context); + registerGscCannibalizationTools(server, context); +} diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts new file mode 100644 index 000000000..84cba7a6c --- /dev/null +++ b/packages/mcp/src/tools/shared.ts @@ -0,0 +1,93 @@ +import type { McpAuthContext } from '../auth'; +import { z } from 'zod'; + +/** + * Build the projectId portion of an input schema. + * + * - Root clients must supply a projectId per call (multi-project access). + * - Read clients have it fixed in context — it's not included in the schema. + */ +export function projectIdSchema(context: McpAuthContext) { + return context.projectId === null + ? z + .string() + .uuid() + .describe( + 'Project ID to query (required for organization-level access)', + ) + : z.string().uuid().optional(); +} + +/** + * Resolve the effective projectId from context + optional tool input. + */ +export function resolveProjectId( + context: McpAuthContext, + inputProjectId: string | undefined, +): string { + if (context.projectId !== null) { + return context.projectId; + } + if (!inputProjectId) { + throw new Error( + 'projectId is required when using a root (organization-level) client', + ); + } + return inputProjectId; +} + +/** + * Zod schema for common date range inputs. Both fields are optional and + * default to the last 30 days when omitted. + */ +export const zDateRange = { + startDate: z + .string() + .optional() + .describe('Start date in YYYY-MM-DD format (e.g. 2024-01-01). Defaults to 30 days ago.'), + endDate: z + .string() + .optional() + .describe('End date in YYYY-MM-DD format (e.g. 2024-03-31). Defaults to today.'), +}; + +/** + * Resolve a date range, defaulting to the last 30 days if not provided. + */ +export function resolveDateRange( + startDate?: string, + endDate?: string, +): { startDate: string; endDate: string } { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(Date.now() - 30 * 86400_000).toISOString().slice(0, 10); + return { startDate: start, endDate: end }; +} + +/** + * Serialize a tool result to MCP content format. + */ +export function toText(data: unknown): { content: [{ type: 'text'; text: string }] } { + return { + content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], + }; +} + +/** + * Wrap a tool handler to catch errors and return them as MCP error content. + */ +export async function withErrorHandling( + fn: () => Promise, +): Promise<{ content: [{ type: 'text'; text: string }]; isError?: boolean }> { + try { + const result = await fn(); + return toText(result); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [{ type: 'text' as const, text: `Error: ${message}` }], + isError: true, + }; + } +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 000000000..6b21fa2a3 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@openpanel/tsconfig/base.json", + "compilerOptions": { + "baseUrl": ".", + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "strictNullChecks": true + }, + "include": ["."], + "exclude": ["node_modules"] +} diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index 20835bd9f..ba8005031 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -2,7 +2,7 @@ import { TRPCError, initTRPC } from '@trpc/server'; import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify'; import { has } from 'ramda'; import superjson from 'superjson'; -import { ZodError } from 'zod'; +import { ZodError, z } from 'zod'; import { COOKIE_OPTIONS, type SessionValidationResult } from '@openpanel/auth'; import { runWithAlsSession } from '@openpanel/db'; @@ -68,7 +68,7 @@ const t = initTRPC.context().create({ data: { ...shape.data, zodError: - error.cause instanceof ZodError ? error.cause.flatten() : null, + error.cause instanceof ZodError ? z.flattenError(error.cause) : null, }, }; }, diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 2b27593e5..a9897002f 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -360,14 +360,14 @@ export const zSlackConfig = z .object({ type: z.literal('slack'), }) - .merge(zSlackAuthResponse); + .extend(zSlackAuthResponse.shape); export type ISlackConfig = z.infer; export const zWebhookConfig = z.object({ type: z.literal('webhook'), url: z.string().url(), - headers: z.record(z.string()), + headers: z.record(z.string(), z.string()), payload: z.record(z.string(), z.unknown()).optional(), mode: z.enum(['message', 'javascript']).default('message'), javascriptTemplate: z.string().optional(), @@ -405,17 +405,13 @@ const zCreateIntegration = z.object({ export const zCreateSlackIntegration = zCreateIntegration; -export const zCreateWebhookIntegration = zCreateIntegration.merge( - z.object({ - config: zWebhookConfig, - }), -); +export const zCreateWebhookIntegration = zCreateIntegration.extend({ + config: zWebhookConfig, +}); -export const zCreateDiscordIntegration = zCreateIntegration.merge( - z.object({ - config: zDiscordConfig, - }), -); +export const zCreateDiscordIntegration = zCreateIntegration.extend({ + config: zDiscordConfig, +}); export const zNotificationRuleEventConfig = z.object({ type: z.literal('events'), @@ -553,7 +549,7 @@ export const zCreateGroup = z.object({ projectId: z.string(), type: z.string().min(1), name: z.string().min(1), - properties: z.record(z.string()).default({}), + properties: z.record(z.string(), z.string()).default({}), }); export type ICreateGroup = z.infer; @@ -562,7 +558,7 @@ export const zUpdateGroup = z.object({ projectId: z.string(), type: z.string().min(1).optional(), name: z.string().min(1).optional(), - properties: z.record(z.string()).optional(), + properties: z.record(z.string(), z.string()).optional(), }); export type IUpdateGroup = z.infer; diff --git a/packages/validation/src/track.validation.ts b/packages/validation/src/track.validation.ts index 0bb9dcc24..8d479b688 100644 --- a/packages/validation/src/track.validation.ts +++ b/packages/validation/src/track.validation.ts @@ -6,7 +6,7 @@ export const zGroupPayload = z.object({ id: z.string().min(1), type: z.string().min(1), name: z.string().min(1), - properties: z.record(z.unknown()).optional(), + properties: z.record(z.string(), z.unknown()).optional(), }); export const zAssignGroupPayload = z.object({ @@ -56,7 +56,7 @@ export const zIdentifyPayload = z.object({ lastName: z.string().optional(), email: z.string().email().optional(), avatar: z.string().url().optional(), - properties: z.record(z.unknown()).optional(), + properties: z.record(z.string(), z.unknown()).optional(), }); export const zIncrementPayload = z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46a4f9acb..7d011727c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ catalogs: specifier: ^5.9.3 version: 5.9.3 zod: - specifier: ^3.24.2 - version: 3.24.2 + specifier: ^4.0.0 + version: 4.1.13 overrides: rolldown: 1.0.0-beta.43 @@ -117,10 +117,10 @@ importers: dependencies: '@ai-sdk/anthropic': specifier: ^1.2.10 - version: 1.2.10(zod@3.24.2) + version: 1.2.10(zod@4.1.13) '@ai-sdk/openai': specifier: ^1.3.12 - version: 1.3.12(zod@3.24.2) + version: 1.3.12(zod@4.1.13) '@fastify/compress': specifier: ^8.1.0 version: 8.1.0 @@ -163,6 +163,9 @@ importers: '@openpanel/logger': specifier: workspace:* version: link:../../packages/logger + '@openpanel/mcp': + specifier: workspace:* + version: link:../../packages/mcp '@openpanel/payments': specifier: workspace:* version: link:../../packages/payments @@ -183,7 +186,7 @@ importers: version: 11.6.0(typescript@5.9.3) ai: specifier: ^4.2.10 - version: 4.2.10(react@19.2.3)(zod@3.24.2) + version: 4.2.10(react@19.2.3)(zod@4.1.13) fast-json-stable-hash: specifier: ^1.0.3 version: 1.0.3 @@ -228,7 +231,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@faker-js/faker': specifier: ^9.0.1 @@ -367,7 +370,7 @@ importers: version: 1.0.7(tailwindcss@4.1.17) zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@tailwindcss/postcss': specifier: ^4.1.17 @@ -404,7 +407,7 @@ importers: dependencies: '@ai-sdk/react': specifier: ^1.2.5 - version: 1.2.5(react@19.2.3)(zod@3.24.2) + version: 1.2.5(react@19.2.3)(zod@4.1.13) '@codemirror/commands': specifier: ^6.7.0 version: 6.10.1 @@ -617,7 +620,7 @@ importers: version: 7.4.3 ai: specifier: ^4.2.10 - version: 4.2.10(react@19.2.3)(zod@3.24.2) + version: 4.2.10(react@19.2.3)(zod@4.1.13) bind-event-listener: specifier: ^3.0.0 version: 3.0.0 @@ -812,7 +815,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -1180,7 +1183,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -1226,7 +1229,7 @@ importers: version: 0.0.5(react-email@3.0.4(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -1297,7 +1300,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/logger': specifier: workspace:* @@ -1400,6 +1403,40 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.12.0 + version: 1.27.1(zod@4.1.13) + '@openpanel/common': + specifier: workspace:* + version: link:../common + '@openpanel/db': + specifier: workspace:* + version: link:../db + '@openpanel/logger': + specifier: workspace:* + version: link:../logger + '@openpanel/redis': + specifier: workspace:* + version: link:../redis + '@openpanel/validation': + specifier: workspace:* + version: link:../validation + zod: + specifier: 'catalog:' + version: 4.1.13 + devDependencies: + '@openpanel/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: 'catalog:' + version: 24.10.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + packages/payments: dependencies: '@polar-sh/sdk': @@ -1753,7 +1790,7 @@ importers: version: 9.0.1 zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -1781,7 +1818,7 @@ importers: version: link:../constants zod: specifier: 'catalog:' - version: 3.24.2 + version: 4.1.13 devDependencies: '@openpanel/tsconfig': specifier: workspace:* @@ -4871,6 +4908,12 @@ packages: '@hapi/topo@5.1.0': resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hookform/resolvers@3.3.4': resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==} peerDependencies: @@ -5265,6 +5308,16 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2': resolution: {integrity: sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==} cpu: [arm64] @@ -10186,6 +10239,9 @@ packages: ajv@8.12.0: resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + alien-signals@3.1.1: resolution: {integrity: sha512-ogkIWbVrLwKtHY6oOAXaYkAxP+cTH7V5FZ5+Tm4NZFd8VDZ6uNMDrfzqctTZ42eTMCSR3ne3otpcxmqSnFfPYA==} @@ -12143,6 +12199,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exec-async@2.2.0: resolution: {integrity: sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw==} @@ -12209,6 +12273,12 @@ packages: resolution: {integrity: sha512-lTqIrKOUTKHLdTuAaJzZihi1v7F8Ix1dOXVWMpToDy9zPC/s+fet0fbyXdFUxYsCUyuEDIB9tvejrTYZk8Hm0Q==} hasBin: true + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.18.2: resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} engines: {node: '>= 0.10.0'} @@ -12816,11 +12886,11 @@ packages: glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -13028,6 +13098,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hono@4.12.9: + resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -13227,6 +13301,10 @@ packages: resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} engines: {node: '>=12.22.0'} + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ip-regex@2.1.0: resolution: {integrity: sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==} engines: {node: '>=4'} @@ -13618,6 +13696,9 @@ packages: join-component@1.1.0: resolution: {integrity: sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -13698,6 +13779,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -15459,6 +15543,10 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-dir@3.0.0: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} @@ -19032,15 +19120,17 @@ packages: peerDependencies: zod: ^3.24.1 + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod-to-ts@1.2.0: resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} peerDependencies: typescript: ^4.9.4 || ^5.0.2 zod: ^3 - zod@3.24.2: - resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -19052,31 +19142,31 @@ packages: snapshots: - '@ai-sdk/anthropic@1.2.10(zod@3.24.2)': + '@ai-sdk/anthropic@1.2.10(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.7(zod@4.1.13) + zod: 4.1.13 - '@ai-sdk/openai@1.3.12(zod@3.24.2)': + '@ai-sdk/openai@1.3.12(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.3 - '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) - zod: 3.24.2 + '@ai-sdk/provider-utils': 2.2.7(zod@4.1.13) + zod: 4.1.13 - '@ai-sdk/provider-utils@2.2.3(zod@3.24.2)': + '@ai-sdk/provider-utils@2.2.3(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.0 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 3.24.2 + zod: 4.1.13 - '@ai-sdk/provider-utils@2.2.7(zod@3.24.2)': + '@ai-sdk/provider-utils@2.2.7(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 - zod: 3.24.2 + zod: 4.1.13 '@ai-sdk/provider@1.1.0': dependencies: @@ -19086,22 +19176,22 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.5(react@19.2.3)(zod@3.24.2)': + '@ai-sdk/react@1.2.5(react@19.2.3)(zod@4.1.13)': dependencies: - '@ai-sdk/provider-utils': 2.2.3(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.4(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.3(zod@4.1.13) + '@ai-sdk/ui-utils': 1.2.4(zod@4.1.13) react: 19.2.3 swr: 2.3.3(react@19.2.3) throttleit: 2.1.0 optionalDependencies: - zod: 3.24.2 + zod: 4.1.13 - '@ai-sdk/ui-utils@1.2.4(zod@3.24.2)': + '@ai-sdk/ui-utils@1.2.4(zod@4.1.13)': dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.3(zod@3.24.2) - zod: 3.24.2 - zod-to-json-schema: 3.24.5(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.3(zod@4.1.13) + zod: 4.1.13 + zod-to-json-schema: 3.24.5(zod@4.1.13) '@alloc/quick-lru@5.2.0': {} @@ -23291,6 +23381,10 @@ snapshots: dependencies: '@hapi/hoek': 9.3.0 + '@hono/node-server@1.19.11(hono@4.12.9)': + dependencies: + hono: 4.12.9 + '@hookform/resolvers@3.3.4(react-hook-form@7.50.1(react@19.2.3))': dependencies: react-hook-form: 7.50.1(react@19.2.3) @@ -23728,6 +23822,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.27.1(zod@4.1.13)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.1.13 + zod-to-json-schema: 3.25.1(zod@4.1.13) + transitivePeerDependencies: + - supports-color + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2': optional: true @@ -29836,15 +29952,15 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@4.2.10(react@19.2.3)(zod@3.24.2): + ai@4.2.10(react@19.2.3)(zod@4.1.13): dependencies: '@ai-sdk/provider': 1.1.0 - '@ai-sdk/provider-utils': 2.2.3(zod@3.24.2) - '@ai-sdk/react': 1.2.5(react@19.2.3)(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.4(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.3(zod@4.1.13) + '@ai-sdk/react': 1.2.5(react@19.2.3)(zod@4.1.13) + '@ai-sdk/ui-utils': 1.2.4(zod@4.1.13) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 - zod: 3.24.2 + zod: 4.1.13 optionalDependencies: react: 19.2.3 @@ -29852,6 +29968,10 @@ snapshots: optionalDependencies: ajv: 8.12.0 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@8.12.0: dependencies: fast-deep-equal: 3.1.3 @@ -29859,6 +29979,13 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + alien-signals@3.1.1: {} anser@1.4.10: {} @@ -32384,6 +32511,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + exec-async@2.2.0: {} execa@1.0.0: @@ -32410,7 +32543,7 @@ snapshots: execa@8.0.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 8.0.1 human-signals: 5.0.0 is-stream: 3.0.0 @@ -32505,6 +32638,11 @@ snapshots: - supports-color - utf-8-validate + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@4.18.2: dependencies: accepts: 1.3.8 @@ -33704,6 +33842,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.12.9: {} + hookable@5.5.3: {} hosted-git-info@3.0.8: @@ -33938,6 +34078,8 @@ snapshots: transitivePeerDependencies: - supports-color + ip-address@10.1.0: {} + ip-regex@2.1.0: {} ipaddr.js@1.9.1: {} @@ -34290,6 +34432,8 @@ snapshots: join-component@1.1.0: {} + jose@6.2.2: {} + joycon@3.1.1: {} js-beautify@1.15.1: @@ -34430,6 +34574,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stringify-safe@5.0.1: {} @@ -36869,6 +37015,8 @@ snapshots: pirates@4.0.6: {} + pkce-challenge@5.0.1: {} + pkg-dir@3.0.0: dependencies: find-up: 3.0.0 @@ -38524,12 +38672,12 @@ snapshots: escape-html: 1.0.3 etag: 1.8.1 fresh: 2.0.0 - http-errors: 2.0.0 + http-errors: 2.0.1 mime-types: 3.0.1 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -40875,21 +41023,23 @@ snapshots: dependencies: bops: 0.1.1 - zod-to-json-schema@3.24.5(zod@3.24.2): - dependencies: - zod: 3.24.2 - zod-to-json-schema@3.24.5(zod@3.25.76): dependencies: zod: 3.25.76 + zod-to-json-schema@3.24.5(zod@4.1.13): + dependencies: + zod: 4.1.13 + + zod-to-json-schema@3.25.1(zod@4.1.13): + dependencies: + zod: 4.1.13 + zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.3 zod: 3.25.76 - zod@3.24.2: {} - zod@3.25.76: {} zod@4.1.13: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8fee52f47..936fbe8e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,7 @@ packages: # Define a catalog of version ranges. catalog: - zod: ^3.24.2 + zod: ^4.0.0 react: ^19.2.3 "@types/react": ^19.2.3 "react-dom": ^19.2.3 From 7e0bd149aa9eba4951d96bcc2a31cc2748afbdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 24 Mar 2026 21:56:11 +0100 Subject: [PATCH 02/18] wip --- .github/workflows/docker-build.yml | 20 +- docker-compose.yml | 3 + docker/clickhouse/clickhouse-user-config.xml | 2 +- packages/db/src/prisma-client.ts | 418 ++++++------ packages/mcp/package.json | 7 +- .../mcp/src/integration/clickhouse-schema.sql | 166 +++++ packages/mcp/src/integration/setup.ts | 304 +++++++++ packages/mcp/src/integration/tools.test.ts | 628 ++++++++++++++++++ .../src/tools/analytics/engagement.test.ts | 83 +++ .../tools/analytics/page-performance.test.ts | 195 ++++++ .../src/tools/analytics/profile-metrics.ts | 3 +- .../mcp/src/tools/analytics/profiles.test.ts | 141 ++++ packages/mcp/src/tools/shared.test.ts | 64 ++ packages/mcp/vitest.config.ts | 8 + vitest.shared.ts | 9 +- 15 files changed, 1815 insertions(+), 236 deletions(-) create mode 100644 packages/mcp/src/integration/clickhouse-schema.sql create mode 100644 packages/mcp/src/integration/setup.ts create mode 100644 packages/mcp/src/integration/tools.test.ts create mode 100644 packages/mcp/src/tools/analytics/engagement.test.ts create mode 100644 packages/mcp/src/tools/analytics/page-performance.test.ts create mode 100644 packages/mcp/src/tools/analytics/profiles.test.ts create mode 100644 packages/mcp/src/tools/shared.test.ts create mode 100644 packages/mcp/vitest.config.ts diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index fcfac62d3..1af6c7477 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -45,6 +45,20 @@ jobs: --health-interval 5s --health-timeout 3s --health-retries 20 + clickhouse: + image: clickhouse/clickhouse-server:26.1.3.52 + ports: + - 8123:8123 + env: + CLICKHOUSE_DB: openpanel + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + options: >- + --health-cmd "wget -qO- http://localhost:8123/ping || exit 1" + --health-interval 5s + --health-timeout 3s + --health-retries 20 steps: - uses: actions/checkout@v4 @@ -75,15 +89,15 @@ jobs: - name: Codegen run: pnpm codegen + - name: Run MCP tests + run: pnpm --filter @openpanel/mcp test:run + # - name: Run Biome # run: pnpm lint # - name: Run TypeScript checks # run: pnpm typecheck - # - name: Run tests - # run: pnpm test - build-and-push-api: permissions: packages: write diff --git a/docker-compose.yml b/docker-compose.yml index 0511328fd..42fd315f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,6 +30,9 @@ services: - ./docker/clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/op-config.xml - ./docker/clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/op-user-config.xml - ./docker/clickhouse/init-db.sh:/docker-entrypoint-initdb.d/init-db.sh:ro + environment: + CLICKHOUSE_DB: openpanel + CLICKHOUSE_SKIP_USER_SETUP: 1 ulimits: nofile: soft: 262144 diff --git a/docker/clickhouse/clickhouse-user-config.xml b/docker/clickhouse/clickhouse-user-config.xml index c8fdca290..56e2b31b3 100644 --- a/docker/clickhouse/clickhouse-user-config.xml +++ b/docker/clickhouse/clickhouse-user-config.xml @@ -8,7 +8,7 @@ - default + ::/0 diff --git a/packages/db/src/prisma-client.ts b/packages/db/src/prisma-client.ts index befe3f742..ece87ad1a 100644 --- a/packages/db/src/prisma-client.ts +++ b/packages/db/src/prisma-client.ts @@ -1,12 +1,4 @@ -import { createLogger } from '@openpanel/logger'; -import { readReplicas } from '@prisma/extension-read-replicas'; -import { - type Organization, - Prisma, - PrismaClient, -} from './generated/prisma/client'; -import { logger } from './logger'; -import { sessionConsistency } from './session-consistency'; +import { type Organization, PrismaClient } from './generated/prisma/client'; export * from './generated/prisma/client'; @@ -14,7 +6,7 @@ const isWillBeCanceled = ( organization: Pick< Organization, 'subscriptionStatus' | 'subscriptionCanceledAt' | 'subscriptionEndsAt' - >, + > ) => organization.subscriptionStatus === 'active' && organization.subscriptionCanceledAt && @@ -24,7 +16,7 @@ const isCanceled = ( organization: Pick< Organization, 'subscriptionStatus' | 'subscriptionCanceledAt' - >, + > ) => organization.subscriptionStatus === 'canceled' && organization.subscriptionCanceledAt && @@ -33,254 +25,228 @@ const isCanceled = ( const getPrismaClient = () => { const prisma = new PrismaClient({ log: ['error'], - }) - .$extends({ - query: { - async $allOperations({ operation, model, args, query }) { - if ( - operation === 'create' || - operation === 'update' || - operation === 'delete' - ) { - // logger.info('Prisma operation', { - // operation, - // args, - // model, - // }); - } - return query(args); - }, - }, - }) + }).$extends({ + result: { + organization: { + subscriptionStatus: { + needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return 'active'; + } - .$extends(sessionConsistency()) - .$extends({ - result: { - organization: { - subscriptionStatus: { - needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return 'active'; - } - - return org.subscriptionStatus || 'trialing'; - }, + return org.subscriptionStatus || 'trialing'; }, - hasSubscription: { - needs: { subscriptionStatus: true, subscriptionEndsAt: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + }, + hasSubscription: { + needs: { subscriptionStatus: true, subscriptionEndsAt: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - if ( - [null, 'canceled', 'trialing'].includes(org.subscriptionStatus) - ) { - return false; - } + if ( + [null, 'canceled', 'trialing'].includes(org.subscriptionStatus) + ) { + return false; + } - return true; - }, + return true; }, - slug: { - needs: { id: true }, - compute(org) { - return org.id; - }, + }, + slug: { + needs: { id: true }, + compute(org) { + return org.id; + }, + }, + subscriptionChartEndDate: { + needs: { + subscriptionEndsAt: true, + subscriptionPeriodEventsCountExceededAt: true, }, - subscriptionChartEndDate: { - needs: { - subscriptionEndsAt: true, - subscriptionPeriodEventsCountExceededAt: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return null; - } + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } - if ( - org.subscriptionEndsAt && + if ( + org.subscriptionEndsAt && + org.subscriptionPeriodEventsCountExceededAt + ) { + return org.subscriptionEndsAt > org.subscriptionPeriodEventsCountExceededAt - ) { - return org.subscriptionEndsAt > - org.subscriptionPeriodEventsCountExceededAt - ? org.subscriptionPeriodEventsCountExceededAt - : org.subscriptionEndsAt; - } + ? org.subscriptionPeriodEventsCountExceededAt + : org.subscriptionEndsAt; + } - if (org.subscriptionEndsAt) { - return org.subscriptionEndsAt; - } + if (org.subscriptionEndsAt) { + return org.subscriptionEndsAt; + } - // Hedge against edge cases :D - return new Date(Date.now() + 1000 * 60 * 60 * 24); - }, + // Hedge against edge cases :D + return new Date(Date.now() + 1000 * 60 * 60 * 24); }, - isActive: { - needs: { - subscriptionStatus: true, - subscriptionEndsAt: true, - subscriptionCanceledAt: true, - }, - compute(org) { - return ( - org.subscriptionStatus === 'active' && - org.subscriptionEndsAt && - org.subscriptionEndsAt > new Date() && - !isCanceled(org) && - !isWillBeCanceled(org) - ); - }, + }, + isActive: { + needs: { + subscriptionStatus: true, + subscriptionEndsAt: true, + subscriptionCanceledAt: true, }, - isTrial: { - needs: { subscriptionStatus: true, subscriptionEndsAt: true }, - compute(org) { - const isSubscriptionInFuture = - org.subscriptionEndsAt && org.subscriptionEndsAt > new Date(); - return ( - (org.subscriptionStatus === 'trialing' || - org.subscriptionStatus === null) && - isSubscriptionInFuture - ); - }, + compute(org) { + return ( + org.subscriptionStatus === 'active' && + org.subscriptionEndsAt && + org.subscriptionEndsAt > new Date() && + !isCanceled(org) && + !isWillBeCanceled(org) + ); }, - isCanceled: { - needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + }, + isTrial: { + needs: { subscriptionStatus: true, subscriptionEndsAt: true }, + compute(org) { + const isSubscriptionInFuture = + org.subscriptionEndsAt && org.subscriptionEndsAt > new Date(); + return ( + (org.subscriptionStatus === 'trialing' || + org.subscriptionStatus === null) && + isSubscriptionInFuture + ); + }, + }, + isCanceled: { + needs: { subscriptionStatus: true, subscriptionCanceledAt: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - return isCanceled(org); - }, + return isCanceled(org); }, - isWillBeCanceled: { - needs: { - subscriptionStatus: true, - subscriptionCanceledAt: true, - subscriptionEndsAt: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + }, + isWillBeCanceled: { + needs: { + subscriptionStatus: true, + subscriptionCanceledAt: true, + subscriptionEndsAt: true, + }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - return isWillBeCanceled(org); - }, + return isWillBeCanceled(org); + }, + }, + isExpired: { + needs: { + subscriptionEndsAt: true, + subscriptionStatus: true, + subscriptionCanceledAt: true, }, - isExpired: { - needs: { - subscriptionEndsAt: true, - subscriptionStatus: true, - subscriptionCanceledAt: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - if (isCanceled(org)) { - return false; - } + if (isCanceled(org)) { + return false; + } - if (isWillBeCanceled(org)) { - return false; - } + if (isWillBeCanceled(org)) { + return false; + } - return ( - org.subscriptionEndsAt && org.subscriptionEndsAt < new Date() - ); - }, + return ( + org.subscriptionEndsAt && org.subscriptionEndsAt < new Date() + ); + }, + }, + isExceeded: { + needs: { + subscriptionPeriodEventsCount: true, + subscriptionPeriodEventsLimit: true, }, - isExceeded: { - needs: { - subscriptionPeriodEventsCount: true, - subscriptionPeriodEventsLimit: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return false; - } + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return false; + } - return ( - org.subscriptionPeriodEventsCount > - org.subscriptionPeriodEventsLimit - ); - }, + return ( + org.subscriptionPeriodEventsCount > + org.subscriptionPeriodEventsLimit + ); }, - subscriptionCurrentPeriodStart: { - needs: { subscriptionStartsAt: true, subscriptionInterval: true }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return null; - } + }, + subscriptionCurrentPeriodStart: { + needs: { subscriptionStartsAt: true, subscriptionInterval: true }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } - if (!org.subscriptionStartsAt) { - return null; - } + if (!org.subscriptionStartsAt) { + return null; + } - if (org.subscriptionInterval === 'year') { - const startDay = org.subscriptionStartsAt.getUTCDate(); - const now = new Date(); - return new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - startDay, - 0, - 0, - 0, - 0, - ), - ); - } + if (org.subscriptionInterval === 'year') { + const startDay = org.subscriptionStartsAt.getUTCDate(); + const now = new Date(); + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + startDay, + 0, + 0, + 0, + 0 + ) + ); + } - return org.subscriptionStartsAt; - }, + return org.subscriptionStartsAt; }, - subscriptionCurrentPeriodEnd: { - needs: { - subscriptionStartsAt: true, - subscriptionEndsAt: true, - subscriptionInterval: true, - }, - compute(org) { - if (process.env.SELF_HOSTED === 'true') { - return null; - } + }, + subscriptionCurrentPeriodEnd: { + needs: { + subscriptionStartsAt: true, + subscriptionEndsAt: true, + subscriptionInterval: true, + }, + compute(org) { + if (process.env.SELF_HOSTED === 'true') { + return null; + } - if (!org.subscriptionStartsAt) { - return null; - } + if (!org.subscriptionStartsAt) { + return null; + } - if (org.subscriptionInterval === 'year') { - const startDay = org.subscriptionStartsAt.getUTCDate(); - const now = new Date(); - return new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth() + 1, - startDay - 1, - 0, - 0, - 0, - 0, - ), - ); - } + if (org.subscriptionInterval === 'year') { + const startDay = org.subscriptionStartsAt.getUTCDate(); + const now = new Date(); + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth() + 1, + startDay - 1, + 0, + 0, + 0, + 0 + ) + ); + } - return org.subscriptionEndsAt; - }, + return org.subscriptionEndsAt; }, }, }, - }) - .$extends( - readReplicas({ - url: process.env.DATABASE_URL_REPLICA ?? process.env.DATABASE_URL!, - }), - ); + }, + }); return prisma; }; diff --git a/packages/mcp/package.json b/packages/mcp/package.json index be1fdecaf..442e002ab 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -7,7 +7,9 @@ ".": "./index.ts" }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", @@ -21,6 +23,7 @@ "devDependencies": { "@openpanel/tsconfig": "workspace:*", "@types/node": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^1.0.0" } } diff --git a/packages/mcp/src/integration/clickhouse-schema.sql b/packages/mcp/src/integration/clickhouse-schema.sql new file mode 100644 index 000000000..c3426b259 --- /dev/null +++ b/packages/mcp/src/integration/clickhouse-schema.sql @@ -0,0 +1,166 @@ +-- Minimal ClickHouse schema for MCP integration tests. +-- Creates only the tables that MCP tools query against. +-- Run against a fresh ClickHouse instance before executing the integration test suite. + +CREATE DATABASE IF NOT EXISTS openpanel; + +CREATE TABLE IF NOT EXISTS openpanel.events +( + id UUID DEFAULT generateUUIDv4(), + name LowCardinality(String), + sdk_name LowCardinality(String), + sdk_version LowCardinality(String), + device_id String, + profile_id String, + project_id String, + session_id String, + groups Array(String) DEFAULT [], + path String, + origin String, + referrer String, + referrer_name String, + referrer_type LowCardinality(String), + revenue UInt64, + duration UInt64, + properties Map(String, String), + created_at DateTime64(3), + country LowCardinality(FixedString(2)), + city String, + region LowCardinality(String), + longitude Nullable(Float32), + latitude Nullable(Float32), + os LowCardinality(String), + os_version LowCardinality(String), + browser LowCardinality(String), + browser_version LowCardinality(String), + device LowCardinality(String), + brand LowCardinality(String), + model LowCardinality(String), + imported_at Nullable(DateTime) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(created_at) +ORDER BY (project_id, toDate(created_at), created_at, name) +SETTINGS index_granularity = 8192; + +CREATE TABLE IF NOT EXISTS openpanel.profiles +( + id String, + is_external Bool, + first_name String, + last_name String, + email String, + avatar String, + properties Map(String, String), + project_id String, + groups Array(String) DEFAULT [], + created_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(created_at) +PARTITION BY toYYYYMM(created_at) +ORDER BY (project_id, id) +SETTINGS index_granularity = 8192; + +CREATE TABLE IF NOT EXISTS openpanel.sessions +( + id String, + project_id String, + profile_id String, + device_id String, + created_at DateTime64(3), + ended_at DateTime64(3), + is_bounce Bool, + entry_origin LowCardinality(String), + entry_path String, + exit_origin LowCardinality(String), + exit_path String, + screen_view_count Int32, + revenue Float64, + event_count Int32, + duration UInt32, + country LowCardinality(FixedString(2)), + region LowCardinality(String), + city String, + longitude Nullable(Float32), + latitude Nullable(Float32), + device LowCardinality(String), + brand LowCardinality(String), + model LowCardinality(String), + browser LowCardinality(String), + browser_version LowCardinality(String), + os LowCardinality(String), + os_version LowCardinality(String), + utm_medium String, + utm_source String, + utm_campaign String, + utm_content String, + utm_term String, + referrer String, + referrer_name String, + referrer_type LowCardinality(String), + sign Int8, + version UInt64, + properties Map(String, String) +) +ENGINE = VersionedCollapsingMergeTree(sign, version) +PARTITION BY toYYYYMM(created_at) +ORDER BY (project_id, id, toDate(created_at), profile_id) +SETTINGS index_granularity = 8192; + +-- Materialized view tables (simplified as regular tables for testing) +-- The real ones are populated by triggers on events; these just need to exist. + +CREATE TABLE IF NOT EXISTS openpanel.distinct_event_names_mv +( + project_id String, + name LowCardinality(String), + count UInt64 +) +ENGINE = AggregatingMergeTree +ORDER BY (project_id, name) +SETTINGS index_granularity = 8192; + +CREATE TABLE IF NOT EXISTS openpanel.event_property_values_mv +( + project_id String, + name LowCardinality(String), + property_key String, + property_value String, + created_at DateTime64(3) +) +ENGINE = MergeTree +ORDER BY (project_id, name, property_key) +SETTINGS index_granularity = 8192; + +CREATE TABLE IF NOT EXISTS openpanel.dau_mv +( + project_id String, + profile_id AggregateFunction(uniq, String), + date Date +) +ENGINE = AggregatingMergeTree +ORDER BY (project_id, date) +SETTINGS index_granularity = 8192; + +CREATE TABLE IF NOT EXISTS openpanel.cohort_events_mv +( + project_id String, + profile_id String, + week Date +) +ENGINE = AggregatingMergeTree +ORDER BY (project_id, week, profile_id) +SETTINGS index_granularity = 8192; + +CREATE TABLE IF NOT EXISTS openpanel.groups +( + id String, + project_id String, + group_id String, + type String, + properties Map(String, String), + created_at DateTime64(3) +) +ENGINE = ReplacingMergeTree(created_at) +ORDER BY (project_id, type, group_id) +SETTINGS index_granularity = 8192; diff --git a/packages/mcp/src/integration/setup.ts b/packages/mcp/src/integration/setup.ts new file mode 100644 index 000000000..4279e3ebe --- /dev/null +++ b/packages/mcp/src/integration/setup.ts @@ -0,0 +1,304 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createClient } from '@openpanel/db'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const TEST_PROJECT_ID = 'mcp-integration-test'; + +function getClient() { + const url = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; + return createClient({ url }); +} + +export async function setup() { + const client = await getClient(); + + // Create tables — strip comment lines first so semicolons inside comments + // don't produce spurious empty statements when splitting. + const sql = readFileSync(join(__dirname, 'clickhouse-schema.sql'), 'utf8'); + const statements = sql + .split('\n') + .filter((line) => !line.trimStart().startsWith('--')) + .join('\n') + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + for (const statement of statements) { + await client.command({ query: statement }); + } + + // Clean up any leftover data from a previous run + await cleanTestData(client); + + const now = new Date(); + // minutesOffset shifts the time within the day so events get distinct timestamps + // (required for ClickHouse windowFunnel strict_increase mode) + const timeAgo = (days: number, minutesOffset = 0) => + new Date(now.getTime() - days * 86_400_000 - minutesOffset * 60_000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ''); + const daysAgo = (n: number) => timeAgo(n); + + // Profiles + await client.insert({ + table: 'openpanel.profiles', + values: [ + // Alice — active recently (event 2 days ago) + { + id: 'profile-alice', + project_id: TEST_PROJECT_ID, + first_name: 'Alice', + last_name: 'Smith', + email: 'alice@example.com', + avatar: '', + is_external: false, + properties: {}, + groups: [], + created_at: daysAgo(60), + }, + // Bob — inactive (no events in last 30 days) + { + id: 'profile-bob', + project_id: TEST_PROJECT_ID, + first_name: 'Bob', + last_name: "O'Brien", + email: 'bob@example.com', + avatar: '', + is_external: false, + properties: { country: 'SE' }, + groups: [], + created_at: daysAgo(90), + }, + // Charlie — performed 'purchase' and has many sessions + { + id: 'profile-charlie', + project_id: TEST_PROJECT_ID, + first_name: 'Charlie', + last_name: 'Brown', + email: 'charlie@example.com', + avatar: '', + is_external: false, + properties: {}, + groups: [], + created_at: daysAgo(30), + }, + ], + format: 'JSONEachRow', + }); + + // Helper to build a minimal event row + function event( + id: string, + name: string, + profileId: string, + sessionId: string, + deviceId: string, + daysBack: number, + overrides: Record = {}, + minutesOffset = 0, + ) { + return { + id, + project_id: TEST_PROJECT_ID, + profile_id: profileId, + name, + session_id: sessionId, + device_id: deviceId, + created_at: timeAgo(daysBack, minutesOffset), + path: '/', + origin: 'https://example.com', + referrer: '', + referrer_name: '', + referrer_type: '', + revenue: 0, + duration: 0, + properties: {}, + groups: [], + country: 'US', + city: '', + region: '', + sdk_name: 'web', + sdk_version: '1.0.0', + os: '', + os_version: '', + browser: 'Chrome', + browser_version: '', + device: 'desktop', + brand: '', + model: '', + ...overrides, + }; + } + + // Events + // Alice (2 days ago): session_start → page_view → session_end + // Charlie (5 days ago): session_start → screen_view → page_view → purchase → session_end + // Bob: no events (inactive) + // + // Charlie's expected metrics: + // sessions=1, screenViews=1, totalEvents=5 + // conversionEvents=2 (page_view + purchase; excludes session_start/screen_view/session_end) + await client.insert({ + table: 'openpanel.events', + values: [ + // Alice — events spaced 2 minutes apart so timestamps are strictly increasing + event('00000000-0000-0000-0000-000000000001', 'session_start', 'profile-alice', 'sess-alice-1', 'dev-alice', 2, {}, 4), + event('00000000-0000-0000-0000-000000000002', 'page_view', 'profile-alice', 'sess-alice-1', 'dev-alice', 2, { path: '/home', browser: 'Chrome' }, 2), + event('00000000-0000-0000-0000-000000000003', 'session_end', 'profile-alice', 'sess-alice-1', 'dev-alice', 2, { duration: 120000 }, 0), + // Charlie — events spaced 5 minutes apart so windowFunnel strict_increase works + event('00000000-0000-0000-0000-000000000004', 'session_start', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { browser: 'Firefox' }, 20), + event('00000000-0000-0000-0000-000000000005', 'screen_view', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { path: '/shop', browser: 'Firefox' }, 15), + event('00000000-0000-0000-0000-000000000006', 'page_view', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { path: '/shop', browser: 'Firefox' }, 10), + event('00000000-0000-0000-0000-000000000007', 'purchase', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { path: '/checkout', revenue: 9900, browser: 'Firefox' }, 5), + event('00000000-0000-0000-0000-000000000008', 'session_end', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { duration: 300000, browser: 'Firefox' }, 0), + ], + format: 'JSONEachRow', + }); + + // Sessions (sign=1 = active row, sign=-1 = collapsed) + await client.insert({ + table: 'openpanel.sessions', + values: [ + { + id: 'sess-alice-1', + project_id: TEST_PROJECT_ID, + profile_id: 'profile-alice', + device_id: 'dev-alice', + created_at: daysAgo(2), + ended_at: daysAgo(2), + is_bounce: false, + entry_origin: 'https://example.com', + entry_path: '/home', + exit_origin: 'https://example.com', + exit_path: '/home', + screen_view_count: 1, + revenue: 0, + event_count: 1, + duration: 120, + country: 'US', + region: '', + city: '', + device: 'desktop', + brand: '', + model: '', + browser: 'Chrome', + browser_version: '', + os: '', + os_version: '', + utm_medium: '', + utm_source: '', + utm_campaign: '', + utm_content: '', + utm_term: '', + referrer: '', + referrer_name: '', + referrer_type: '', + sign: 1, + version: 1, + properties: {}, + }, + { + id: 'sess-charlie-1', + project_id: TEST_PROJECT_ID, + profile_id: 'profile-charlie', + device_id: 'dev-charlie', + created_at: daysAgo(5), + ended_at: daysAgo(5), + is_bounce: false, + entry_origin: 'https://example.com', + entry_path: '/shop', + exit_origin: 'https://example.com', + exit_path: '/checkout', + screen_view_count: 2, + revenue: 9900, + event_count: 2, + duration: 300, + country: 'US', + region: '', + city: '', + device: 'desktop', + brand: '', + model: '', + browser: 'Firefox', + browser_version: '', + os: '', + os_version: '', + utm_medium: '', + utm_source: '', + utm_campaign: '', + utm_content: '', + utm_term: '', + referrer: '', + referrer_name: '', + referrer_type: '', + sign: 1, + version: 1, + properties: {}, + }, + { + id: 'sess-charlie-2', + project_id: TEST_PROJECT_ID, + profile_id: 'profile-charlie', + device_id: 'dev-charlie', + created_at: daysAgo(10), + ended_at: daysAgo(10), + is_bounce: true, + entry_origin: 'https://example.com', + entry_path: '/shop', + exit_origin: 'https://example.com', + exit_path: '/shop', + screen_view_count: 1, + revenue: 0, + event_count: 1, + duration: 15, + country: 'US', + region: '', + city: '', + device: 'desktop', + brand: '', + model: '', + browser: 'Firefox', + browser_version: '', + os: '', + os_version: '', + utm_medium: '', + utm_source: '', + utm_campaign: '', + utm_content: '', + utm_term: '', + referrer: '', + referrer_name: '', + referrer_type: '', + sign: 1, + version: 1, + properties: {}, + }, + ], + format: 'JSONEachRow', + }); + + await client.close(); +} + +export async function teardown() { + const client = await getClient(); + await cleanTestData(client); + await client.close(); +} + +async function cleanTestData(client: Awaited>) { + await Promise.all([ + client.command({ + query: `DELETE FROM openpanel.profiles WHERE project_id = '${TEST_PROJECT_ID}'`, + }), + client.command({ + query: `DELETE FROM openpanel.events WHERE project_id = '${TEST_PROJECT_ID}'`, + }), + client.command({ + query: `DELETE FROM openpanel.sessions WHERE project_id = '${TEST_PROJECT_ID}'`, + }), + ]); +} diff --git a/packages/mcp/src/integration/tools.test.ts b/packages/mcp/src/integration/tools.test.ts new file mode 100644 index 000000000..17447758e --- /dev/null +++ b/packages/mcp/src/integration/tools.test.ts @@ -0,0 +1,628 @@ +/** + * Integration tests for MCP tools against a real ClickHouse instance. + * + * CLICKHOUSE_URL is pinned to http://localhost:8123 in vitest.shared.ts — + * always targets local Docker, never production. Start with: pnpm dock:up + * + * Fixture data (inserted by globalSetup in setup.ts): + * Alice — 3 events: session_start, page_view(/home), session_end — 2 days ago — country: US, browser: Chrome + * Bob — 0 events (inactive) — profile created 90 days ago — country: SE + * Charlie — 5 events: session_start, screen_view, page_view(/shop), purchase, session_end — 5 days ago — browser: Firefox + * 2 sessions (sess-charlie-1 5d ago, sess-charlie-2 10d ago) + * + * For tools that also call getSettingsForProject (Postgres), we mock only + * that function — all ClickHouse queries still run for real. + */ + +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@openpanel/db', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSettingsForProject: vi.fn().mockResolvedValue({ timezone: 'UTC' }), + }; +}); + +import { registerActiveUserTools } from '../tools/analytics/active-users'; +import { registerEngagementTools } from '../tools/analytics/engagement'; +import { registerEventNameTools } from '../tools/analytics/event-names'; +import { registerEventTools } from '../tools/analytics/events'; +import { registerFunnelTools } from '../tools/analytics/funnel'; +import { registerGroupTools } from '../tools/analytics/groups'; +import { registerOverviewTools } from '../tools/analytics/overview'; +import { registerPagePerformanceTools } from '../tools/analytics/page-performance'; +import { registerPageTools } from '../tools/analytics/pages'; +import { registerProfileMetricTools } from '../tools/analytics/profile-metrics'; +import { registerProfileTools } from '../tools/analytics/profiles'; +import { registerPropertyValueTools } from '../tools/analytics/property-values'; +import { registerRetentionTools } from '../tools/analytics/retention'; +import { registerSessionTools } from '../tools/analytics/sessions'; +import { registerTrafficTools } from '../tools/analytics/traffic'; +import { registerUserFlowTools } from '../tools/analytics/user-flow'; +import { TEST_PROJECT_ID } from './setup'; + +const CTX = { + projectId: TEST_PROJECT_ID, + organizationId: 'org-test', + clientType: 'read' as const, +}; + +function makeServer() { + const handlers = new Map Promise>(); + return { + tool: (name: string, _desc: string, _schema: unknown, fn: (input: unknown) => Promise) => { + handlers.set(name, fn); + }, + invoke: async (name: string, input: unknown) => { + const handler = handlers.get(name); + if (!handler) throw new Error(`Tool not registered: ${name}`); + const result = await handler(input) as any; + return JSON.parse(result.content[0].text); + }, + }; +} + +// ─── Discovery ──────────────────────────────────────────────────────────────── + +describe('list_event_names', () => { + it('returns { event_names: string[] }', async () => { + const server = makeServer(); + registerEventNameTools(server as any, CTX); + const res = await server.invoke('list_event_names', { projectId: TEST_PROJECT_ID }); + expect(Array.isArray(res.event_names)).toBe(true); + }); +}); + +describe('list_event_properties', () => { + it('returns { properties: array }', async () => { + const server = makeServer(); + registerPropertyValueTools(server as any, CTX); + const res = await server.invoke('list_event_properties', { projectId: TEST_PROJECT_ID }); + expect(Array.isArray(res.properties)).toBe(true); + }); +}); + +describe('get_event_property_values', () => { + it('returns { event, property, values }', async () => { + const server = makeServer(); + registerPropertyValueTools(server as any, CTX); + const res = await server.invoke('get_event_property_values', { + projectId: TEST_PROJECT_ID, + eventName: 'purchase', + propertyKey: 'plan', + }); + expect(res.event).toBe('purchase'); + expect(res.property).toBe('plan'); + expect(Array.isArray(res.values)).toBe(true); + }); +}); + +// ─── Raw data ───────────────────────────────────────────────────────────────── + +describe('query_events', () => { + it('returns all 8 fixture events', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(res.length).toBe(8); + }); + + it('filters by eventName — only returns purchase events', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + eventNames: ['purchase'], + }); + expect(res.length).toBe(1); + expect(res[0].name).toBe('purchase'); + expect(res[0].profile_id).toBe('profile-charlie'); + expect(res[0].revenue).toBe(9900); + }); + + it('filters by profileId — returns only alice events', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + profileId: 'profile-alice', + }); + expect(res.length).toBe(3); + expect(res.every((e: any) => e.profile_id === 'profile-alice')).toBe(true); + }); + + it('filters by browser', async () => { + const server = makeServer(); + registerEventTools(server as any, CTX); + const res = await server.invoke('query_events', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + browser: 'Firefox', + }); + expect(res.length).toBe(5); + expect(res.every((e: any) => e.browser === 'Firefox')).toBe(true); + }); + + // Note: read-context resolveProjectId ignores the input projectId and always + // uses CTX.projectId — so there is no way to query another project's data. + +}); + +describe('query_sessions', () => { + it('returns all 3 fixture sessions', async () => { + const server = makeServer(); + registerSessionTools(server as any, CTX); + const res = await server.invoke('query_sessions', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(res.length).toBe(3); + }); + + it('filters by profileId — charlie has 2 sessions', async () => { + const server = makeServer(); + registerSessionTools(server as any, CTX); + const res = await server.invoke('query_sessions', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + profileId: 'profile-charlie', + }); + expect(res.length).toBe(2); + expect(res.every((s: any) => s.profile_id === 'profile-charlie')).toBe(true); + }); + + it('filters by browser', async () => { + const server = makeServer(); + registerSessionTools(server as any, CTX); + const res = await server.invoke('query_sessions', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + browser: 'Chrome', + }); + expect(res.length).toBe(1); + expect(res[0].profile_id).toBe('profile-alice'); + }); +}); + +// ─── Profile tools ──────────────────────────────────────────────────────────── + +describe('find_profiles', () => { + it('returns all 3 fixture profiles', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID }); + expect(res.length).toBe(3); + }); + + it('filters by email partial match', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, email: 'alice@' }); + expect(res.length).toBe(1); + expect(res[0].email).toBe('alice@example.com'); + }); + + it('filters by name — matches first_name and last_name', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const byFirst = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, name: 'Charlie' }); + expect(byFirst.length).toBe(1); + expect(byFirst[0].first_name).toBe('Charlie'); + + const byLast = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, name: 'Smith' }); + expect(byLast.length).toBe(1); + expect(byLast[0].last_name).toBe('Smith'); + }); + + it('filters by country property', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, country: 'SE' }); + expect(res.length).toBe(1); + expect(res[0].email).toBe('bob@example.com'); + }); + + it('inactiveDays=7 excludes alice (active 2 days ago) but includes bob (no events)', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, inactiveDays: 7 }); + const emails = res.map((p: any) => p.email); + expect(emails).not.toContain('alice@example.com'); + expect(emails).not.toContain('charlie@example.com'); + expect(emails).toContain('bob@example.com'); + }); + + it('minSessions=2 returns only charlie (has 2 sessions)', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, minSessions: 2 }); + expect(res.length).toBe(1); + expect(res[0].first_name).toBe('Charlie'); + }); + + it('performedEvent=purchase returns only charlie', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, performedEvent: 'purchase' }); + expect(res.length).toBe(1); + expect(res[0].first_name).toBe('Charlie'); + }); + + // Note: read-context resolveProjectId ignores the input projectId and always + // uses CTX.projectId — so there is no way to query another project's data. + +}); + +describe('get_profile', () => { + it('returns correct profile and events for charlie', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('get_profile', { + projectId: TEST_PROJECT_ID, + profileId: 'profile-charlie', + }); + expect(res.profile.first_name).toBe('Charlie'); + expect(res.profile.email).toBe('charlie@example.com'); + expect(Array.isArray(res.recent_events)).toBe(true); + expect(res.recent_events.length).toBe(5); // all charlie events + }); +}); + +describe('get_profile_sessions', () => { + it('returns 2 sessions for charlie', async () => { + const server = makeServer(); + registerProfileTools(server as any, CTX); + const res = await server.invoke('get_profile_sessions', { + projectId: TEST_PROJECT_ID, + profileId: 'profile-charlie', + }); + expect(res.sessions.length).toBe(2); + expect(res.sessions.every((s: any) => s.profile_id === 'profile-charlie')).toBe(true); + }); +}); + +describe('get_profile_metrics', () => { + it('returns exact metrics for charlie', async () => { + const server = makeServer(); + registerProfileMetricTools(server as any, CTX); + const res = await server.invoke('get_profile_metrics', { + projectId: TEST_PROJECT_ID, + profileId: 'profile-charlie', + }); + // No error — bug was getProfileMetrics returns single object, not array + expect(res.error).toBeUndefined(); + expect(res.profileId).toBe('profile-charlie'); + expect(res.sessions).toBe(1); // 1 session_start event + expect(res.screenViews).toBe(1); // 1 screen_view event + expect(res.totalEvents).toBe(5); // session_start + screen_view + page_view + purchase + session_end + expect(res.conversionEvents).toBe(2); // page_view + purchase (excludes session_start/screen_view/session_end) + expect(res.uniqueDaysActive).toBe(1); // all on the same day + expect(res.firstSeen).not.toBeNull(); + expect(res.lastSeen).not.toBeNull(); + }); + + it('returns metrics for alice', async () => { + const server = makeServer(); + registerProfileMetricTools(server as any, CTX); + const res = await server.invoke('get_profile_metrics', { + projectId: TEST_PROJECT_ID, + profileId: 'profile-alice', + }); + expect(res.error).toBeUndefined(); + expect(res.sessions).toBe(1); + expect(res.totalEvents).toBe(3); // session_start + page_view + session_end + expect(res.conversionEvents).toBe(1); // page_view only + expect(res.screenViews).toBe(0); + }); +}); + +// ─── Groups ─────────────────────────────────────────────────────────────────── + +describe('list_group_types', () => { + it('returns { types: [] } (no groups in fixtures)', async () => { + const server = makeServer(); + registerGroupTools(server as any, CTX); + const res = await server.invoke('list_group_types', { projectId: TEST_PROJECT_ID }); + expect(Array.isArray(res.types)).toBe(true); + expect(res.types).toHaveLength(0); + }); +}); + +describe('find_groups', () => { + it('returns empty array (no groups in fixtures)', async () => { + const server = makeServer(); + registerGroupTools(server as any, CTX); + const res = await server.invoke('find_groups', { projectId: TEST_PROJECT_ID }); + expect(Array.isArray(res)).toBe(true); + }); +}); + +describe('get_group', () => { + it('returns not-found error for unknown group', async () => { + const server = makeServer(); + registerGroupTools(server as any, CTX); + const res = await server.invoke('get_group', { + projectId: TEST_PROJECT_ID, + groupId: 'nonexistent', + }); + expect(res.error).toBe('Group not found'); + expect(res.groupId).toBe('nonexistent'); + }); +}); + +// ─── Aggregated metrics ─────────────────────────────────────────────────────── + +describe('get_analytics_overview', () => { + it('returns summary with numeric metric fields and a series array', async () => { + const server = makeServer(); + registerOverviewTools(server as any, CTX); + const res = await server.invoke('get_analytics_overview', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(res).toHaveProperty('summary'); + expect(res).toHaveProperty('series'); + expect(Array.isArray(res.series)).toBe(true); + }); +}); + +describe('get_top_pages', () => { + it('returns array including /shop and /home from fixtures', async () => { + const server = makeServer(); + registerPageTools(server as any, CTX); + const res = await server.invoke('get_top_pages', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + const paths = res.map((p: any) => p.path); + // getTopPages queries screen_view events only — Charlie's /shop appears; + // Alice's /home is a page_view (not screen_view) so it won't show here. + expect(paths).toContain('/shop'); + }); +}); + +describe('get_entry_exit_pages', () => { + it('returns entry pages array', async () => { + const server = makeServer(); + registerPageTools(server as any, CTX); + const res = await server.invoke('get_entry_exit_pages', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + mode: 'entry', + }); + expect(Array.isArray(res)).toBe(true); + }); + + it('returns exit pages array', async () => { + const server = makeServer(); + registerPageTools(server as any, CTX); + const res = await server.invoke('get_entry_exit_pages', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + mode: 'exit', + }); + expect(Array.isArray(res)).toBe(true); + }); +}); + +describe('get_page_performance', () => { + it('returns pages array with seo_signals on each page', async () => { + const server = makeServer(); + registerPagePerformanceTools(server as any, CTX); + const res = await server.invoke('get_page_performance', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(typeof res.total_pages).toBe('number'); + expect(typeof res.shown).toBe('number'); + expect(Array.isArray(res.pages)).toBe(true); + for (const page of res.pages) { + expect(page).toHaveProperty('seo_signals'); + expect(typeof page.seo_signals.high_bounce).toBe('boolean'); + expect(typeof page.seo_signals.low_engagement).toBe('boolean'); + expect(typeof page.seo_signals.good_landing_page).toBe('boolean'); + } + }); +}); + +describe('get_top_referrers', () => { + it('returns array', async () => { + const server = makeServer(); + registerTrafficTools(server as any, CTX); + const res = await server.invoke('get_top_referrers', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + }); +}); + +describe('get_country_breakdown', () => { + it('returns US as country in fixtures', async () => { + const server = makeServer(); + registerTrafficTools(server as any, CTX); + const res = await server.invoke('get_country_breakdown', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + // getTopGeneric returns { name, sessions, pageviews } — field is 'name' + const countries = res.map((r: any) => r.name); + expect(countries).toContain('US'); + }); +}); + +describe('get_device_breakdown', () => { + it('returns desktop in fixtures', async () => { + const server = makeServer(); + registerTrafficTools(server as any, CTX); + const res = await server.invoke('get_device_breakdown', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + }); + expect(Array.isArray(res)).toBe(true); + // getTopGeneric returns { name, sessions, pageviews } — field is 'name' + const devices = res.map((r: any) => r.name); + expect(devices).toContain('desktop'); + }); +}); + +// ─── User behavior ──────────────────────────────────────────────────────────── + +describe('get_funnel', () => { + it('detects charlie completing session_start → purchase', async () => { + const server = makeServer(); + registerFunnelTools(server as any, CTX); + const res = await server.invoke('get_funnel', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + steps: ['session_start', 'purchase'], + }); + expect(res).toHaveProperty('steps'); + expect(res.steps.length).toBe(2); + expect(res.steps[0].eventName).toBe('session_start'); + expect(res.steps[1].eventName).toBe('purchase'); + expect(res.totalUsers).toBeGreaterThanOrEqual(1); + expect(res.completedUsers).toBeGreaterThanOrEqual(1); + expect(res.overallConversionRate).toBeGreaterThan(0); + // Each step has the required fields + for (const step of res.steps) { + expect(typeof step.step).toBe('number'); + expect(typeof step.users).toBe('number'); + expect(typeof step.conversionRateFromStart).toBe('number'); + } + }); + + it('returns zero completions for an impossible funnel order', async () => { + const server = makeServer(); + registerFunnelTools(server as any, CTX); + const res = await server.invoke('get_funnel', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + steps: ['purchase', 'session_start'], // reversed — nobody completes this + }); + expect(res.completedUsers).toBe(0); + }); +}); + +describe('get_user_flow', () => { + it('returns nodes and links for flow after session_start', async () => { + const server = makeServer(); + registerUserFlowTools(server as any, CTX); + const res = await server.invoke('get_user_flow', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + startEvent: 'session_start', + mode: 'after', + }); + expect(res.mode).toBe('after'); + expect(res.startEvent).toBe('session_start'); + expect(Array.isArray(res.nodes)).toBe(true); + expect(Array.isArray(res.links)).toBe(true); + expect(typeof res.node_count).toBe('number'); + expect(typeof res.link_count).toBe('number'); + }); + + it('returns error when mode=between without endEvent', async () => { + const server = makeServer(); + registerUserFlowTools(server as any, CTX); + const res = await server.invoke('get_user_flow', { + projectId: TEST_PROJECT_ID, + startDate: '2000-01-01', + endDate: '2099-01-01', + startEvent: 'session_start', + mode: 'between', + // endEvent intentionally omitted + }); + expect(res.error).toContain('endEvent'); + }); +}); + +describe('get_rolling_active_users', () => { + it('returns DAU series (may be empty — dau_mv not auto-populated)', async () => { + const server = makeServer(); + registerActiveUserTools(server as any, CTX); + const res = await server.invoke('get_rolling_active_users', { projectId: TEST_PROJECT_ID, days: 1 }); + expect(res.label).toBe('DAU'); + expect(res.window_days).toBe(1); + expect(Array.isArray(res.series)).toBe(true); + }); + + it('uses correct label for WAU and MAU', async () => { + const server = makeServer(); + registerActiveUserTools(server as any, CTX); + const wau = await server.invoke('get_rolling_active_users', { projectId: TEST_PROJECT_ID, days: 7 }); + expect(wau.label).toBe('WAU'); + const mau = await server.invoke('get_rolling_active_users', { projectId: TEST_PROJECT_ID, days: 30 }); + expect(mau.label).toBe('MAU'); + }); +}); + +describe('get_weekly_retention_series', () => { + it('returns array of { date, active_users, retained_users, retention } rows', async () => { + const server = makeServer(); + registerActiveUserTools(server as any, CTX); + const res = await server.invoke('get_weekly_retention_series', { projectId: TEST_PROJECT_ID }); + expect(Array.isArray(res)).toBe(true); + if (res.length > 0) { + expect(res[0]).toHaveProperty('date'); + expect(res[0]).toHaveProperty('active_users'); + expect(res[0]).toHaveProperty('retained_users'); + expect(res[0]).toHaveProperty('retention'); + } + }); +}); + +describe('get_retention_cohort', () => { + it('returns array of cohort rows with period_0..period_9', async () => { + const server = makeServer(); + registerRetentionTools(server as any, CTX); + const res = await server.invoke('get_retention_cohort', { projectId: TEST_PROJECT_ID }); + expect(Array.isArray(res)).toBe(true); + if (res.length > 0) { + expect(res[0]).toHaveProperty('first_seen'); + expect(res[0]).toHaveProperty('period_0'); + } + }); +}); + +describe('get_user_last_seen_distribution', () => { + it('returns alice and charlie in active_last_7_days bucket', async () => { + const server = makeServer(); + registerEngagementTools(server as any, CTX); + const res = await server.invoke('get_user_last_seen_distribution', { projectId: TEST_PROJECT_ID }); + // Alice: last event 2 days ago → 0-7 bucket + // Charlie: last event 5 days ago → 0-7 bucket + // Bob: no events → not counted + expect(res.summary.total_identified_users).toBe(2); + expect(res.summary.active_last_7_days).toBe(2); + expect(res.summary.active_8_to_14_days).toBe(0); + expect(res.summary.churned_60_plus_days).toBe(0); + expect(Array.isArray(res.distribution)).toBe(true); + }); +}); diff --git a/packages/mcp/src/tools/analytics/engagement.test.ts b/packages/mcp/src/tools/analytics/engagement.test.ts new file mode 100644 index 000000000..9d7d9548a --- /dev/null +++ b/packages/mcp/src/tools/analytics/engagement.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetRetentionLastSeenSeries = vi.hoisted(() => vi.fn()); + +vi.mock('@openpanel/db', () => ({ + getRetentionLastSeenSeries: mockGetRetentionLastSeenSeries, +})); + +// Import after mock is set up +import { registerEngagementTools } from './engagement'; + +// Helper: directly invoke the bucketing logic by importing it through a minimal mock server +// We test the bucketing by calling the tool handler directly via a test double McpServer. +function makeServer() { + let handler: ((input: unknown) => Promise) | null = null; + return { + tool: (_name: string, _desc: string, _schema: unknown, fn: (input: unknown) => Promise) => { + handler = fn; + }, + invoke: (input: unknown) => { + if (!handler) throw new Error('tool not registered'); + return handler(input); + }, + }; +} + +const READ_CTX = { projectId: 'proj-1', organizationId: 'org-1', clientType: 'read' as const }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('get_user_last_seen_distribution — bucketing', () => { + it('correctly buckets users into recency segments', async () => { + mockGetRetentionLastSeenSeries.mockResolvedValue([ + { days: 0, users: 10 }, + { days: 3, users: 20 }, + { days: 7, users: 5 }, // still in 0-7 + { days: 10, users: 8 }, // 8-14 + { days: 14, users: 2 }, // 8-14 + { days: 20, users: 12 }, // 15-30 + { days: 45, users: 6 }, // 31-60 + { days: 90, users: 3 }, // 60+ + ]); + + const server = makeServer() as any; + registerEngagementTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.summary.active_last_7_days).toBe(10 + 20 + 5); // 35 + expect(content.summary.active_8_to_14_days).toBe(8 + 2); // 10 + expect(content.summary.active_15_to_30_days).toBe(12); + expect(content.summary.inactive_31_to_60_days).toBe(6); + expect(content.summary.churned_60_plus_days).toBe(3); + expect(content.summary.total_identified_users).toBe(66); + }); + + it('returns zero counts when no data', async () => { + mockGetRetentionLastSeenSeries.mockResolvedValue([]); + + const server = makeServer() as any; + registerEngagementTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.summary.total_identified_users).toBe(0); + expect(content.summary.active_last_7_days).toBe(0); + expect(content.summary.churned_60_plus_days).toBe(0); + }); + + it('passes raw distribution alongside the summary', async () => { + const raw = [{ days: 1, users: 5 }]; + mockGetRetentionLastSeenSeries.mockResolvedValue(raw); + + const server = makeServer() as any; + registerEngagementTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.distribution).toEqual(raw); + }); +}); diff --git a/packages/mcp/src/tools/analytics/page-performance.test.ts b/packages/mcp/src/tools/analytics/page-performance.test.ts new file mode 100644 index 000000000..d57e59ddf --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-performance.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetTopPages = vi.hoisted(() => vi.fn()); +const mockGetSettingsForProject = vi.hoisted(() => + vi.fn().mockResolvedValue({ timezone: 'UTC' }), +); + +vi.mock('@openpanel/db', () => ({ + PagesService: vi.fn().mockImplementation(() => ({ + getTopPages: mockGetTopPages, + })), + ch: {}, + getSettingsForProject: mockGetSettingsForProject, +})); + +import { registerPagePerformanceTools } from './page-performance'; + +function makeServer() { + let handler: ((input: unknown) => Promise) | null = null; + return { + tool: (_name: string, _desc: string, _schema: unknown, fn: (input: unknown) => Promise) => { + handler = fn; + }, + invoke: (input: unknown) => { + if (!handler) throw new Error('tool not registered'); + return handler(input); + }, + }; +} + +const READ_CTX = { projectId: 'proj-1', organizationId: 'org-1', clientType: 'read' as const }; + +function makePage(overrides: Record = {}) { + return { + path: '/page', + title: 'Page', + sessions: 100, + pageviews: 200, + bounce_rate: 50, + avg_duration: 2, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetSettingsForProject.mockResolvedValue({ timezone: 'UTC' }); +}); + +describe('get_page_performance — seo_signals annotation', () => { + it('marks high_bounce when bounce_rate > 70', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 80 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.high_bounce).toBe(true); + expect(content.pages[0].seo_signals.low_engagement).toBe(false); + expect(content.pages[0].seo_signals.good_landing_page).toBe(false); + }); + + it('does not mark high_bounce when bounce_rate is exactly 70', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 70 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.high_bounce).toBe(false); + }); + + it('marks low_engagement when avg_duration < 1', async () => { + mockGetTopPages.mockResolvedValue([makePage({ avg_duration: 0.5, bounce_rate: 30 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.low_engagement).toBe(true); + }); + + it('marks good_landing_page when bounce_rate < 40 and avg_duration > 2', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 25, avg_duration: 3 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.good_landing_page).toBe(true); + expect(content.pages[0].seo_signals.high_bounce).toBe(false); + expect(content.pages[0].seo_signals.low_engagement).toBe(false); + }); + + it('does not mark good_landing_page when bounce_rate is exactly 40', async () => { + mockGetTopPages.mockResolvedValue([makePage({ bounce_rate: 40, avg_duration: 3 })]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0].seo_signals.good_landing_page).toBe(false); + }); +}); + +describe('get_page_performance — sorting', () => { + const pages = [ + makePage({ path: '/a', bounce_rate: 20, sessions: 10 }), + makePage({ path: '/b', bounce_rate: 80, sessions: 50 }), + makePage({ path: '/c', bounce_rate: 50, sessions: 30 }), + ]; + + it('sorts by sessions descending by default', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + const paths = content.pages.map((p: any) => p.path); + + expect(paths).toEqual(['/b', '/c', '/a']); + }); + + it('sorts by bounce_rate descending', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, sortBy: 'bounce_rate', sortOrder: 'desc' }) as any; + const content = JSON.parse(result.content[0].text); + const paths = content.pages.map((p: any) => p.path); + + expect(paths).toEqual(['/b', '/c', '/a']); + }); + + it('sorts by bounce_rate ascending', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, sortBy: 'bounce_rate', sortOrder: 'asc' }) as any; + const content = JSON.parse(result.content[0].text); + const paths = content.pages.map((p: any) => p.path); + + expect(paths).toEqual(['/a', '/c', '/b']); + }); + + it('respects limit', async () => { + mockGetTopPages.mockResolvedValue([...pages]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, limit: 2 }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages).toHaveLength(2); + expect(content.shown).toBe(2); + expect(content.total_pages).toBe(3); + }); +}); + +describe('get_page_performance — metadata', () => { + it('returns total_pages and shown counts', async () => { + const manyPages = Array.from({ length: 10 }, (_, i) => + makePage({ path: `/page-${i}` }), + ); + mockGetTopPages.mockResolvedValue(manyPages); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId, limit: 5 }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.total_pages).toBe(10); + expect(content.shown).toBe(5); + }); + + it('returns empty pages array when no data', async () => { + mockGetTopPages.mockResolvedValue([]); + + const server = makeServer() as any; + registerPagePerformanceTools(server, READ_CTX); + const result = await server.invoke({ projectId: READ_CTX.projectId }) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages).toEqual([]); + expect(content.total_pages).toBe(0); + }); +}); diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts index 6bf2c4ff3..c768a5290 100644 --- a/packages/mcp/src/tools/analytics/profile-metrics.ts +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -22,8 +22,7 @@ export function registerProfileMetricTools( async ({ projectId: inputProjectId, profileId }) => withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); - const rows = await getProfileMetrics(profileId, projectId); - const raw = rows[0]; + const raw = await getProfileMetrics(profileId, projectId); if (!raw) { return { error: 'Profile not found or has no events', profileId }; } diff --git a/packages/mcp/src/tools/analytics/profiles.test.ts b/packages/mcp/src/tools/analytics/profiles.test.ts new file mode 100644 index 000000000..c0043f8f4 --- /dev/null +++ b/packages/mcp/src/tools/analytics/profiles.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockChQuery = vi.hoisted(() => vi.fn().mockResolvedValue([])); + +vi.mock('@openpanel/db', () => ({ + TABLE_NAMES: { + profiles: 'profiles', + events: 'events', + sessions: 'sessions', + }, + ch: {}, + chQuery: mockChQuery, + // clix is used by getProfileSessionsCore and getProfileWithEvents — mock a chainable builder + clix: vi.fn(() => { + const builder: Record = {}; + const chain = () => builder; + builder.select = chain; + builder.from = chain; + builder.where = chain; + builder.orderBy = chain; + builder.limit = chain; + builder.execute = vi.fn().mockResolvedValue([]); + return builder; + }), +})); + +import { findProfilesCore } from './profiles'; + +function capturedSql(): string { + return mockChQuery.mock.calls[0][0] as string; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('findProfilesCore — SQL conditions', () => { + it('always includes project_id condition', async () => { + await findProfilesCore({ projectId: 'proj-1' }); + expect(capturedSql()).toContain("project_id = 'proj-1'"); + }); + + it('adds email LIKE condition when email is provided', async () => { + await findProfilesCore({ projectId: 'proj-1', email: 'carl@' }); + expect(capturedSql()).toContain("email LIKE '%carl@%'"); + }); + + it('searches both first_name and last_name for name filter', async () => { + await findProfilesCore({ projectId: 'proj-1', name: 'Carl' }); + const sql = capturedSql(); + expect(sql).toContain('first_name LIKE'); + expect(sql).toContain('last_name LIKE'); + expect(sql).toContain('%Carl%'); + }); + + it('adds country property condition', async () => { + await findProfilesCore({ projectId: 'proj-1', country: 'SE' }); + expect(capturedSql()).toContain("properties['country'] = 'SE'"); + }); + + it('adds inactiveDays NOT IN subquery', async () => { + await findProfilesCore({ projectId: 'proj-1', inactiveDays: 14 }); + const sql = capturedSql(); + expect(sql).toContain('NOT IN'); + expect(sql).toContain('INTERVAL 14 DAY'); + }); + + it('floors inactiveDays to integer (prevents SQL injection via floats)', async () => { + await findProfilesCore({ projectId: 'proj-1', inactiveDays: 14.9 }); + expect(capturedSql()).toContain('INTERVAL 14 DAY'); + expect(capturedSql()).not.toContain('14.9'); + }); + + it('adds minSessions HAVING subquery', async () => { + await findProfilesCore({ projectId: 'proj-1', minSessions: 5 }); + const sql = capturedSql(); + expect(sql).toContain('HAVING count() >= 5'); + }); + + it('adds performedEvent IN subquery', async () => { + await findProfilesCore({ projectId: 'proj-1', performedEvent: 'purchase' }); + expect(capturedSql()).toContain("name = 'purchase'"); + }); + + it('defaults to ORDER BY created_at DESC', async () => { + await findProfilesCore({ projectId: 'proj-1' }); + expect(capturedSql()).toContain('ORDER BY created_at DESC'); + }); + + it('respects sortOrder: asc', async () => { + await findProfilesCore({ projectId: 'proj-1', sortOrder: 'asc' }); + expect(capturedSql()).toContain('ORDER BY created_at ASC'); + }); + + it('defaults limit to 20', async () => { + await findProfilesCore({ projectId: 'proj-1' }); + expect(capturedSql()).toContain('LIMIT 20'); + }); + + it('caps limit at 100 regardless of input', async () => { + await findProfilesCore({ projectId: 'proj-1', limit: 9999 }); + expect(capturedSql()).toContain('LIMIT 100'); + expect(capturedSql()).not.toContain('LIMIT 9999'); + }); +}); + +describe('findProfilesCore — SQL injection protection', () => { + it('escapes single quotes in string values', async () => { + await findProfilesCore({ projectId: "proj'; DROP TABLE profiles;--" }); + // The projectId must be escaped — raw SQL injection string must not appear + expect(capturedSql()).not.toContain("proj'; DROP TABLE profiles;--"); + }); + + it('escapes single quotes in name search', async () => { + await findProfilesCore({ projectId: 'proj-1', name: "O'Brien" }); + // Unescaped apostrophe in the SQL would break the query + const sql = capturedSql(); + expect(sql).not.toMatch(/LIKE '%O'Brien%'/); + }); + + it('escapes backslashes in email', async () => { + await findProfilesCore({ projectId: 'proj-1', email: 'test\\@x.com' }); + // Raw backslash in ClickHouse SQL needs escaping + expect(capturedSql()).not.toContain("'%test\\@x.com%'"); + }); +}); + +describe('findProfilesCore — return value', () => { + it('returns whatever chQuery resolves with', async () => { + const fakeProfiles = [{ id: 'p1', first_name: 'Alice' }]; + mockChQuery.mockResolvedValueOnce(fakeProfiles); + const result = await findProfilesCore({ projectId: 'proj-1' }); + expect(result).toEqual(fakeProfiles); + }); + + it('returns empty array when no profiles found', async () => { + mockChQuery.mockResolvedValueOnce([]); + const result = await findProfilesCore({ projectId: 'proj-1' }); + expect(result).toEqual([]); + }); +}); diff --git a/packages/mcp/src/tools/shared.test.ts b/packages/mcp/src/tools/shared.test.ts new file mode 100644 index 000000000..c54765aff --- /dev/null +++ b/packages/mcp/src/tools/shared.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { McpAuthContext } from '../auth'; +import { resolveDateRange, resolveProjectId } from './shared'; + +const READ_CTX: McpAuthContext = { + projectId: 'proj-abc', + organizationId: 'org-1', + clientType: 'read', +}; + +const ROOT_CTX: McpAuthContext = { + projectId: null, + organizationId: 'org-1', + clientType: 'root', +}; + +describe('resolveDateRange', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-03-15T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes through explicit dates unchanged', () => { + const result = resolveDateRange('2024-01-01', '2024-02-28'); + expect(result).toEqual({ startDate: '2024-01-01', endDate: '2024-02-28' }); + }); + + it('defaults endDate to today when omitted', () => { + const { endDate } = resolveDateRange('2024-01-01'); + expect(endDate).toBe('2024-03-15'); + }); + + it('defaults startDate to 30 days ago when omitted', () => { + const { startDate } = resolveDateRange(undefined, '2024-03-15'); + expect(startDate).toBe('2024-02-14'); + }); + + it('defaults both to last 30 days when neither is provided', () => { + const result = resolveDateRange(); + expect(result.endDate).toBe('2024-03-15'); + expect(result.startDate).toBe('2024-02-14'); + }); +}); + +describe('resolveProjectId', () => { + it('returns the context projectId for read clients, ignoring any input', () => { + expect(resolveProjectId(READ_CTX, undefined)).toBe('proj-abc'); + expect(resolveProjectId(READ_CTX, 'other-proj')).toBe('proj-abc'); + }); + + it('returns the input projectId for root clients', () => { + expect(resolveProjectId(ROOT_CTX, 'proj-xyz')).toBe('proj-xyz'); + }); + + it('throws for root clients when no projectId is provided', () => { + expect(() => resolveProjectId(ROOT_CTX, undefined)).toThrow( + 'projectId is required', + ); + }); +}); diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts new file mode 100644 index 000000000..d91931b0c --- /dev/null +++ b/packages/mcp/vitest.config.ts @@ -0,0 +1,8 @@ +import { mergeConfig } from 'vitest/config'; +import { getSharedVitestConfig } from '../../vitest.shared'; + +export default mergeConfig(getSharedVitestConfig({ __dirname }), { + test: { + globalSetup: ['./src/integration/setup.ts'], + }, +}); diff --git a/vitest.shared.ts b/vitest.shared.ts index 2551b83c0..881e20f63 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -3,7 +3,9 @@ import { defineConfig } from 'vitest/config'; export const getSharedVitestConfig = ({ __dirname: dirname, -}: { __dirname: string }) => { +}: { + __dirname: string; +}) => { return defineConfig({ resolve: { alias: { @@ -12,8 +14,11 @@ export const getSharedVitestConfig = ({ }, test: { env: { - // Not used, just so prisma is happy + // Always point at local Docker — never production, regardless of .env DATABASE_URL: 'postgresql://u:p@127.0.0.1:5432/db', + CLICKHOUSE_URL: 'http://localhost:8123/openpanel', + REDIS_URL: 'redis://localhost:6379', + SELF_HOSTED: 'true', }, include: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], browser: { From c3b92823676cbd703ba0c3414a36da9e6ede6b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 10:40:02 +0200 Subject: [PATCH 03/18] wip --- apps/api/src/index.ts | 2 +- apps/api/src/utils/graceful-shutdown.ts | 6 +- apps/worker/src/jobs/import.ts | 2 +- packages/auth/src/session.ts | 10 +- packages/db/src/services/access.service.ts | 18 +-- .../db/src/services/organization.service.ts | 24 +-- packages/db/src/services/project.service.ts | 2 +- packages/mcp/src/handler.ts | 95 +++++++---- packages/mcp/src/integration/setup.ts | 6 +- packages/mcp/src/integration/tools.test.ts | 16 +- packages/mcp/src/session-manager.ts | 127 +++++++-------- packages/mcp/src/test-setup.ts | 11 ++ .../mcp/src/tools/analytics/profiles.test.ts | 2 +- packages/mcp/src/tools/analytics/profiles.ts | 22 ++- packages/mcp/src/tools/analytics/reports.ts | 147 ++++++++++++++++++ packages/mcp/src/tools/analytics/sessions.ts | 7 +- packages/mcp/src/tools/dashboard-links.ts | 69 ++++++++ packages/mcp/src/tools/index.ts | 8 + packages/mcp/src/tools/projects.ts | 54 +++++++ packages/mcp/src/tools/shared.ts | 33 ++-- packages/mcp/vitest.config.ts | 4 +- packages/trpc/src/routers/widget.ts | 35 ++--- 22 files changed, 528 insertions(+), 172 deletions(-) create mode 100644 packages/mcp/src/test-setup.ts create mode 100644 packages/mcp/src/tools/analytics/reports.ts create mode 100644 packages/mcp/src/tools/dashboard-links.ts create mode 100644 packages/mcp/src/tools/projects.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9c5a54a8c..46aaa29c7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -34,7 +34,6 @@ import { requestIdHook } from './hooks/request-id.hook'; import { requestLoggingHook } from './hooks/request-logging.hook'; import { timestampHook } from './hooks/timestamp.hook'; import aiRouter from './routes/ai.router'; -import mcpRouter, { mcpSessionManager } from './routes/mcp.router'; import eventRouter from './routes/event.router'; import exportRouter from './routes/export.router'; import gscCallbackRouter from './routes/gsc-callback.router'; @@ -42,6 +41,7 @@ import importRouter from './routes/import.router'; import insightsRouter from './routes/insights.router'; import liveRouter from './routes/live.router'; import manageRouter from './routes/manage.router'; +import mcpRouter from './routes/mcp.router'; import miscRouter from './routes/misc.router'; import oauthRouter from './routes/oauth-callback.router'; import profileRouter from './routes/profile.router'; diff --git a/apps/api/src/utils/graceful-shutdown.ts b/apps/api/src/utils/graceful-shutdown.ts index ce0461eab..9aeb1b046 100644 --- a/apps/api/src/utils/graceful-shutdown.ts +++ b/apps/api/src/utils/graceful-shutdown.ts @@ -1,5 +1,4 @@ import { ch, db } from '@openpanel/db'; -import { mcpSessionManager } from '@/routes/mcp.router'; import { cronQueue, eventsGroupQueues, @@ -15,6 +14,7 @@ import { } from '@openpanel/redis'; import type { FastifyInstance } from 'fastify'; import { logger } from './logger'; +import { mcpSessionManager } from '@/routes/mcp.router'; let shuttingDown = false; @@ -30,7 +30,7 @@ export function isShuttingDown() { export async function shutdown( fastify: FastifyInstance, signal: string, - exitCode = 0, + exitCode = 0 ) { if (isShuttingDown()) { logger.warn('Shutdown already in progress, ignoring signal', { signal }); @@ -105,7 +105,7 @@ export async function shutdown( if (redis.status === 'ready') { await redis.quit(); } - }), + }) ); logger.info('Redis connections closed'); } catch (error) { diff --git a/apps/worker/src/jobs/import.ts b/apps/worker/src/jobs/import.ts index 95ada90ec..fc898f95e 100644 --- a/apps/worker/src/jobs/import.ts +++ b/apps/worker/src/jobs/import.ts @@ -33,7 +33,7 @@ const RESUMABLE_STEPS = ['creating_sessions', 'moving', 'backfilling_sessions']; export async function importJob(job: Job) { const { importId } = job.data.payload; - const record = await db.$primary().import.findUniqueOrThrow({ + const record = await db.import.findUniqueOrThrow({ where: { id: importId }, include: { project: true }, }); diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts index 3d4bcebc2..652060dcf 100644 --- a/packages/auth/src/session.ts +++ b/packages/auth/src/session.ts @@ -1,5 +1,5 @@ import crypto from 'node:crypto'; -import { type Session, type User, db } from '@openpanel/db'; +import { db, type Session, type User } from '@openpanel/db'; import { sha256 } from '@oslojs/crypto/sha2'; import { encodeBase32LowerCaseNoPadding, @@ -15,7 +15,7 @@ export function generateSessionToken(): string { export async function createSession( token: string, - userId: string, + userId: string ): Promise { const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); const session: Session = { @@ -38,7 +38,7 @@ export const EMPTY_SESSION: SessionValidationResult = { }; export async function createDemoSession( - userId: string, + userId: string ): Promise { const user = await db.user.findUniqueOrThrow({ where: { @@ -66,7 +66,7 @@ export const decodeSessionToken = (token: string): string | null => { }; export async function validateSessionToken( - token: string | null | undefined, + token: string | null | undefined ): Promise { if (process.env.DEMO_USER_ID) { return createDemoSession(process.env.DEMO_USER_ID); @@ -79,7 +79,7 @@ export async function validateSessionToken( if (!sessionId) { return EMPTY_SESSION; } - const result = await db.$primary().session.findUnique({ + const result = await db.session.findUnique({ where: { id: sessionId, }, diff --git a/packages/db/src/services/access.service.ts b/packages/db/src/services/access.service.ts index 0b66ae64d..53cf364a3 100644 --- a/packages/db/src/services/access.service.ts +++ b/packages/db/src/services/access.service.ts @@ -4,13 +4,7 @@ import { getProjectById } from './project.service'; export const getProjectAccess = cacheable( 'getProjectAccess', - async ({ - userId, - projectId, - }: { - userId: string; - projectId: string; - }) => { + async ({ userId, projectId }: { userId: string; projectId: string }) => { try { // Check if user has access to the project const project = await getProjectById(projectId); @@ -19,13 +13,13 @@ export const getProjectAccess = cacheable( } const [projectAccess, member] = await Promise.all([ - db.$primary().projectAccess.findMany({ + db.projectAccess.findMany({ where: { userId, organizationId: project.organizationId, }, }), - db.$primary().member.findFirst({ + db.member.findFirst({ where: { organizationId: project.organizationId, userId, @@ -42,7 +36,7 @@ export const getProjectAccess = cacheable( return false; } }, - 60 * 5, + 60 * 5 ); export const getOrganizationAccess = cacheable( @@ -54,14 +48,14 @@ export const getOrganizationAccess = cacheable( userId: string; organizationId: string; }) => { - return db.$primary().member.findFirst({ + return db.member.findFirst({ where: { userId, organizationId, }, }); }, - 60 * 5, + 60 * 5 ); export async function getClientAccess({ diff --git a/packages/db/src/services/organization.service.ts b/packages/db/src/services/organization.service.ts index ce8876351..ddb0f70c9 100644 --- a/packages/db/src/services/organization.service.ts +++ b/packages/db/src/services/organization.service.ts @@ -6,7 +6,7 @@ import type { Invite, Prisma, ProjectAccess, User } from '../prisma-client'; import { db } from '../prisma-client'; import { createSqlBuilder } from '../sql-builder'; import { getOrganizationAccess, getProjectAccess } from './access.service'; -import { type IServiceProject, getProjectById } from './project.service'; +import type { IServiceProject } from './project.service'; export type IServiceOrganization = Awaited< ReturnType >; @@ -17,7 +17,9 @@ export type IServiceMember = Prisma.MemberGetPayload<{ export type IServiceProjectAccess = ProjectAccess; export async function getOrganizations(userId: string | null) { - if (!userId) return []; + if (!userId) { + return []; + } const organizations = await db.organization.findMany({ where: { @@ -62,7 +64,7 @@ export async function getOrganizationByProjectId(projectId: string) { export const getOrganizationByProjectIdCached = cacheable( getOrganizationByProjectId, - 60 * 5, + 60 * 5 ); export async function getInvites(organizationId: string) { @@ -141,7 +143,7 @@ export async function connectUserToOrganization({ }) { // Use primary since before this we might have just created the invite // If we use replica it might not find the invite - const invite = await db.$primary().invite.findUnique({ + const invite = await db.invite.findUnique({ where: { id: inviteId, }, @@ -202,13 +204,15 @@ export async function connectUserToOrganization({ * current subscription period for an organization */ export async function getOrganizationBillingEventsCount( - organization: IServiceOrganization & { projects: IServiceProject[] }, + organization: IServiceOrganization & { projects: IServiceProject[] } ) { // Dont count events if the organization has no subscription // Since we only use this for billing purposes if ( - !organization.subscriptionCurrentPeriodStart || - !organization.subscriptionCurrentPeriodEnd + !( + organization.subscriptionCurrentPeriodStart && + organization.subscriptionCurrentPeriodEnd + ) ) { return 0; } @@ -232,7 +236,7 @@ export async function getOrganizationBillingEventsCountSerie( }: { startDate: Date; endDate: Date; - }, + } ) { const interval = 'day'; const { sb, getSql } = createSqlBuilder(); @@ -251,12 +255,12 @@ export async function getOrganizationBillingEventsCountSerie( export const getOrganizationBillingEventsCountSerieCached = cacheable( getOrganizationBillingEventsCountSerie, - 60 * 10, + 60 * 10 ); export async function getOrganizationSubscriptionChartEndDate( projectId: string, - endDate: string, + endDate: string ) { const organization = await getOrganizationByProjectIdCached(projectId); if (!organization) { diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index 3144adcf9..60effcef7 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -29,7 +29,7 @@ export async function getProjectById(id: string) { export const getProjectByIdCached = cacheable(getProjectById, 60 * 60 * 24); export async function getProjectWithClients(id: string) { - const res = await db.$primary().project.findUnique({ + const res = await db.project.findUnique({ where: { id, }, diff --git a/packages/mcp/src/handler.ts b/packages/mcp/src/handler.ts index 6db01db33..9fcff9074 100644 --- a/packages/mcp/src/handler.ts +++ b/packages/mcp/src/handler.ts @@ -10,8 +10,10 @@ const logger = createLogger({ name: 'mcp:handler' }); /** * Handle a POST /mcp request. * - * - If Mcp-Session-Id is present, routes to the existing session. - * - Otherwise authenticates via token, creates a new session, and handles. + * - If Mcp-Session-Id is present and the session is local: route to existing transport. + * - If Mcp-Session-Id is present but not local: check Redis — if context found, + * recreate server+transport on this instance (cross-instance migration). + * - Otherwise authenticate via token and create a new session. * * Writes directly to `res` (caller must have hijacked the Fastify reply). */ @@ -25,13 +27,24 @@ export async function handleMcpPost( const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId) { - const session = sessionManager.get(sessionId); - if (!session) { + // Fast path: session is already on this instance + const local = sessionManager.getLocal(sessionId); + if (local) { + await sessionManager.touchContext(sessionId); + await local.transport.handleRequest(req, res, body); + return; + } + + // Slow path: session exists on another instance — retrieve context from Redis + const context = await sessionManager.getContext(sessionId); + if (!context) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Session not found or expired' })); return; } - await session.transport.handleRequest(req, res, body); + + logger.info('MCP session migrated to this instance', { sessionId }); + await attachSession(sessionManager, sessionId, context, req, res, body); return; } @@ -40,28 +53,7 @@ export async function handleMcpPost( try { const context = await authenticateToken(token); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => sessionManager.generateId(), - onsessioninitialized: (id: string) => { - sessionManager.set(id, { - server, - transport, - context, - lastActivity: Date.now(), - }); - logger.info('MCP session initialized', { - sessionId: id, - clientType: context.clientType, - organizationId: context.organizationId, - projectId: context.projectId, - }); - }, - }); - - const server = createMcpServer(context); - await server.connect(transport); - await transport.handleRequest(req, res, body); + await attachSession(sessionManager, null, context, req, res, body); } catch (err) { if (err instanceof McpAuthError) { res.writeHead(401, { 'Content-Type': 'application/json' }); @@ -74,8 +66,49 @@ export async function handleMcpPost( } } +/** + * Create (or recreate) a server+transport for the given context, handle the + * request, and register the session locally + in Redis. + * + * @param fixedSessionId When migrating an existing session, pass its ID so we + * reuse the same session ID rather than generating a new one. + */ +async function attachSession( + sessionManager: SessionManager, + fixedSessionId: string | null, + context: Parameters[0], + req: IncomingMessage, + res: ServerResponse, + body: unknown, +): Promise { + const server = createMcpServer(context); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: fixedSessionId + ? () => fixedSessionId + : () => sessionManager.generateId(), + onsessioninitialized: async (id: string) => { + sessionManager.setLocal(id, { server, transport }); + await sessionManager.setContext(id, context); + logger.info('MCP session initialized', { + sessionId: id, + clientType: context.clientType, + organizationId: context.organizationId, + projectId: context.projectId, + }); + }, + }); + + await server.connect(transport); + await transport.handleRequest(req, res, body); +} + /** * Handle a GET /mcp request (SSE stream for an existing session). + * + * SSE streams are tied to the instance they started on. If the session is not + * local (i.e., it was started on a different instance), return 404 so the + * client reconnects and establishes a fresh session on this instance. */ export async function handleMcpGet( sessionManager: SessionManager, @@ -89,10 +122,14 @@ export async function handleMcpGet( return; } - const session = sessionManager.get(sessionId); + const session = sessionManager.getLocal(sessionId); if (!session) { res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Session not found or expired' })); + res.end( + JSON.stringify({ + error: 'Session not found on this instance — reconnect to start a new session', + }), + ); return; } diff --git a/packages/mcp/src/integration/setup.ts b/packages/mcp/src/integration/setup.ts index 4279e3ebe..3ebacc3a9 100644 --- a/packages/mcp/src/integration/setup.ts +++ b/packages/mcp/src/integration/setup.ts @@ -25,9 +25,9 @@ export async function setup() { .split(';') .map((s) => s.trim()) .filter((s) => s.length > 0); - for (const statement of statements) { - await client.command({ query: statement }); - } + // Run all CREATE TABLE / CREATE DATABASE statements in parallel — they are + // independent so there's no ordering requirement. + await Promise.all(statements.map((statement) => client.command({ query: statement }))); // Clean up any leftover data from a previous run await cleanTestData(client); diff --git a/packages/mcp/src/integration/tools.test.ts b/packages/mcp/src/integration/tools.test.ts index 17447758e..3448557be 100644 --- a/packages/mcp/src/integration/tools.test.ts +++ b/packages/mcp/src/integration/tools.test.ts @@ -14,7 +14,7 @@ * that function — all ClickHouse queries still run for real. */ -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; vi.mock('@openpanel/db', async (importOriginal) => { const actual = await importOriginal(); @@ -24,6 +24,16 @@ vi.mock('@openpanel/db', async (importOriginal) => { }; }); +// Bypass Redis caching — prevents ioredis TCP connections that hang the process +vi.mock('@openpanel/redis', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCache: async (_key: string, _ttl: number, fn: () => Promise) => fn(), + }; +}); + +import { setup, teardown } from './setup'; import { registerActiveUserTools } from '../tools/analytics/active-users'; import { registerEngagementTools } from '../tools/analytics/engagement'; import { registerEventNameTools } from '../tools/analytics/event-names'; @@ -48,6 +58,10 @@ const CTX = { clientType: 'read' as const, }; +// Run ClickHouse fixture setup only when this file is executed (not for unit tests) +beforeAll(() => setup(), 30_000); +afterAll(() => teardown(), 10_000); + function makeServer() { const handlers = new Map Promise>(); return { diff --git a/packages/mcp/src/session-manager.ts b/packages/mcp/src/session-manager.ts index e5abca65b..8f5f92729 100644 --- a/packages/mcp/src/session-manager.ts +++ b/packages/mcp/src/session-manager.ts @@ -2,108 +2,101 @@ import { randomUUID } from 'node:crypto'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createLogger } from '@openpanel/logger'; +import { getRedisCache } from '@openpanel/redis'; import type { McpAuthContext } from './auth'; const logger = createLogger({ name: 'mcp:sessions' }); -interface McpSession { +const SESSION_TTL_SECONDS = 30 * 60; // 30 minutes + +function redisKey(id: string) { + return `mcp:session:${id}`; +} + +interface McpLocalSession { server: McpServer; transport: StreamableHTTPServerTransport; - context: McpAuthContext; - lastActivity: number; } -const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes -const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // check every 5 minutes - +/** + * Hybrid session manager: + * - Auth context is stored in Redis (shared across all API instances, TTL 30 min) + * - Active transport/server are kept in a local Map (in-process only — they hold live HTTP connections) + * + * This means POST requests can be handled by any instance: if the transport + * isn't local, we retrieve the context from Redis and recreate it here. + * SSE (GET) streams are inherently tied to the instance they started on; + * when that instance goes down the client reconnects and gets a fresh session. + */ export class SessionManager { - private sessions = new Map(); - private cleanupTimer: ReturnType | null = null; - - constructor() { - this.cleanupTimer = setInterval( - () => this.cleanup(), - CLEANUP_INTERVAL_MS, - ); - // Don't keep the process alive just for session cleanup - this.cleanupTimer.unref(); - } + private local = new Map(); generateId(): string { return randomUUID(); } - set(id: string, session: McpSession): void { - this.sessions.set(id, session); - logger.info('MCP session created', { + // --- context (Redis) --- + + async setContext(id: string, context: McpAuthContext): Promise { + await getRedisCache().setJson(redisKey(id), SESSION_TTL_SECONDS, context); + logger.info('MCP session context stored', { sessionId: id, - clientType: session.context.clientType, - organizationId: session.context.organizationId, - projectId: session.context.projectId, + clientType: context.clientType, + organizationId: context.organizationId, + projectId: context.projectId, }); } - get(id: string): McpSession | undefined { - const session = this.sessions.get(id); - if (session) { - session.lastActivity = Date.now(); - } - return session; + async getContext(id: string): Promise { + return getRedisCache().getJson(redisKey(id)); } - has(id: string): boolean { - return this.sessions.has(id); + async touchContext(id: string): Promise { + await getRedisCache().expire(redisKey(id), SESSION_TTL_SECONDS); } - async close(id: string): Promise { - const session = this.sessions.get(id); - if (!session) return; + async deleteContext(id: string): Promise { + await getRedisCache().del(redisKey(id)); + } - this.sessions.delete(id); + // --- transport/server (local) --- - try { - await session.transport.close(); - } catch (err) { - logger.warn('Error closing MCP transport', { sessionId: id, err }); - } + setLocal(id: string, session: McpLocalSession): void { + this.local.set(id, session); + } - logger.info('MCP session closed', { sessionId: id }); + getLocal(id: string): McpLocalSession | undefined { + return this.local.get(id); } - get size(): number { - return this.sessions.size; + deleteLocal(id: string): void { + this.local.delete(id); } - private async cleanup(): Promise { - const now = Date.now(); - const expired: string[] = []; + // --- combined ops --- - for (const [id, session] of this.sessions) { - if (now - session.lastActivity > SESSION_TTL_MS) { - expired.push(id); - } - } + async close(id: string): Promise { + const session = this.local.get(id); + this.local.delete(id); + await this.deleteContext(id); - for (const id of expired) { - logger.info('MCP session expired', { sessionId: id }); - await this.close(id); + if (session) { + try { + await session.transport.close(); + } catch (err) { + logger.warn('Error closing MCP transport', { sessionId: id, err }); + } } - if (expired.length > 0) { - logger.info('MCP session cleanup complete', { - expired: expired.length, - remaining: this.sessions.size, - }); - } + logger.info('MCP session closed', { sessionId: id }); } async destroy(): Promise { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = null; - } - for (const id of [...this.sessions.keys()]) { - await this.close(id); - } + const ids = [...this.local.keys()]; + await Promise.all(ids.map((id) => this.close(id))); + } + + get localSize(): number { + return this.local.size; } } diff --git a/packages/mcp/src/test-setup.ts b/packages/mcp/src/test-setup.ts new file mode 100644 index 000000000..be1c74424 --- /dev/null +++ b/packages/mcp/src/test-setup.ts @@ -0,0 +1,11 @@ +/** + * Runs after every test file in this package (via setupFiles in vitest.config.ts). + * + * Closes the ClickHouse client's keep-alive connection pool so Vitest's worker + * thread can exit cleanly. Without this the process hangs waiting for idle + * sockets to time out (~10 s). + */ +import { afterAll } from 'vitest'; +import { originalCh } from '@openpanel/db'; + +afterAll(() => originalCh.close()); diff --git a/packages/mcp/src/tools/analytics/profiles.test.ts b/packages/mcp/src/tools/analytics/profiles.test.ts index c0043f8f4..ce9ac74c8 100644 --- a/packages/mcp/src/tools/analytics/profiles.test.ts +++ b/packages/mcp/src/tools/analytics/profiles.test.ts @@ -27,7 +27,7 @@ vi.mock('@openpanel/db', () => ({ import { findProfilesCore } from './profiles'; function capturedSql(): string { - return mockChQuery.mock.calls[0][0] as string; + return mockChQuery.mock.calls[0]?.[0] as string; } beforeEach(() => { diff --git a/packages/mcp/src/tools/analytics/profiles.ts b/packages/mcp/src/tools/analytics/profiles.ts index 9e1ad4418..80fdf660b 100644 --- a/packages/mcp/src/tools/analytics/profiles.ts +++ b/packages/mcp/src/tools/analytics/profiles.ts @@ -7,6 +7,7 @@ import type { import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; +import { profileUrl, sessionUrl } from '../dashboard-links'; import { projectIdSchema, resolveProjectId, @@ -218,7 +219,11 @@ export function registerProfileTools( async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); - return findProfilesCore({ projectId, ...input }); + const profiles = await findProfilesCore({ projectId, ...input }); + return profiles.map((p) => ({ + ...p, + dashboard_url: profileUrl(context.organizationId, projectId, p.id), + })); }), ); @@ -243,7 +248,10 @@ export function registerProfileTools( if (!result.profile) { return { error: 'Profile not found', profileId }; } - return result; + return { + ...result, + dashboard_url: profileUrl(context.organizationId, projectId, profileId), + }; }), ); @@ -265,7 +273,15 @@ export function registerProfileTools( withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); const sessions = await getProfileSessionsCore(projectId, profileId, limit); - return { profileId, session_count: sessions.length, sessions }; + return { + profileId, + dashboard_url: profileUrl(context.organizationId, projectId, profileId), + session_count: sessions.length, + sessions: sessions.map((s) => ({ + ...s, + dashboard_url: sessionUrl(context.organizationId, projectId, s.id), + })), + }; }), ); } diff --git a/packages/mcp/src/tools/analytics/reports.ts b/packages/mcp/src/tools/analytics/reports.ts new file mode 100644 index 000000000..4f85f4792 --- /dev/null +++ b/packages/mcp/src/tools/analytics/reports.ts @@ -0,0 +1,147 @@ +import { + AggregateChartEngine, + ChartEngine, + db, + funnelService, + getChartStartEndDate, + getReportById, + getReportsByDashboardId, + getSettingsForProject, +} from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { dashboardBaseUrl } from '../dashboard-links'; +import { + projectIdSchema, + resolveProjectId, + withErrorHandling, +} from '../shared'; + +function reportUrl( + organizationId: string, + projectId: string, + reportId: string, +) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/reports/${reportId}`; +} + +function dashboardUrl( + organizationId: string, + projectId: string, + dashboardId: string, +) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/dashboards/${dashboardId}`; +} + +export function registerReportTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_dashboards', + 'List all dashboards for a project. Returns dashboard IDs and names. Use these IDs with list_reports to see what reports each dashboard contains.', + { + projectId: projectIdSchema(context), + }, + async ({ projectId: inputProjectId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const dashboards = await db.dashboard.findMany({ + where: { projectId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, projectId: true }, + }); + return dashboards.map((d) => ({ + ...d, + dashboard_url: dashboardUrl(context.organizationId, projectId, d.id), + })); + }), + ); + + server.tool( + 'list_reports', + 'List all reports in a dashboard. Returns report IDs, names, chart types, and the events/metrics they track. Use get_report_data to execute a report and retrieve its actual data.', + { + projectId: projectIdSchema(context), + dashboardId: z.string().describe('The dashboard ID to list reports for'), + }, + async ({ projectId: inputProjectId, dashboardId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const reports = await getReportsByDashboardId(dashboardId); + return reports.map((r) => ({ + id: r.id, + name: r.name, + chartType: r.chartType, + range: r.range, + interval: r.interval, + metric: r.metric, + series: r.series.map((s) => + s.type === 'formula' + ? { type: 'formula', id: s.id, formula: s.formula } + : { + type: 'event', + id: s.id, + name: s.name, + displayName: s.displayName, + segment: s.segment, + }, + ), + breakdowns: r.breakdowns, + dashboard_url: reportUrl(context.organizationId, projectId, r.id), + })); + }), + ); + + server.tool( + 'get_report_data', + 'Execute a saved report and return its data. Works for all chart types: linear/bar/area/pie/map (time-series or breakdowns), metric (aggregate numbers), and funnel (conversion steps). Pass the report ID from list_reports.', + { + projectId: projectIdSchema(context), + reportId: z.string().describe('The report ID to execute'), + }, + async ({ projectId: inputProjectId, reportId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const report = await getReportById(reportId); + + if (!report) { + return { error: 'Report not found', reportId }; + } + + if (report.projectId !== projectId) { + return { error: 'Report does not belong to this project', reportId }; + } + + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(report, timezone); + const chartInput = { ...report, startDate, endDate, timezone }; + + const meta = { + id: report.id, + name: report.name, + chartType: report.chartType, + range: report.range, + interval: report.interval, + startDate, + endDate, + dashboard_url: reportUrl(context.organizationId, projectId, reportId), + }; + + if (report.chartType === 'funnel') { + const result = await funnelService.getFunnel(chartInput); + return { ...meta, data: result }; + } + + if (report.chartType === 'metric') { + const result = await AggregateChartEngine.execute(chartInput); + return { ...meta, data: result }; + } + + // linear, bar, histogram, pie, area, map, etc. + const result = await ChartEngine.execute(chartInput); + return { ...meta, data: result }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/sessions.ts b/packages/mcp/src/tools/analytics/sessions.ts index 99711ad40..17868c56d 100644 --- a/packages/mcp/src/tools/analytics/sessions.ts +++ b/packages/mcp/src/tools/analytics/sessions.ts @@ -3,6 +3,7 @@ import type { IClickhouseSession } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; +import { sessionUrl } from '../dashboard-links'; import { projectIdSchema, resolveDateRange, @@ -124,7 +125,11 @@ export function registerSessionTools( async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); - return querySessionsCore({ projectId, ...input }); + const sessions = await querySessionsCore({ projectId, ...input }); + return sessions.map((s) => ({ + ...s, + dashboard_url: sessionUrl(context.organizationId, projectId, s.id), + })); }), ); } diff --git a/packages/mcp/src/tools/dashboard-links.ts b/packages/mcp/src/tools/dashboard-links.ts new file mode 100644 index 000000000..59857f8fe --- /dev/null +++ b/packages/mcp/src/tools/dashboard-links.ts @@ -0,0 +1,69 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../auth'; +import { projectIdSchema, resolveProjectId, withErrorHandling } from './shared'; + +export function dashboardBaseUrl() { + return ( + process.env.DASHBOARD_URL || + process.env.NEXT_PUBLIC_DASHBOARD_URL || + 'https://dashboard.openpanel.dev' + ).replace(/\/$/, ''); +} + +export function profileUrl(organizationId: string, projectId: string, profileId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/profiles/${profileId}`; +} + +export function sessionUrl(organizationId: string, projectId: string, sessionId: string) { + return `${dashboardBaseUrl()}/${organizationId}/${projectId}/sessions/${sessionId}`; +} + +export function registerDashboardLinkTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'get_dashboard_urls', + 'Get clickable dashboard URLs for the current project. Returns links to all main sections (overview, events, profiles, sessions, etc.) and optionally deep-links to a specific profile, session, dashboard, or report when their IDs are provided. Use these links to let the user navigate directly to relevant pages.', + { + projectId: projectIdSchema(context), + profileId: z.string().optional().describe('Profile ID to get a direct link to that profile'), + sessionId: z.string().optional().describe('Session ID to get a direct link to that session'), + dashboardId: z.string().optional().describe('Dashboard ID to get a direct link to that dashboard'), + reportId: z.string().optional().describe('Report ID to get a direct link to that report'), + }, + async ({ projectId: inputProjectId, profileId, sessionId, dashboardId, reportId }) => + withErrorHandling(async () => { + const projectId = resolveProjectId(context, inputProjectId); + const base = `${dashboardBaseUrl()}/${context.organizationId}/${projectId}`; + + const urls: Record = { + overview: base, + events: `${base}/events`, + profiles: `${base}/profiles`, + sessions: `${base}/sessions`, + dashboards: `${base}/dashboards`, + reports: `${base}/reports`, + realtime: `${base}/realtime`, + pages: `${base}/pages`, + insights: `${base}/insights`, + }; + + if (profileId) { + urls.profile = `${base}/profiles/${profileId}`; + } + if (sessionId) { + urls.session = `${base}/sessions/${sessionId}`; + } + if (dashboardId) { + urls.dashboard = `${base}/dashboards/${dashboardId}`; + } + if (reportId) { + urls.report = `${base}/reports/${reportId}`; + } + + return urls; + }), + ); +} diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index e94b1ca2d..8ee13ef0f 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -12,6 +12,7 @@ import { registerPageTools } from './analytics/pages'; import { registerProfileMetricTools } from './analytics/profile-metrics'; import { registerProfileTools } from './analytics/profiles'; import { registerPropertyValueTools } from './analytics/property-values'; +import { registerReportTools } from './analytics/reports'; import { registerRetentionTools } from './analytics/retention'; import { registerSessionTools } from './analytics/sessions'; import { registerTrafficTools } from './analytics/traffic'; @@ -20,11 +21,18 @@ import { registerGscCannibalizationTools } from './gsc/cannibalization'; import { registerGscOverviewTools } from './gsc/overview'; import { registerGscPageTools } from './gsc/pages'; import { registerGscQueryTools } from './gsc/queries'; +import { registerDashboardLinkTools } from './dashboard-links'; +import { registerProjectTools } from './projects'; export function registerAllTools( server: McpServer, context: McpAuthContext, ): void { + // Project access — always call first to discover available projects + registerProjectTools(server, context); + registerDashboardLinkTools(server, context); + registerReportTools(server, context); + // Analytics — discovery (call these first to understand the data) registerEventNameTools(server, context); registerPropertyValueTools(server, context); diff --git a/packages/mcp/src/tools/projects.ts b/packages/mcp/src/tools/projects.ts new file mode 100644 index 000000000..0a3e4742a --- /dev/null +++ b/packages/mcp/src/tools/projects.ts @@ -0,0 +1,54 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { db } from '@openpanel/db'; +import type { McpAuthContext } from '../auth'; +import { withErrorHandling } from './shared'; + +export function registerProjectTools( + server: McpServer, + context: McpAuthContext, +) { + server.tool( + 'list_projects', + context.clientType === 'root' + ? 'List all projects in your organization. Use the returned project IDs when calling other tools that require a projectId.' + : 'Returns the single project this client has access to.', + {}, + async () => + withErrorHandling(async () => { + if (context.clientType === 'root') { + const projects = await db.project.findMany({ + where: { organizationId: context.organizationId }, + orderBy: { eventsCount: 'desc' }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }); + return { clientType: 'root', projects }; + } + + const project = context.projectId + ? await db.project.findUnique({ + where: { id: context.projectId }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }) + : null; + + return { + clientType: 'read', + projects: project ? [project] : [], + }; + }), + ); +} diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts index 84cba7a6c..623dfd8f5 100644 --- a/packages/mcp/src/tools/shared.ts +++ b/packages/mcp/src/tools/shared.ts @@ -1,5 +1,8 @@ -import type { McpAuthContext } from '../auth'; +import { createLogger } from '@openpanel/logger'; import { z } from 'zod'; +import type { McpAuthContext } from '../auth'; + +const logger = createLogger({ name: 'mcp' }); /** * Build the projectId portion of an input schema. @@ -11,11 +14,10 @@ export function projectIdSchema(context: McpAuthContext) { return context.projectId === null ? z .string() - .uuid() .describe( - 'Project ID to query (required for organization-level access)', + 'Project ID to query (required for organization-level access)' ) - : z.string().uuid().optional(); + : z.string().optional(); } /** @@ -23,14 +25,14 @@ export function projectIdSchema(context: McpAuthContext) { */ export function resolveProjectId( context: McpAuthContext, - inputProjectId: string | undefined, + inputProjectId: string | undefined ): string { if (context.projectId !== null) { return context.projectId; } if (!inputProjectId) { throw new Error( - 'projectId is required when using a root (organization-level) client', + 'projectId is required when using a root (organization-level) client' ); } return inputProjectId; @@ -44,11 +46,15 @@ export const zDateRange = { startDate: z .string() .optional() - .describe('Start date in YYYY-MM-DD format (e.g. 2024-01-01). Defaults to 30 days ago.'), + .describe( + 'Start date in YYYY-MM-DD format (e.g. 2024-01-01). Defaults to 30 days ago.' + ), endDate: z .string() .optional() - .describe('End date in YYYY-MM-DD format (e.g. 2024-03-31). Defaults to today.'), + .describe( + 'End date in YYYY-MM-DD format (e.g. 2024-03-31). Defaults to today.' + ), }; /** @@ -56,19 +62,21 @@ export const zDateRange = { */ export function resolveDateRange( startDate?: string, - endDate?: string, + endDate?: string ): { startDate: string; endDate: string } { const end = endDate ?? new Date().toISOString().slice(0, 10); const start = startDate ?? - new Date(Date.now() - 30 * 86400_000).toISOString().slice(0, 10); + new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); return { startDate: start, endDate: end }; } /** * Serialize a tool result to MCP content format. */ -export function toText(data: unknown): { content: [{ type: 'text'; text: string }] } { +export function toText(data: unknown): { + content: [{ type: 'text'; text: string }]; +} { return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }], }; @@ -78,13 +86,14 @@ export function toText(data: unknown): { content: [{ type: 'text'; text: string * Wrap a tool handler to catch errors and return them as MCP error content. */ export async function withErrorHandling( - fn: () => Promise, + fn: () => Promise ): Promise<{ content: [{ type: 'text'; text: string }]; isError?: boolean }> { try { const result = await fn(); return toText(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); + logger.error(`MCP tool error: ${message}`, { err }); return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true, diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index d91931b0c..dbd8a0797 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -3,6 +3,8 @@ import { getSharedVitestConfig } from '../../vitest.shared'; export default mergeConfig(getSharedVitestConfig({ __dirname }), { test: { - globalSetup: ['./src/integration/setup.ts'], + // Closes the ClickHouse keep-alive connection pool after every test file + // so the worker thread can exit without hanging. + setupFiles: ['./src/test-setup.ts'], }, }); diff --git a/packages/trpc/src/routers/widget.ts b/packages/trpc/src/routers/widget.ts index 6e54b4f5b..acfbbfb3a 100644 --- a/packages/trpc/src/routers/widget.ts +++ b/packages/trpc/src/routers/widget.ts @@ -1,22 +1,15 @@ -import ShortUniqueId from 'short-unique-id'; -import { z } from 'zod'; - import { - TABLE_NAMES, ch, clix, db, eventBuffer, getSettingsForProject, + TABLE_NAMES, } from '@openpanel/db'; import { getCache } from '@openpanel/redis'; -import { - zCounterWidgetOptions, - zRealtimeWidgetOptions, - zWidgetOptions, - zWidgetType, -} from '@openpanel/validation'; - +import { zWidgetOptions, zWidgetType } from '@openpanel/validation'; +import ShortUniqueId from 'short-unique-id'; +import { z } from 'zod'; import { TRPCNotFoundError } from '../errors'; import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; @@ -24,11 +17,11 @@ const uid = new ShortUniqueId({ length: 6 }); // Helper to find widget by projectId and type async function findWidgetByType(projectId: string, type: string) { - const widgets = await db.$primary().shareWidget.findMany({ + const widgets = await db.shareWidget.findMany({ where: { projectId }, }); return widgets.find( - (w) => (w.options as z.infer)?.type === type, + (w) => (w.options as z.infer)?.type === type ); } @@ -54,7 +47,7 @@ export const widgetRouter = createTRPCRouter({ organizationId: z.string(), type: zWidgetType, enabled: z.boolean(), - }), + }) ) .mutation(async ({ input }) => { const existing = await findWidgetByType(input.projectId, input.type); @@ -95,12 +88,12 @@ export const widgetRouter = createTRPCRouter({ projectId: z.string(), organizationId: z.string(), options: zWidgetOptions, - }), + }) ) .mutation(async ({ input }) => { const existing = await findWidgetByType( input.projectId, - input.options.type, + input.options.type ); if (existing) { @@ -131,7 +124,7 @@ export const widgetRouter = createTRPCRouter({ }, }); - if (!widget || !widget.public) { + if (!(widget && widget.public)) { throw TRPCNotFoundError('Widget not found'); } @@ -154,7 +147,7 @@ export const widgetRouter = createTRPCRouter({ }, }); - if (!widget || !widget.public) { + if (!(widget && widget.public)) { throw TRPCNotFoundError('Widget not found'); } @@ -179,7 +172,7 @@ export const widgetRouter = createTRPCRouter({ const result = await uniqueVisitorsQuery.execute(); return result[0]?.count || 0; - }, + } ); return { @@ -206,7 +199,7 @@ export const widgetRouter = createTRPCRouter({ }, }); - if (!widget || !widget.public) { + if (!(widget && widget.public)) { throw TRPCNotFoundError('Widget not found'); } @@ -245,7 +238,7 @@ export const widgetRouter = createTRPCRouter({ .fill( clix.exp('toStartOfMinute(now() - INTERVAL 30 MINUTE)'), clix.exp('toStartOfMinute(now())'), - clix.exp('INTERVAL 1 MINUTE'), + clix.exp('INTERVAL 1 MINUTE') ); // Conditionally fetch countries From 1f721c8dc66d005483fb04b9ee63feec1b119bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 10:40:17 +0200 Subject: [PATCH 04/18] mcp api --- apps/api/src/controllers/query.controller.ts | 722 ++++++++++++++++++ apps/api/src/index.ts | 2 + apps/api/src/routes/mcp.router.ts | 3 +- apps/api/src/routes/query.router.ts | 85 +++ packages/mcp/index.ts | 69 ++ .../mcp/src/tools/analytics/active-users.ts | 23 + .../mcp/src/tools/analytics/engagement.ts | 33 + .../mcp/src/tools/analytics/event-names.ts | 3 + packages/mcp/src/tools/analytics/groups.ts | 44 ++ .../src/tools/analytics/page-performance.ts | 42 + .../src/tools/analytics/profile-metrics.ts | 26 + .../src/tools/analytics/property-values.ts | 44 ++ packages/mcp/src/tools/analytics/reports.ts | 95 +++ packages/mcp/src/tools/analytics/retention.ts | 4 + packages/mcp/src/tools/analytics/traffic.ts | 11 +- packages/mcp/src/tools/analytics/user-flow.ts | 40 + packages/mcp/src/tools/gsc/cannibalization.ts | 12 + packages/mcp/src/tools/gsc/overview.ts | 33 + packages/mcp/src/tools/gsc/pages.ts | 28 + packages/mcp/src/tools/gsc/queries.ts | 51 ++ packages/mcp/src/tools/projects.ts | 41 + 21 files changed, 1409 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/controllers/query.controller.ts create mode 100644 apps/api/src/routes/query.router.ts diff --git a/apps/api/src/controllers/query.controller.ts b/apps/api/src/controllers/query.controller.ts new file mode 100644 index 000000000..3252ce8a0 --- /dev/null +++ b/apps/api/src/controllers/query.controller.ts @@ -0,0 +1,722 @@ +import { parseQueryString } from '@/utils/parse-zod-query-string'; +import { + findGroupsCore, + findProfilesCore, + getEngagementCore, + getEntryExitPagesCore, + getEventPropertyValuesCore, + getFunnelCore, + getGroupCore, + getPagePerformanceCore, + getProfileMetricsCore, + getProfileWithEvents, + getProfileSessionsCore, + getReportDataCore, + getRetentionCohortCore, + getRollingActiveUsersCore, + getTopPagesCore, + getTrafficBreakdownCore, + getUserFlowCore, + getWeeklyRetentionSeriesCore, + gscGetCannibalizationCore, + gscGetOverviewCore, + gscGetPageDetailsCore, + gscGetQueryDetailsCore, + gscGetQueryOpportunitiesCore, + gscGetTopPagesCore, + gscGetTopQueriesCore, + listDashboardsCore, + listEventNamesCore, + listEventPropertiesCore, + listGroupTypesCore, + listProjectsCore, + listReportsCore, + getAnalyticsOverviewCore, + queryEventsCore, + querySessionsCore, + resolveDateRange, + type TrafficColumn, +} from '@openpanel/mcp'; +import { ClientType } from '@openpanel/db'; +import type { IServiceClientWithProject } from '@openpanel/db'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveQueryProjectId( + client: IServiceClientWithProject, + urlProjectId: string | undefined, +): string { + if (client.type === ClientType.root) { + if (!urlProjectId) { + throw new Error('projectId URL parameter is required for root clients'); + } + return urlProjectId; + } + if (!client.projectId) { + throw new Error('Client is not associated with a project'); + } + return client.projectId; +} + +const zDateRange = z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), +}); + +type RequestWithProjectParam = FastifyRequest<{ + Params: { projectId?: string }; +}>; + +function getProjectId(req: RequestWithProjectParam): string { + return resolveQueryProjectId(req.client!, req.params.projectId); +} + +function getOrgId(req: RequestWithProjectParam): string { + return req.client!.organizationId; +} + +function getClientType( + req: RequestWithProjectParam, +): 'root' | 'read' { + return req.client!.type === ClientType.root ? 'root' : 'read'; +} + +function badRequest(reply: FastifyReply, error: z.ZodError) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Invalid query parameters', + details: error.issues, + }); +} + +// --------------------------------------------------------------------------- +// Projects +// --------------------------------------------------------------------------- + +export async function listProjects( + req: FastifyRequest, + reply: FastifyReply, +) { + const client = req.client!; + return reply.send( + await listProjectsCore({ + clientType: getClientType(req as RequestWithProjectParam), + organizationId: client.organizationId, + projectId: client.projectId ?? null, + }), + ); +} + +// --------------------------------------------------------------------------- +// Analytics — overview +// --------------------------------------------------------------------------- + +const zOverviewQuery = zDateRange.extend({ + interval: z.enum(['hour', 'day', 'week', 'month']).optional(), +}); + +export async function getOverview( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zOverviewQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await getAnalyticsOverviewCore({ projectId, startDate, endDate, interval: parsed.data.interval })); +} + +// --------------------------------------------------------------------------- +// Analytics — active users +// --------------------------------------------------------------------------- + +const zActiveUsersQuery = z.object({ + days: z.number().int().min(1).max(90).default(7), +}); + +export async function getActiveUsers( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zActiveUsersQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await getRollingActiveUsersCore({ projectId, days: parsed.data.days })); +} + +// --------------------------------------------------------------------------- +// Analytics — retention series +// --------------------------------------------------------------------------- + +export async function getRetentionSeries( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await getWeeklyRetentionSeriesCore(projectId)); +} + +// --------------------------------------------------------------------------- +// Analytics — retention cohort +// --------------------------------------------------------------------------- + +export async function getRetentionCohort( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await getRetentionCohortCore(projectId)); +} + +// --------------------------------------------------------------------------- +// Analytics — pages (top) +// --------------------------------------------------------------------------- + +export async function getTopPages( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zDateRange.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await getTopPagesCore({ projectId, startDate, endDate })); +} + +// --------------------------------------------------------------------------- +// Analytics — pages (entry/exit) +// --------------------------------------------------------------------------- + +const zEntryExitQuery = zDateRange.extend({ + mode: z.enum(['entry', 'exit']).default('entry'), +}); + +export async function getEntryExitPages( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zEntryExitQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await getEntryExitPagesCore({ projectId, startDate, endDate, mode: parsed.data.mode })); +} + +// --------------------------------------------------------------------------- +// Analytics — page performance +// --------------------------------------------------------------------------- + +const zPagePerfQuery = zDateRange.extend({ + search: z.string().optional(), + sortBy: z.enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), + limit: z.number().int().min(1).max(500).default(50), +}); + +export async function getPagePerformance( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zPagePerfQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await getPagePerformanceCore({ projectId, startDate, endDate, ...parsed.data }), + ); +} + +// --------------------------------------------------------------------------- +// Analytics — funnel +// --------------------------------------------------------------------------- + +const zFunnelQuery = zDateRange.extend({ + steps: z + .union([z.array(z.string()), z.string().transform((s) => [s])]) + .refine((a) => a.length >= 2 && a.length <= 10, { + message: 'steps must have between 2 and 10 items', + }), + windowHours: z.number().int().min(1).max(720).default(24), + groupBy: z.enum(['session_id', 'profile_id']).default('session_id'), +}); + +export async function getFunnel( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zFunnelQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await getFunnelCore({ + projectId, + startDate, + endDate, + steps: parsed.data.steps, + windowHours: parsed.data.windowHours, + groupBy: parsed.data.groupBy, + }), + ); +} + +// --------------------------------------------------------------------------- +// Analytics — traffic (referrers / geo / devices) +// --------------------------------------------------------------------------- + +const referrerColumns = ['referrer_name', 'referrer_type', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'] as const; +const geoColumns = ['country', 'region', 'city'] as const; +const deviceColumns = ['device', 'browser', 'os'] as const; + +const zReferrerQuery = zDateRange.extend({ + breakdown: z.enum(referrerColumns).default('referrer_name'), +}); + +const zGeoQuery = zDateRange.extend({ + breakdown: z.enum(geoColumns).default('country'), +}); + +const zDeviceQuery = zDateRange.extend({ + breakdown: z.enum(deviceColumns).default('device'), +}); + +export async function getTrafficReferrers( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zReferrerQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), + ); +} + +export async function getTrafficGeo( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGeoQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), + ); +} + +export async function getTrafficDevices( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zDeviceQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), + ); +} + +// --------------------------------------------------------------------------- +// Analytics — user flow +// --------------------------------------------------------------------------- + +const zUserFlowQuery = zDateRange.extend({ + startEvent: z.string(), + endEvent: z.string().optional(), + mode: z.enum(['after', 'before', 'between']).default('after'), + steps: z.number().int().min(2).max(10).default(5), + exclude: z + .union([z.array(z.string()), z.string().transform((s) => [s])]) + .optional(), + include: z + .union([z.array(z.string()), z.string().transform((s) => [s])]) + .optional(), +}); + +export async function getUserFlow( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zUserFlowQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await getUserFlowCore({ projectId, startDate, endDate, ...parsed.data }), + ); +} + +// --------------------------------------------------------------------------- +// Analytics — engagement +// --------------------------------------------------------------------------- + +export async function getEngagement( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await getEngagementCore(projectId)); +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +const zEventsQuery = zDateRange.extend({ + eventNames: z + .union([z.array(z.string()), z.string().transform((s) => [s])]) + .optional(), + path: z.string().optional(), + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + referrer: z.string().optional(), + referrerName: z.string().optional(), + referrerType: z.string().optional(), + profileId: z.string().optional(), + properties: z.record(z.string(), z.string()).optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function queryEvents( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zEventsQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send( + await queryEventsCore({ projectId, ...parsed.data }), + ); +} + +export async function listEventNames( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + const names = await listEventNamesCore(projectId); + return reply.send({ event_names: names }); +} + +const zEventPropertiesQuery = z.object({ + eventName: z.string().optional(), +}); + +export async function listEventProperties( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zEventPropertiesQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await listEventPropertiesCore({ projectId, eventName: parsed.data.eventName })); +} + +const zPropertyValuesQuery = z.object({ + eventName: z.string(), + propertyKey: z.string(), +}); + +export async function getEventPropertyValues( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zPropertyValuesQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send( + await getEventPropertyValuesCore({ projectId, ...parsed.data }), + ); +} + +// --------------------------------------------------------------------------- +// Profiles +// --------------------------------------------------------------------------- + +const zProfilesQuery = z.object({ + name: z.string().optional(), + email: z.string().optional(), + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + inactiveDays: z.number().int().min(1).optional(), + minSessions: z.number().int().min(1).optional(), + performedEvent: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function findProfiles( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zProfilesQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await findProfilesCore({ projectId, ...parsed.data })); +} + +const zGetProfileQuery = z.object({ + eventLimit: z.number().int().min(1).max(100).default(20), +}); + +export async function getProfile( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, + reply: FastifyReply, +) { + const parsed = zGetProfileQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const result = await getProfileWithEvents(projectId, req.params.profileId, parsed.data.eventLimit); + if (!result.profile) { + return reply.status(404).send({ error: 'Profile not found', profileId: req.params.profileId }); + } + return reply.send(result); +} + +const zProfileSessionsQuery = z.object({ + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function getProfileSessions( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, + reply: FastifyReply, +) { + const parsed = zProfileSessionsQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const sessions = await getProfileSessionsCore(projectId, req.params.profileId, parsed.data.limit); + return reply.send({ profileId: req.params.profileId, session_count: sessions.length, sessions }); +} + +export async function getProfileMetrics( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send( + await getProfileMetricsCore({ projectId, profileId: req.params.profileId }), + ); +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +const zSessionsQuery = zDateRange.extend({ + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + referrer: z.string().optional(), + referrerName: z.string().optional(), + referrerType: z.string().optional(), + profileId: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function querySessions( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zSessionsQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await querySessionsCore({ projectId, ...parsed.data })); +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +export async function listGroupTypes( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await listGroupTypesCore(projectId)); +} + +const zGroupsQuery = z.object({ + type: z.string().optional(), + search: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function findGroups( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGroupsQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send(await findGroupsCore({ projectId, ...parsed.data })); +} + +const zGetGroupQuery = z.object({ + memberLimit: z.number().int().min(1).max(50).default(10), +}); + +export async function getGroup( + req: FastifyRequest<{ Params: { projectId?: string; groupId: string } }>, + reply: FastifyReply, +) { + const parsed = zGetGroupQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + return reply.send( + await getGroupCore({ projectId, groupId: req.params.groupId, memberLimit: parsed.data.memberLimit }), + ); +} + +// --------------------------------------------------------------------------- +// Dashboards & reports +// --------------------------------------------------------------------------- + +export async function listDashboards( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + const organizationId = getOrgId(req as RequestWithProjectParam); + return reply.send(await listDashboardsCore({ projectId, organizationId })); +} + +export async function listReports( + req: FastifyRequest<{ Params: { projectId?: string; dashboardId: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + const organizationId = getOrgId(req as RequestWithProjectParam); + return reply.send( + await listReportsCore({ projectId, dashboardId: req.params.dashboardId, organizationId }), + ); +} + +export async function getReportData( + req: FastifyRequest<{ Params: { projectId?: string; reportId: string } }>, + reply: FastifyReply, +) { + const projectId = getProjectId(req as RequestWithProjectParam); + const organizationId = getOrgId(req as RequestWithProjectParam); + return reply.send( + await getReportDataCore({ projectId, reportId: req.params.reportId, organizationId }), + ); +} + +// --------------------------------------------------------------------------- +// Google Search Console +// --------------------------------------------------------------------------- + +const zGscOverviewQuery = zDateRange.extend({ + interval: z.enum(['day', 'week', 'month']).default('day'), +}); + +export async function gscOverview( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGscOverviewQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: parsed.data.interval })); +} + +const zGscLimitQuery = zDateRange.extend({ + limit: z.number().int().min(1).max(1000).default(100), +}); + +export async function gscPages( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGscLimitQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: parsed.data.limit })); +} + +const zGscPageDetailsQuery = zDateRange.extend({ + page: z.string().url(), +}); + +export async function gscPageDetails( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGscPageDetailsQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: parsed.data.page })); +} + +export async function gscQueries( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGscLimitQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: parsed.data.limit })); +} + +const zGscQueryDetailsQuery = zDateRange.extend({ + query: z.string(), +}); + +export async function gscQueryDetails( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGscQueryDetailsQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: parsed.data.query }), + ); +} + +const zGscOpportunitiesQuery = zDateRange.extend({ + minImpressions: z.number().int().min(1).default(50), +}); + +export async function gscQueryOpportunities( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zGscOpportunitiesQuery.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send( + await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: parsed.data.minImpressions }), + ); +} + +export async function gscCannibalization( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply, +) { + const parsed = zDateRange.safeParse(parseQueryString(req.query as Record)); + if (!parsed.success) return badRequest(reply, parsed.error); + const projectId = getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + return reply.send(await gscGetCannibalizationCore({ projectId, startDate, endDate })); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 46aaa29c7..57079dd66 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -42,6 +42,7 @@ import insightsRouter from './routes/insights.router'; import liveRouter from './routes/live.router'; import manageRouter from './routes/manage.router'; import mcpRouter from './routes/mcp.router'; +import queryRouter from './routes/query.router'; import miscRouter from './routes/misc.router'; import oauthRouter from './routes/oauth-callback.router'; import profileRouter from './routes/profile.router'; @@ -213,6 +214,7 @@ const startServer = async () => { instance.register(insightsRouter, { prefix: '/insights' }); instance.register(trackRouter, { prefix: '/track' }); instance.register(manageRouter, { prefix: '/manage' }); + instance.register(queryRouter, { prefix: '/query' }); // Keep existing endpoints for backward compatibility instance.get('/healthcheck', healthcheck); // New Kubernetes-style health endpoints diff --git a/apps/api/src/routes/mcp.router.ts b/apps/api/src/routes/mcp.router.ts index 5350eddd4..b35186f78 100644 --- a/apps/api/src/routes/mcp.router.ts +++ b/apps/api/src/routes/mcp.router.ts @@ -56,7 +56,8 @@ const mcpRouter: FastifyPluginAsync = async (fastify) => { .status(400) .send({ error: 'Mcp-Session-Id header is required' }); } - if (!mcpSessionManager.has(sessionId)) { + const context = await mcpSessionManager.getContext(sessionId); + if (!context) { return reply.status(404).send({ error: 'Session not found' }); } await mcpSessionManager.close(sessionId); diff --git a/apps/api/src/routes/query.router.ts b/apps/api/src/routes/query.router.ts new file mode 100644 index 000000000..3b3193d05 --- /dev/null +++ b/apps/api/src/routes/query.router.ts @@ -0,0 +1,85 @@ +import * as controller from '@/controllers/query.controller'; +import { validateExportRequest } from '@/utils/auth'; +import { activateRateLimiter } from '@/utils/rate-limiter'; +import { Prisma } from '@openpanel/db'; +import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; + +const queryRouter: FastifyPluginCallback = async (fastify) => { + await activateRateLimiter({ + fastify, + max: 60, + timeWindow: '10 seconds', + }); + + fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { + try { + const client = await validateExportRequest(req.headers); + req.client = client; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Client ID seems to be malformed', + }); + } + if (e instanceof Error) { + return reply.status(401).send({ error: 'Unauthorized', message: e.message }); + } + return reply.status(401).send({ error: 'Unauthorized', message: 'Unexpected error' }); + } + }); + + // Projects + fastify.get('/projects', controller.listProjects); + + // Analytics + fastify.get('/:projectId/overview', controller.getOverview); + fastify.get('/:projectId/active-users', controller.getActiveUsers); + fastify.get('/:projectId/retention', controller.getRetentionSeries); + fastify.get('/:projectId/retention/cohort', controller.getRetentionCohort); + fastify.get('/:projectId/pages/top', controller.getTopPages); + fastify.get('/:projectId/pages/entry-exit', controller.getEntryExitPages); + fastify.get('/:projectId/pages/performance', controller.getPagePerformance); + fastify.get('/:projectId/funnel', controller.getFunnel); + fastify.get('/:projectId/traffic/referrers', controller.getTrafficReferrers); + fastify.get('/:projectId/traffic/geo', controller.getTrafficGeo); + fastify.get('/:projectId/traffic/devices', controller.getTrafficDevices); + fastify.get('/:projectId/user-flow', controller.getUserFlow); + fastify.get('/:projectId/engagement', controller.getEngagement); + + // Events + fastify.get('/:projectId/events', controller.queryEvents); + fastify.get('/:projectId/events/names', controller.listEventNames); + fastify.get('/:projectId/events/properties', controller.listEventProperties); + fastify.get('/:projectId/events/property-values', controller.getEventPropertyValues); + + // Profiles + fastify.get('/:projectId/profiles', controller.findProfiles); + fastify.get('/:projectId/profiles/:profileId', controller.getProfile); + fastify.get('/:projectId/profiles/:profileId/sessions', controller.getProfileSessions); + fastify.get('/:projectId/profiles/:profileId/metrics', controller.getProfileMetrics); + + // Sessions + fastify.get('/:projectId/sessions', controller.querySessions); + + // Groups + fastify.get('/:projectId/groups/types', controller.listGroupTypes); + fastify.get('/:projectId/groups', controller.findGroups); + fastify.get('/:projectId/groups/:groupId', controller.getGroup); + + // Dashboards & reports + fastify.get('/:projectId/dashboards', controller.listDashboards); + fastify.get('/:projectId/dashboards/:dashboardId/reports', controller.listReports); + fastify.get('/:projectId/reports/:reportId/data', controller.getReportData); + + // Google Search Console + fastify.get('/:projectId/gsc/overview', controller.gscOverview); + fastify.get('/:projectId/gsc/pages', controller.gscPages); + fastify.get('/:projectId/gsc/pages/details', controller.gscPageDetails); + fastify.get('/:projectId/gsc/queries', controller.gscQueries); + fastify.get('/:projectId/gsc/queries/details', controller.gscQueryDetails); + fastify.get('/:projectId/gsc/queries/opportunities', controller.gscQueryOpportunities); + fastify.get('/:projectId/gsc/cannibalization', controller.gscCannibalization); +}; + +export default queryRouter; diff --git a/packages/mcp/index.ts b/packages/mcp/index.ts index 5f1afe12b..1cb69ce3d 100644 --- a/packages/mcp/index.ts +++ b/packages/mcp/index.ts @@ -3,3 +3,72 @@ export { SessionManager } from './src/session-manager'; export { authenticateToken, McpAuthError } from './src/auth'; export { handleMcpGet, handleMcpPost } from './src/handler'; export type { McpAuthContext } from './src/auth'; + +// Core analytics functions — callable directly without MCP transport +export { resolveDateRange } from './src/tools/shared'; +export { listProjectsCore } from './src/tools/projects'; +export { + getAnalyticsOverviewCore, + type GetAnalyticsOverviewInput, +} from './src/tools/analytics/overview'; +export { + getFunnelCore, +} from './src/tools/analytics/funnel'; +export { + getTopPagesCore, + getEntryExitPagesCore, +} from './src/tools/analytics/pages'; +export { + getRollingActiveUsersCore, + getWeeklyRetentionSeriesCore, +} from './src/tools/analytics/active-users'; +export { getRetentionCohortCore } from './src/tools/analytics/retention'; +export { + getTrafficBreakdownCore, + type TrafficColumn, +} from './src/tools/analytics/traffic'; +export { getUserFlowCore } from './src/tools/analytics/user-flow'; +export { getEngagementCore } from './src/tools/analytics/engagement'; +export { getPagePerformanceCore } from './src/tools/analytics/page-performance'; +export { + queryEventsCore, + type QueryEventsInput, +} from './src/tools/analytics/events'; +export { + listEventNamesCore, +} from './src/tools/analytics/event-names'; +export { + listEventPropertiesCore, + getEventPropertyValuesCore, +} from './src/tools/analytics/property-values'; +export { + findProfilesCore, + getProfileWithEvents, + getProfileSessionsCore, + type FindProfilesInput, +} from './src/tools/analytics/profiles'; +export { + getProfileMetricsCore, +} from './src/tools/analytics/profile-metrics'; +export { + querySessionsCore, + type QuerySessionsInput, +} from './src/tools/analytics/sessions'; +export { + listGroupTypesCore, + findGroupsCore, + getGroupCore, +} from './src/tools/analytics/groups'; +export { + listDashboardsCore, + listReportsCore, + getReportDataCore, +} from './src/tools/analytics/reports'; +export { gscGetOverviewCore } from './src/tools/gsc/overview'; +export { gscGetTopPagesCore, gscGetPageDetailsCore } from './src/tools/gsc/pages'; +export { + gscGetTopQueriesCore, + gscGetQueryOpportunitiesCore, + gscGetQueryDetailsCore, +} from './src/tools/gsc/queries'; +export { gscGetCannibalizationCore } from './src/tools/gsc/cannibalization'; diff --git a/packages/mcp/src/tools/analytics/active-users.ts b/packages/mcp/src/tools/analytics/active-users.ts index 01523b4b5..83f3cda9e 100644 --- a/packages/mcp/src/tools/analytics/active-users.ts +++ b/packages/mcp/src/tools/analytics/active-users.ts @@ -11,6 +11,29 @@ import { withErrorHandling, } from '../shared'; +export async function getRollingActiveUsersCore(input: { + projectId: string; + days: number; +}) { + const data = await getRollingActiveUsers(input); + return { + window_days: input.days, + label: + input.days === 1 + ? 'DAU' + : input.days === 7 + ? 'WAU' + : input.days === 30 + ? 'MAU' + : `${input.days}d active`, + series: data, + }; +} + +export async function getWeeklyRetentionSeriesCore(projectId: string) { + return getRetentionSeries({ projectId }); +} + export function registerActiveUserTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/engagement.ts b/packages/mcp/src/tools/analytics/engagement.ts index b9b9b0822..dd91b0f18 100644 --- a/packages/mcp/src/tools/analytics/engagement.ts +++ b/packages/mcp/src/tools/analytics/engagement.ts @@ -3,6 +3,39 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; +export async function getEngagementCore(projectId: string) { + const raw = await getRetentionLastSeenSeries({ projectId }); + + let active_0_7 = 0; + let active_8_14 = 0; + let active_15_30 = 0; + let active_31_60 = 0; + let churned_60_plus = 0; + + for (const row of raw) { + if (row.days <= 7) active_0_7 += row.users; + else if (row.days <= 14) active_8_14 += row.users; + else if (row.days <= 30) active_15_30 += row.users; + else if (row.days <= 60) active_31_60 += row.users; + else churned_60_plus += row.users; + } + + const total = + active_0_7 + active_8_14 + active_15_30 + active_31_60 + churned_60_plus; + + return { + summary: { + total_identified_users: total, + active_last_7_days: active_0_7, + active_8_to_14_days: active_8_14, + active_15_to_30_days: active_15_30, + inactive_31_to_60_days: active_31_60, + churned_60_plus_days: churned_60_plus, + }, + distribution: raw, + }; +} + export function registerEngagementTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/event-names.ts b/packages/mcp/src/tools/analytics/event-names.ts index 08e39784c..d1813cea4 100644 --- a/packages/mcp/src/tools/analytics/event-names.ts +++ b/packages/mcp/src/tools/analytics/event-names.ts @@ -24,6 +24,9 @@ export async function getTopEventNames(projectId: string): Promise { }); } +export const listEventNamesCore = (projectId: string): Promise => + getTopEventNames(projectId); + export function registerEventNameTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/groups.ts b/packages/mcp/src/tools/analytics/groups.ts index fac855358..0c59caba4 100644 --- a/packages/mcp/src/tools/analytics/groups.ts +++ b/packages/mcp/src/tools/analytics/groups.ts @@ -9,6 +9,50 @@ import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; +export async function listGroupTypesCore(projectId: string) { + const types = await getGroupTypes(projectId); + return { types }; +} + +export async function findGroupsCore(input: { + projectId: string; + type?: string; + search?: string; + limit?: number; +}) { + return getGroupList({ + projectId: input.projectId, + type: input.type, + search: input.search, + take: input.limit ?? 20, + }); +} + +export async function getGroupCore(input: { + projectId: string; + groupId: string; + memberLimit?: number; +}) { + const [group, members] = await Promise.all([ + getGroupById(input.groupId, input.projectId), + getGroupMemberProfiles({ + projectId: input.projectId, + groupId: input.groupId, + take: input.memberLimit ?? 10, + }), + ]); + + if (!group) { + return { error: 'Group not found', groupId: input.groupId }; + } + + return { + group, + member_count: members.count, + members: members.data, + }; +} + export function registerGroupTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/page-performance.ts b/packages/mcp/src/tools/analytics/page-performance.ts index 86e4db5bd..a6a14a006 100644 --- a/packages/mcp/src/tools/analytics/page-performance.ts +++ b/packages/mcp/src/tools/analytics/page-performance.ts @@ -12,6 +12,48 @@ import { const pagesService = new PagesService(ch); +export async function getPagePerformanceCore(input: { + projectId: string; + startDate: string; + endDate: string; + search?: string; + sortBy?: 'sessions' | 'pageviews' | 'bounce_rate' | 'avg_duration'; + sortOrder?: 'asc' | 'desc'; + limit?: number; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + const pages = await pagesService.getTopPages({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + timezone, + search: input.search, + limit: 1000, + }); + + const col = input.sortBy ?? 'sessions'; + const dir = input.sortOrder === 'asc' ? 1 : -1; + const sorted = [...pages].sort( + (a, b) => dir * ((a[col] ?? 0) < (b[col] ?? 0) ? -1 : 1), + ); + const results = sorted.slice(0, input.limit ?? 50); + + const annotated = results.map((p) => ({ + ...p, + seo_signals: { + high_bounce: p.bounce_rate > 70, + low_engagement: p.avg_duration < 1, + good_landing_page: p.bounce_rate < 40 && p.avg_duration > 2, + }, + })); + + return { + total_pages: pages.length, + shown: annotated.length, + pages: annotated, + }; +} + export function registerPagePerformanceTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts index c768a5290..cae2a678c 100644 --- a/packages/mcp/src/tools/analytics/profile-metrics.ts +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -8,6 +8,32 @@ import { withErrorHandling, } from '../shared'; +export async function getProfileMetricsCore(input: { + projectId: string; + profileId: string; +}) { + const raw = await getProfileMetrics(input.profileId, input.projectId); + if (!raw) { + return { error: 'Profile not found or has no events', profileId: input.profileId }; + } + return { + profileId: input.profileId, + firstSeen: raw.firstSeen, + lastSeen: raw.lastSeen, + sessions: raw.sessions, + screenViews: raw.screenViews, + totalEvents: raw.totalEvents, + conversionEvents: raw.conversionEvents, + uniqueDaysActive: raw.uniqueDaysActive, + avgSessionDurationMin: raw.durationAvg, + p90SessionDurationMin: raw.durationP90, + avgEventsPerSession: raw.avgEventsPerSession, + avgTimeBetweenSessionsSec: raw.avgTimeBetweenSessions, + bounceRate: raw.bounceRate, + revenue: raw.revenue, + }; +} + export function registerProfileMetricTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/property-values.ts b/packages/mcp/src/tools/analytics/property-values.ts index 23b03b769..9d9a9e23a 100644 --- a/packages/mcp/src/tools/analytics/property-values.ts +++ b/packages/mcp/src/tools/analytics/property-values.ts @@ -8,6 +8,50 @@ import { withErrorHandling, } from '../shared'; +export async function listEventPropertiesCore(input: { + projectId: string; + eventName?: string; +}): Promise<{ properties: Array<{ property_key: string; event_name: string }> }> { + const builder = clix(ch) + .select<{ property_key: string; event_name: string }>([ + 'distinct property_key', + 'name as event_name', + ]) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', input.projectId) + .orderBy('property_key', 'ASC') + .limit(500); + + if (input.eventName) { + builder.where('name', '=', input.eventName); + } + + const rows = await builder.execute(); + return { properties: rows }; +} + +export async function getEventPropertyValuesCore(input: { + projectId: string; + eventName: string; + propertyKey: string; +}): Promise<{ event: string; property: string; values: string[] }> { + const rows = await clix(ch) + .select<{ value: string }>(['property_value as value']) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', input.projectId) + .where('name', '=', input.eventName) + .where('property_key', '=', input.propertyKey) + .orderBy('created_at', 'DESC') + .limit(200) + .execute(); + + return { + event: input.eventName, + property: input.propertyKey, + values: rows.map((r) => r.value), + }; +} + export function registerPropertyValueTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/reports.ts b/packages/mcp/src/tools/analytics/reports.ts index 4f85f4792..92b612afe 100644 --- a/packages/mcp/src/tools/analytics/reports.ts +++ b/packages/mcp/src/tools/analytics/reports.ts @@ -34,6 +34,101 @@ function dashboardUrl( return `${dashboardBaseUrl()}/${organizationId}/${projectId}/dashboards/${dashboardId}`; } +export async function listDashboardsCore(input: { + projectId: string; + organizationId: string; +}) { + const dashboards = await db.dashboard.findMany({ + where: { projectId: input.projectId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, projectId: true }, + }); + return dashboards.map((d) => ({ + ...d, + dashboard_url: dashboardUrl(input.organizationId, input.projectId, d.id), + })); +} + +export async function listReportsCore(input: { + projectId: string; + dashboardId: string; + organizationId: string; +}) { + const reports = await getReportsByDashboardId(input.dashboardId); + return reports.map((r) => ({ + id: r.id, + name: r.name, + chartType: r.chartType, + range: r.range, + interval: r.interval, + metric: r.metric, + series: r.series.map((s) => + s.type === 'formula' + ? { type: 'formula', id: s.id, formula: s.formula } + : { + type: 'event', + id: s.id, + name: s.name, + displayName: s.displayName, + segment: s.segment, + }, + ), + breakdowns: r.breakdowns, + dashboard_url: reportUrl(input.organizationId, input.projectId, r.id), + })); +} + +export async function getReportDataCore(input: { + projectId: string; + reportId: string; + organizationId: string; +}) { + const report = await getReportById(input.reportId); + + if (!report) { + return { error: 'Report not found', reportId: input.reportId }; + } + + if (report.projectId !== input.projectId) { + return { + error: 'Report does not belong to this project', + reportId: input.reportId, + }; + } + + const { timezone } = await getSettingsForProject(input.projectId); + const { startDate, endDate } = getChartStartEndDate(report, timezone); + const chartInput = { ...report, startDate, endDate, timezone }; + + const meta = { + id: report.id, + name: report.name, + chartType: report.chartType, + range: report.range, + interval: report.interval, + startDate, + endDate, + dashboard_url: reportUrl( + input.organizationId, + input.projectId, + input.reportId, + ), + }; + + if (report.chartType === 'funnel') { + const result = await funnelService.getFunnel(chartInput); + return { ...meta, data: result }; + } + + if (report.chartType === 'metric') { + const result = await AggregateChartEngine.execute(chartInput); + return { ...meta, data: result }; + } + + const result = await ChartEngine.execute(chartInput); + return { ...meta, data: result }; +} + export function registerReportTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/retention.ts b/packages/mcp/src/tools/analytics/retention.ts index 6e9b27543..2649097dd 100644 --- a/packages/mcp/src/tools/analytics/retention.ts +++ b/packages/mcp/src/tools/analytics/retention.ts @@ -7,6 +7,10 @@ import { withErrorHandling, } from '../shared'; +export async function getRetentionCohortCore(projectId: string) { + return getRetentionCohortTable({ projectId }); +} + export function registerRetentionTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/traffic.ts b/packages/mcp/src/tools/analytics/traffic.ts index 2009e79df..0763c5068 100644 --- a/packages/mcp/src/tools/analytics/traffic.ts +++ b/packages/mcp/src/tools/analytics/traffic.ts @@ -16,7 +16,7 @@ import { const overviewService = new OverviewService(ch); -type TrafficColumn = +export type TrafficColumn = | 'referrer' | 'referrer_name' | 'referrer_type' @@ -30,6 +30,15 @@ type TrafficColumn = | 'browser' | 'os'; +export async function getTrafficBreakdownCore(input: { + projectId: string; + startDate: string; + endDate: string; + column: TrafficColumn; +}) { + return getTopGeneric(input); +} + async function getTopGeneric(input: { projectId: string; startDate: string; diff --git a/packages/mcp/src/tools/analytics/user-flow.ts b/packages/mcp/src/tools/analytics/user-flow.ts index 34a58fea0..8b676ed32 100644 --- a/packages/mcp/src/tools/analytics/user-flow.ts +++ b/packages/mcp/src/tools/analytics/user-flow.ts @@ -23,6 +23,46 @@ function toChartEvent(name: string) { }; } +export async function getUserFlowCore(input: { + projectId: string; + startDate: string; + endDate: string; + startEvent: string; + endEvent?: string; + mode: 'after' | 'before' | 'between'; + steps?: number; + exclude?: string[]; + include?: string[]; +}) { + if (input.mode === 'between' && !input.endEvent) { + return { error: 'endEvent is required when mode is "between"' }; + } + + const { timezone } = await getSettingsForProject(input.projectId); + const result = await sankeyService.getSankey({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + steps: input.steps ?? 5, + mode: input.mode, + startEvent: toChartEvent(input.startEvent), + endEvent: input.endEvent ? toChartEvent(input.endEvent) : undefined, + exclude: input.exclude ?? [], + include: input.include, + timezone, + }); + + return { + mode: input.mode, + startEvent: input.startEvent, + endEvent: input.endEvent, + node_count: result.nodes.length, + link_count: result.links.length, + nodes: result.nodes, + links: result.links, + }; +} + export function registerUserFlowTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/gsc/cannibalization.ts b/packages/mcp/src/tools/gsc/cannibalization.ts index c945c8bd2..f0f5d3233 100644 --- a/packages/mcp/src/tools/gsc/cannibalization.ts +++ b/packages/mcp/src/tools/gsc/cannibalization.ts @@ -9,6 +9,18 @@ import { zDateRange, } from '../shared'; +export async function gscGetCannibalizationCore(input: { + projectId: string; + startDate: string; + endDate: string; +}) { + return getGscCannibalization( + input.projectId, + input.startDate, + input.endDate, + ); +} + export function registerGscCannibalizationTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/gsc/overview.ts b/packages/mcp/src/tools/gsc/overview.ts index 3dd2b04d5..e1f12caa6 100644 --- a/packages/mcp/src/tools/gsc/overview.ts +++ b/packages/mcp/src/tools/gsc/overview.ts @@ -10,6 +10,39 @@ import { zDateRange, } from '../shared'; +export async function gscGetOverviewCore(input: { + projectId: string; + startDate: string; + endDate: string; + interval?: 'day' | 'week' | 'month'; +}) { + const data = await getGscOverview( + input.projectId, + input.startDate, + input.endDate, + input.interval ?? 'day', + ); + return { + data, + summary: { + total_clicks: data.reduce((s, r) => s + r.clicks, 0), + total_impressions: data.reduce((s, r) => s + r.impressions, 0), + avg_ctr: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.ctr, 0) / data.length) * 10000, + ) / 100 + : 0, + avg_position: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.position, 0) / data.length) * 10, + ) / 10 + : 0, + }, + }; +} + export function registerGscOverviewTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/gsc/pages.ts b/packages/mcp/src/tools/gsc/pages.ts index d2f3124c2..cda2336d3 100644 --- a/packages/mcp/src/tools/gsc/pages.ts +++ b/packages/mcp/src/tools/gsc/pages.ts @@ -10,6 +10,34 @@ import { zDateRange, } from '../shared'; +export async function gscGetTopPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + return getGscPages( + input.projectId, + input.startDate, + input.endDate, + input.limit ?? 100, + ); +} + +export async function gscGetPageDetailsCore(input: { + projectId: string; + startDate: string; + endDate: string; + page: string; +}) { + return getGscPageDetails( + input.projectId, + input.page, + input.startDate, + input.endDate, + ); +} + export function registerGscPageTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/gsc/queries.ts b/packages/mcp/src/tools/gsc/queries.ts index a01acf49c..fd0aac077 100644 --- a/packages/mcp/src/tools/gsc/queries.ts +++ b/packages/mcp/src/tools/gsc/queries.ts @@ -88,6 +88,57 @@ function computeOpportunities( .slice(0, 50); } +export async function gscGetTopQueriesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + return getGscQueries( + input.projectId, + input.startDate, + input.endDate, + input.limit ?? 100, + ); +} + +export async function gscGetQueryOpportunitiesCore(input: { + projectId: string; + startDate: string; + endDate: string; + minImpressions?: number; +}) { + const queries = await getGscQueries( + input.projectId, + input.startDate, + input.endDate, + 5000, + ); + const filtered = queries.filter( + (q) => q.impressions >= (input.minImpressions ?? 50), + ); + const opportunities = computeOpportunities(filtered); + return { + opportunities, + total_analyzed: filtered.length, + min_impressions: input.minImpressions ?? 50, + }; +} + +export async function gscGetQueryDetailsCore(input: { + projectId: string; + startDate: string; + endDate: string; + query: string; +}) { + return getGscQueryDetails( + input.projectId, + input.query, + input.startDate, + input.endDate, + ); +} + export function registerGscQueryTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/projects.ts b/packages/mcp/src/tools/projects.ts index 0a3e4742a..b034180c8 100644 --- a/packages/mcp/src/tools/projects.ts +++ b/packages/mcp/src/tools/projects.ts @@ -3,6 +3,47 @@ import { db } from '@openpanel/db'; import type { McpAuthContext } from '../auth'; import { withErrorHandling } from './shared'; +export async function listProjectsCore(input: { + clientType: 'root' | 'read'; + organizationId: string; + projectId: string | null; +}) { + if (input.clientType === 'root') { + const projects = await db.project.findMany({ + where: { organizationId: input.organizationId }, + orderBy: { eventsCount: 'desc' }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }); + return { clientType: 'root', projects }; + } + + const project = input.projectId + ? await db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }) + : null; + + return { + clientType: 'read', + projects: project ? [project] : [], + }; +} + export function registerProjectTools( server: McpServer, context: McpAuthContext, From e4046dbbd44d226c5fec6a63f9da7adfe2f3133d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 11:52:03 +0200 Subject: [PATCH 05/18] e2e tests --- apps/api/package.json | 5 +- apps/api/src/app.ts | 234 +++++++++++ apps/api/src/controllers/query.controller.ts | 63 ++- apps/api/src/index.ts | 260 +----------- apps/api/src/integration/setup.ts | 9 + apps/api/src/routes/query.router.test.ts | 406 +++++++++++++++++++ apps/api/src/test-setup.ts | 6 + apps/api/src/utils/parse-zod-query-string.ts | 37 +- apps/api/src/utils/rate-limiter.ts | 3 +- apps/api/tsconfig.json | 2 +- apps/api/vitest.config.ts | 8 + biome.json | 3 +- packages/mcp/src/integration/setup.ts | 292 +------------ packages/mcp/src/integration/tools.test.ts | 26 +- packages/mcp/tsconfig.json | 2 +- test/clickhouse-fixtures.ts | 284 +++++++++++++ 16 files changed, 1049 insertions(+), 591 deletions(-) create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/integration/setup.ts create mode 100644 apps/api/src/routes/query.router.test.ts create mode 100644 apps/api/src/test-setup.ts create mode 100644 apps/api/vitest.config.ts create mode 100644 test/clickhouse-fixtures.ts diff --git a/apps/api/package.json b/apps/api/package.json index e00b1d85a..8dd5248bd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,8 @@ "build": "rm -rf dist && tsdown", "gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts", "test:manage": "jiti scripts/test-manage-api.ts", + "test": "vitest", + "test:run": "vitest run", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -64,6 +66,7 @@ "@types/ws": "^8.5.14", "js-yaml": "^4.1.0", "tsdown": "0.14.2", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "^1.0.0" } } \ No newline at end of file diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 000000000..b26d24bae --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,234 @@ +/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */ +import compress from '@fastify/compress'; +import cookie from '@fastify/cookie'; +import cors, { type FastifyCorsOptions } from '@fastify/cors'; +import { + EMPTY_SESSION, + type SessionValidationResult, + decodeSessionToken, + validateSessionToken, +} from '@openpanel/auth'; +import { generateId } from '@openpanel/common'; +import { type IServiceClientWithProject, runWithAlsSession } from '@openpanel/db'; +import type { AppRouter } from '@openpanel/trpc'; +import { appRouter, createContext } from '@openpanel/trpc'; +import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import type { FastifyBaseLogger, FastifyInstance, FastifyRequest } from 'fastify'; +import Fastify from 'fastify'; +import metricsPlugin from 'fastify-metrics'; +import { + healthcheck, + liveness, + readiness, +} from './controllers/healthcheck.controller'; +import { ipHook } from './hooks/ip.hook'; +import { requestIdHook } from './hooks/request-id.hook'; +import { requestLoggingHook } from './hooks/request-logging.hook'; +import { timestampHook } from './hooks/timestamp.hook'; +import aiRouter from './routes/ai.router'; +import eventRouter from './routes/event.router'; +import exportRouter from './routes/export.router'; +import gscCallbackRouter from './routes/gsc-callback.router'; +import importRouter from './routes/import.router'; +import insightsRouter from './routes/insights.router'; +import liveRouter from './routes/live.router'; +import manageRouter from './routes/manage.router'; +import mcpRouter from './routes/mcp.router'; +import miscRouter from './routes/misc.router'; +import oauthRouter from './routes/oauth-callback.router'; +import profileRouter from './routes/profile.router'; +import queryRouter from './routes/query.router'; +import trackRouter from './routes/track.router'; +import webhookRouter from './routes/webhook.router'; +import { HttpError } from './utils/errors'; +import { logger } from './utils/logger'; + +declare module 'fastify' { + interface FastifyRequest { + client: IServiceClientWithProject | null; + clientIp: string; + clientIpHeader: string; + timestamp?: number; + session: SessionValidationResult; + } +} + +export interface BuildAppOptions { + /** Set to true when running under Vitest — disables logging and Prometheus metrics */ + testing?: boolean; +} + +export async function buildApp( + options: BuildAppOptions = {}, +): Promise { + const { testing = false } = options; + + const fastify = Fastify({ + maxParamLength: 15_000, + bodyLimit: 1_048_576 * 500, + disableRequestLogging: true, + genReqId: (req) => + req.headers['request-id'] + ? String(req.headers['request-id']) + : generateId(), + ...(testing + ? { logger: false } + : { loggerInstance: logger as unknown as FastifyBaseLogger }), + }); + + fastify.register(cors, () => { + return ( + req: FastifyRequest, + callback: (error: Error | null, options: FastifyCorsOptions) => void, + ) => { + const corsPaths = ['/trpc', '/live', '/webhook', '/oauth', '/misc', '/ai', '/mcp']; + const isPrivatePath = corsPaths.some((p) => req.url.startsWith(p)); + + if (isPrivatePath) { + const allowedOrigins = [ + process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL, + ...(process.env.API_CORS_ORIGINS?.split(',') ?? []), + ].filter(Boolean); + const origin = req.headers.origin; + const isAllowed = origin && allowedOrigins.includes(origin); + return callback(null, { origin: isAllowed ? origin : false, credentials: true }); + } + + return callback(null, { origin: '*', maxAge: 86_400 * 7 }); + }; + }); + + await fastify.register(import('fastify-raw-body'), { global: false }); + + fastify.addHook('onRequest', requestIdHook); + fastify.addHook('onRequest', timestampHook); + fastify.addHook('onRequest', ipHook); + fastify.addHook('onResponse', requestLoggingHook); + + fastify.register(compress, { global: false, encodings: ['gzip', 'deflate'] }); + + // Dashboard API + fastify.register(async (instance) => { + instance.register(cookie, { + secret: process.env.COOKIE_SECRET ?? '', + hook: 'onRequest', + parseOptions: {}, + }); + + instance.addHook('onRequest', async (req) => { + if (req.cookies?.session) { + try { + const sessionId = decodeSessionToken(req.cookies?.session); + const session = await runWithAlsSession(sessionId, () => + validateSessionToken(req.cookies.session), + ); + req.session = session; + } catch { + req.session = EMPTY_SESSION; + } + } else if (process.env.DEMO_USER_ID) { + try { + const session = await runWithAlsSession('1', () => + validateSessionToken(null), + ); + req.session = session; + } catch { + req.session = EMPTY_SESSION; + } + } else { + req.session = EMPTY_SESSION; + } + }); + + instance.register(fastifyTRPCPlugin, { + prefix: '/trpc', + trpcOptions: { + router: appRouter, + createContext, + onError(ctx) { + if (ctx.error.code === 'UNAUTHORIZED' && ctx.path === 'organization.list') { + return; + } + ctx.req.log.error('trpc error', { + error: ctx.error, + path: ctx.path, + input: ctx.input, + type: ctx.type, + session: ctx.ctx?.session, + }); + }, + } satisfies FastifyTRPCPluginOptions['trpcOptions'], + }); + + instance.register(liveRouter, { prefix: '/live' }); + instance.register(webhookRouter, { prefix: '/webhook' }); + instance.register(oauthRouter, { prefix: '/oauth' }); + instance.register(gscCallbackRouter, { prefix: '/gsc' }); + instance.register(miscRouter, { prefix: '/misc' }); + instance.register(aiRouter, { prefix: '/ai' }); + instance.register(mcpRouter, { prefix: '/mcp' }); + }); + + // Public API + fastify.register(async (instance) => { + // Prometheus metrics: skip in tests (causes global state conflicts across test runs) + if (!testing) { + instance.register(metricsPlugin, { endpoint: '/metrics' }); + } + + instance.register(eventRouter, { prefix: '/event' }); + instance.register(profileRouter, { prefix: '/profile' }); + instance.register(exportRouter, { prefix: '/export' }); + instance.register(importRouter, { prefix: '/import' }); + instance.register(insightsRouter, { prefix: '/insights' }); + instance.register(trackRouter, { prefix: '/track' }); + instance.register(manageRouter, { prefix: '/manage' }); + instance.register(queryRouter, { prefix: '/query' }); + + instance.get('/healthcheck', healthcheck); + instance.get('/healthz/live', liveness); + instance.get('/healthz/ready', readiness); + instance.get('/', (_request, reply) => + reply.send({ status: 'ok', message: 'Successfully running OpenPanel.dev API' }), + ); + }); + + const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE']; + fastify.setErrorHandler((error, request, reply) => { + if (error.statusCode === 429) { + return reply.status(429).send({ + status: 429, + error: 'Too Many Requests', + message: 'You have exceeded the rate limit for this endpoint.', + }); + } + + if (error instanceof HttpError) { + if (!SKIP_LOG_ERRORS.includes(error.code)) { + request.log.error('internal server error', { error }); + } + if (process.env.NODE_ENV === 'production' && error.status === 500) { + return reply.status(500).send('Internal server error'); + } + return reply.status(error.status).send({ + status: error.status, + error: error.error, + message: error.message, + }); + } + + if (!SKIP_LOG_ERRORS.includes(error.code)) { + request.log.error('request error', { error }); + } + + const status = error?.statusCode ?? 500; + if (process.env.NODE_ENV === 'production' && status === 500) { + return reply.status(500).send('Internal server error'); + } + + return reply.status(status).send({ status, error, message: error.message }); + }); + + return fastify; +} diff --git a/apps/api/src/controllers/query.controller.ts b/apps/api/src/controllers/query.controller.ts index 3252ce8a0..9808743a3 100644 --- a/apps/api/src/controllers/query.controller.ts +++ b/apps/api/src/controllers/query.controller.ts @@ -37,8 +37,9 @@ import { resolveDateRange, type TrafficColumn, } from '@openpanel/mcp'; -import { ClientType } from '@openpanel/db'; +import { ClientType, getChartStartEndDate, getSettingsForProject } from '@openpanel/db'; import type { IServiceClientWithProject } from '@openpanel/db'; +import { zRange } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; @@ -65,8 +66,28 @@ function resolveQueryProjectId( const zDateRange = z.object({ startDate: z.string().optional(), endDate: z.string().optional(), + // Convenience shorthand matching the insights API (e.g. ?range=7d, ?range=30d). + // When provided without explicit startDate, it expands to a timezone-aware range. + // Explicit startDate/endDate always take precedence. + range: zRange.optional(), }); +type DateRangeInput = z.infer; + +async function resolveDates( + projectId: string, + data: DateRangeInput, +): Promise<{ startDate: string; endDate: string }> { + // Explicit dates always win — range is only a shorthand when no startDate is given + if (!data.range || data.startDate) { + return resolveDateRange(data.startDate, data.endDate); + } + const { timezone } = await getSettingsForProject(projectId); + // data.range is guaranteed non-nullish here (checked above); cast to satisfy + // getChartStartEndDate which expects a non-optional range with a default value. + return getChartStartEndDate({ startDate: data.startDate, endDate: data.endDate, range: data.range! }, timezone); +} + type RequestWithProjectParam = FastifyRequest<{ Params: { projectId?: string }; }>; @@ -126,7 +147,7 @@ export async function getOverview( const parsed = zOverviewQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await getAnalyticsOverviewCore({ projectId, startDate, endDate, interval: parsed.data.interval })); } @@ -183,7 +204,7 @@ export async function getTopPages( const parsed = zDateRange.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await getTopPagesCore({ projectId, startDate, endDate })); } @@ -202,7 +223,7 @@ export async function getEntryExitPages( const parsed = zEntryExitQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await getEntryExitPagesCore({ projectId, startDate, endDate, mode: parsed.data.mode })); } @@ -224,7 +245,7 @@ export async function getPagePerformance( const parsed = zPagePerfQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await getPagePerformanceCore({ projectId, startDate, endDate, ...parsed.data }), ); @@ -251,7 +272,7 @@ export async function getFunnel( const parsed = zFunnelQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await getFunnelCore({ projectId, @@ -291,7 +312,7 @@ export async function getTrafficReferrers( const parsed = zReferrerQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), ); @@ -304,7 +325,7 @@ export async function getTrafficGeo( const parsed = zGeoQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), ); @@ -317,7 +338,7 @@ export async function getTrafficDevices( const parsed = zDeviceQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), ); @@ -347,7 +368,7 @@ export async function getUserFlow( const parsed = zUserFlowQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await getUserFlowCore({ projectId, startDate, endDate, ...parsed.data }), ); @@ -404,8 +425,7 @@ export async function listEventNames( reply: FastifyReply, ) { const projectId = getProjectId(req as RequestWithProjectParam); - const names = await listEventNamesCore(projectId); - return reply.send({ event_names: names }); + return reply.send(await listEventNamesCore(projectId)); } const zEventPropertiesQuery = z.object({ @@ -482,7 +502,8 @@ export async function getProfile( if (!result.profile) { return reply.status(404).send({ error: 'Profile not found', profileId: req.params.profileId }); } - return reply.send(result); + // Transform snake_case MCP key to camelCase for REST consumers + return reply.send({ profile: result.profile, recentEvents: result.recent_events }); } const zProfileSessionsQuery = z.object({ @@ -497,7 +518,7 @@ export async function getProfileSessions( if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); const sessions = await getProfileSessionsCore(projectId, req.params.profileId, parsed.data.limit); - return reply.send({ profileId: req.params.profileId, session_count: sessions.length, sessions }); + return reply.send(sessions); } export async function getProfileMetrics( @@ -631,7 +652,7 @@ export async function gscOverview( const parsed = zGscOverviewQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: parsed.data.interval })); } @@ -646,7 +667,7 @@ export async function gscPages( const parsed = zGscLimitQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: parsed.data.limit })); } @@ -661,7 +682,7 @@ export async function gscPageDetails( const parsed = zGscPageDetailsQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: parsed.data.page })); } @@ -672,7 +693,7 @@ export async function gscQueries( const parsed = zGscLimitQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: parsed.data.limit })); } @@ -687,7 +708,7 @@ export async function gscQueryDetails( const parsed = zGscQueryDetailsQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: parsed.data.query }), ); @@ -704,7 +725,7 @@ export async function gscQueryOpportunities( const parsed = zGscOpportunitiesQuery.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send( await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: parsed.data.minImpressions }), ); @@ -717,6 +738,6 @@ export async function gscCannibalization( const parsed = zDateRange.safeParse(parseQueryString(req.query as Record)); if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = resolveDateRange(parsed.data.startDate, parsed.data.endDate); + const { startDate, endDate } = await resolveDates(projectId, parsed.data); return reply.send(await gscGetCannibalizationCore({ projectId, startDate, endDate })); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 57079dd66..dd956374b 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,69 +1,14 @@ /** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */ process.env.TZ = 'UTC'; -import compress from '@fastify/compress'; -import cookie from '@fastify/cookie'; -import cors, { type FastifyCorsOptions } from '@fastify/cors'; -import { - decodeSessionToken, - EMPTY_SESSION, - type SessionValidationResult, - validateSessionToken, -} from '@openpanel/auth'; -import { generateId } from '@openpanel/common'; -import { - type IServiceClientWithProject, - runWithAlsSession, -} from '@openpanel/db'; -import { getRedisPub } from '@openpanel/redis'; -import type { AppRouter } from '@openpanel/trpc'; -import { appRouter, createContext } from '@openpanel/trpc'; -import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify'; -import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; -import type { FastifyBaseLogger, FastifyRequest } from 'fastify'; -import Fastify from 'fastify'; -import metricsPlugin from 'fastify-metrics'; import sourceMapSupport from 'source-map-support'; -import { - healthcheck, - liveness, - readiness, -} from './controllers/healthcheck.controller'; -import { ipHook } from './hooks/ip.hook'; -import { requestIdHook } from './hooks/request-id.hook'; -import { requestLoggingHook } from './hooks/request-logging.hook'; -import { timestampHook } from './hooks/timestamp.hook'; -import aiRouter from './routes/ai.router'; -import eventRouter from './routes/event.router'; -import exportRouter from './routes/export.router'; -import gscCallbackRouter from './routes/gsc-callback.router'; -import importRouter from './routes/import.router'; -import insightsRouter from './routes/insights.router'; -import liveRouter from './routes/live.router'; -import manageRouter from './routes/manage.router'; -import mcpRouter from './routes/mcp.router'; -import queryRouter from './routes/query.router'; -import miscRouter from './routes/misc.router'; -import oauthRouter from './routes/oauth-callback.router'; -import profileRouter from './routes/profile.router'; -import trackRouter from './routes/track.router'; -import webhookRouter from './routes/webhook.router'; -import { HttpError } from './utils/errors'; +import { buildApp } from './app'; import { shutdown } from './utils/graceful-shutdown'; import { logger } from './utils/logger'; +import { getRedisPub } from '@openpanel/redis'; sourceMapSupport.install(); -declare module 'fastify' { - interface FastifyRequest { - client: IServiceClientWithProject | null; - clientIp: string; - clientIpHeader: string; - timestamp?: number; - session: SessionValidationResult; - } -} - const port = Number.parseInt(process.env.API_PORT || '3000', 10); const host = process.env.API_HOST || @@ -72,203 +17,7 @@ const host = const startServer = async () => { logger.info('Starting server'); try { - const fastify = Fastify({ - maxParamLength: 15_000, - bodyLimit: 1_048_576 * 500, // 500MB - loggerInstance: logger as unknown as FastifyBaseLogger, - disableRequestLogging: true, - genReqId: (req) => - req.headers['request-id'] - ? String(req.headers['request-id']) - : generateId(), - }); - - fastify.register(cors, () => { - return ( - req: FastifyRequest, - callback: (error: Error | null, options: FastifyCorsOptions) => void - ) => { - // TODO: set prefix on dashboard routes - const corsPaths = [ - '/trpc', - '/live', - '/webhook', - '/oauth', - '/misc', - '/ai', - '/mcp', - ]; - - const isPrivatePath = corsPaths.some((path) => - req.url.startsWith(path) - ); - - if (isPrivatePath) { - // Allow multiple dashboard domains - const allowedOrigins = [ - process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL, - ...(process.env.API_CORS_ORIGINS?.split(',') ?? []), - ].filter(Boolean); - - const origin = req.headers.origin; - const isAllowed = origin && allowedOrigins.includes(origin); - - return callback(null, { - origin: isAllowed ? origin : false, - credentials: true, - }); - } - - return callback(null, { - origin: '*', - maxAge: 86_400 * 7, // cache preflight for 7 days - }); - }; - }); - - await fastify.register(import('fastify-raw-body'), { - global: false, - }); - - fastify.addHook('onRequest', requestIdHook); - fastify.addHook('onRequest', timestampHook); - fastify.addHook('onRequest', ipHook); - fastify.addHook('onResponse', requestLoggingHook); - - fastify.register(compress, { - global: false, - encodings: ['gzip', 'deflate'], - }); - - // Dashboard API - fastify.register(async (instance) => { - instance.register(cookie, { - secret: process.env.COOKIE_SECRET ?? '', - hook: 'onRequest', - parseOptions: {}, - }); - - instance.addHook('onRequest', async (req) => { - if (req.cookies?.session) { - try { - const sessionId = decodeSessionToken(req.cookies?.session); - const session = await runWithAlsSession(sessionId, () => - validateSessionToken(req.cookies.session) - ); - req.session = session; - } catch { - req.session = EMPTY_SESSION; - } - } else if (process.env.DEMO_USER_ID) { - try { - const session = await runWithAlsSession('1', () => - validateSessionToken(null) - ); - req.session = session; - } catch { - req.session = EMPTY_SESSION; - } - } else { - req.session = EMPTY_SESSION; - } - }); - - instance.register(fastifyTRPCPlugin, { - prefix: '/trpc', - trpcOptions: { - router: appRouter, - createContext, - onError(ctx) { - if ( - ctx.error.code === 'UNAUTHORIZED' && - ctx.path === 'organization.list' - ) { - return; - } - ctx.req.log.error('trpc error', { - error: ctx.error, - path: ctx.path, - input: ctx.input, - type: ctx.type, - session: ctx.ctx?.session, - }); - }, - } satisfies FastifyTRPCPluginOptions['trpcOptions'], - }); - instance.register(liveRouter, { prefix: '/live' }); - instance.register(webhookRouter, { prefix: '/webhook' }); - instance.register(oauthRouter, { prefix: '/oauth' }); - instance.register(gscCallbackRouter, { prefix: '/gsc' }); - instance.register(miscRouter, { prefix: '/misc' }); - instance.register(aiRouter, { prefix: '/ai' }); - instance.register(mcpRouter, { prefix: '/mcp' }); - }); - - // Public API - fastify.register(async (instance) => { - instance.register(metricsPlugin, { endpoint: '/metrics' }); - instance.register(eventRouter, { prefix: '/event' }); - instance.register(profileRouter, { prefix: '/profile' }); - instance.register(exportRouter, { prefix: '/export' }); - instance.register(importRouter, { prefix: '/import' }); - instance.register(insightsRouter, { prefix: '/insights' }); - instance.register(trackRouter, { prefix: '/track' }); - instance.register(manageRouter, { prefix: '/manage' }); - instance.register(queryRouter, { prefix: '/query' }); - // Keep existing endpoints for backward compatibility - instance.get('/healthcheck', healthcheck); - // New Kubernetes-style health endpoints - instance.get('/healthz/live', liveness); - instance.get('/healthz/ready', readiness); - instance.get('/', (_request, reply) => - reply.send({ - status: 'ok', - message: 'Successfully running OpenPanel.dev API', - }) - ); - }); - - const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE']; - fastify.setErrorHandler((error, request, reply) => { - if (error.statusCode === 429) { - return reply.status(429).send({ - status: 429, - error: 'Too Many Requests', - message: 'You have exceeded the rate limit for this endpoint.', - }); - } - - if (error instanceof HttpError) { - if (!SKIP_LOG_ERRORS.includes(error.code)) { - request.log.error('internal server error', { error }); - } - - if (process.env.NODE_ENV === 'production' && error.status === 500) { - return reply.status(500).send('Internal server error'); - } - - return reply.status(error.status).send({ - status: error.status, - error: error.error, - message: error.message, - }); - } - - if (!SKIP_LOG_ERRORS.includes(error.code)) { - request.log.error('request error', { error }); - } - - const status = error?.statusCode ?? 500; - if (process.env.NODE_ENV === 'production' && status === 500) { - return reply.status(500).send('Internal server error'); - } - - return reply.status(status).send({ - status, - error, - message: error.message, - }); - }); + const fastify = await buildApp(); if (process.env.NODE_ENV === 'production') { logger.info('Registering graceful shutdown handlers'); @@ -287,12 +36,11 @@ const startServer = async () => { await fastify.listen({ host, port }); try { - // Notify when keys expires await getRedisPub().config('SET', 'notify-keyspace-events', 'Ex'); } catch (error) { logger.warn('Failed to set redis notify-keyspace-events', error); logger.warn( - 'If you use a managed Redis service, you may need to set this manually.' + 'If you use a managed Redis service, you may need to set this manually.', ); logger.warn('Otherwise some functions may not work as expected.'); } diff --git a/apps/api/src/integration/setup.ts b/apps/api/src/integration/setup.ts new file mode 100644 index 000000000..e10778782 --- /dev/null +++ b/apps/api/src/integration/setup.ts @@ -0,0 +1,9 @@ +import { setupFixtures, teardownFixtures } from '../../../../test/clickhouse-fixtures'; + +export { FIXTURE } from '../../../../test/clickhouse-fixtures'; + +export const TEST_PROJECT_ID = 'api-e2e-test'; +export const TEST_ORG_ID = 'api-e2e-org'; + +export const setup = () => setupFixtures(TEST_PROJECT_ID); +export const teardown = () => teardownFixtures(TEST_PROJECT_ID); diff --git a/apps/api/src/routes/query.router.test.ts b/apps/api/src/routes/query.router.test.ts new file mode 100644 index 000000000..026f3fa53 --- /dev/null +++ b/apps/api/src/routes/query.router.test.ts @@ -0,0 +1,406 @@ +/** + * Integration tests for the /query/* REST routes. + * + * Auth is mocked (getClientByIdCached, verifyPassword, getCache). + * ClickHouse is real — uses the local Docker instance (pnpm dock:up). + * + * Fixture data (see apps/api/src/tests/setup.ts): + * Alice — 3 events: session_start, page_view(/home), session_end — 2 days ago — Chrome / US + * Charlie — 5 events: session_start, screen_view, page_view(/shop), purchase, session_end — 5 days ago — Firefox + * 2 sessions (sess-charlie-1 5d ago, sess-charlie-2 10d ago) + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +// ─── Module mocks (hoisted before imports) ──────────────────────────────────── + +vi.mock('@openpanel/db', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Auth: getClientByIdCached is controlled per-test via mockResolvedValue + getClientByIdCached: vi.fn(), + // Settings: always return UTC so overview / retention tests are stable + getSettingsForProject: vi.fn().mockResolvedValue({ timezone: 'UTC' }), + // Prisma client used by listProjectsCore — return a minimal project stub + db: { + ...actual.db, + project: { + findMany: vi.fn().mockResolvedValue([ + { + id: 'api-e2e-test', + name: 'E2E Test Project', + organizationId: 'api-e2e-org', + eventsCount: 8, + domain: null, + types: [], + }, + ]), + findUnique: vi.fn().mockResolvedValue({ + id: 'api-e2e-test', + name: 'E2E Test Project', + organizationId: 'api-e2e-org', + eventsCount: 8, + domain: null, + types: [], + }), + }, + }, + }; +}); + +// Password verification is always truthy in tests +vi.mock('@openpanel/common/server', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, verifyPassword: vi.fn().mockResolvedValue(true) }; +}); + +// Bypass Redis caching — no real ioredis connections in tests. +// getRedisCache must return a truthy object so that @trpc-limiter/redis's +// RateLimiterRedis constructor doesn't throw "storeClient is not set". +// The fake client methods are never called in our tests (we only test /query/*). +vi.mock('@openpanel/redis', async (importOriginal) => { + const actual = await importOriginal(); + // Minimal fake that satisfies RateLimiterRedis's truthy-client check + const fakeRedisClient = new Proxy( + {}, + { get: (_t, p) => (p === 'status' ? 'ready' : vi.fn().mockResolvedValue(null)) }, + ); + return { + ...actual, + getCache: async (_key: string, _ttl: number, fn: () => Promise) => fn(), + getRedisCache: vi.fn().mockReturnValue(fakeRedisClient), + }; +}); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── + +import { ClientType, getClientByIdCached } from '@openpanel/db'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../app'; +import { FIXTURE, TEST_ORG_ID, TEST_PROJECT_ID, setup, teardown } from '../integration/setup'; + +// ─── Test client constants ──────────────────────────────────────────────────── + +const CLIENT_ID = '00000000-0000-0000-0000-000000000099'; +const CLIENT_SECRET = 'test-secret'; + +const AUTH = { + 'openpanel-client-id': CLIENT_ID, + 'openpanel-client-secret': CLIENT_SECRET, +}; + +/** Minimal shape that satisfies validateExportRequest */ +const READ_CLIENT = { + id: CLIENT_ID, + type: ClientType.read, + projectId: TEST_PROJECT_ID, + organizationId: TEST_ORG_ID, + secret: 'hashed-secret', + name: 'Test Client', + cors: null, + description: '', + ignoreCorsAndSecret: false, + createdAt: new Date(), + updatedAt: new Date(), + project: null, +}; + +// ─── Lifecycle ──────────────────────────────────────────────────────────────── + +let app: FastifyInstance; + +beforeAll(async () => { + vi.mocked(getClientByIdCached).mockResolvedValue(READ_CLIENT as any); + + app = await buildApp({ testing: true }); + await app.ready(); + + // Insert ClickHouse fixture data + await setup(); +}, 30_000); + +afterAll(async () => { + await teardown(); + await app.close(); +}, 10_000); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function get(path: string, headers: Record = AUTH) { + return app.inject({ method: 'GET', url: path, headers }); +} + +// ─── Auth ───────────────────────────────────────────────────────────────────── + +describe('auth', () => { + it('returns 401 when no client-id header is present', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events/names`, {}); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 when client-id is not a valid UUID', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events/names`, { + 'openpanel-client-id': 'not-a-uuid', + 'openpanel-client-secret': CLIENT_SECRET, + }); + expect(res.statusCode).toBe(401); + }); + + it('returns 200 with valid credentials', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events/names`); + expect(res.statusCode).toBe(200); + }); +}); + +// ─── Projects ───────────────────────────────────────────────────────────────── + +describe('GET /query/projects', () => { + it('returns project list for read client', async () => { + const res = await get('/query/projects'); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveProperty('projects'); + expect(Array.isArray(body.projects)).toBe(true); + }); +}); + +// ─── Events ─────────────────────────────────────────────────────────────────── + +describe('GET /query/:projectId/events/names', () => { + it('returns event_names array', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events/names`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('includes events from fixture data', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events/names`); + const body = res.json(); + expect(body).toContain('session_start'); + expect(body).toContain('page_view'); + expect(body).toContain('session_end'); + }); +}); + +describe('GET /query/:projectId/events', () => { + it('returns events array', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('respects limit parameter', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events?limit=2`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.length).toBeLessThanOrEqual(2); + }); + + it('filters by eventNames', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events?eventNames=purchase`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.every((e: any) => e.name === 'purchase')).toBe(true); + }); + + it('returns 400 when limit is out of range', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events?limit=9999`); + expect(res.statusCode).toBe(400); + }); +}); + +describe('GET /query/:projectId/events/properties', () => { + it('returns properties array', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events/properties`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body.properties)).toBe(true); + }); +}); + +describe('GET /query/:projectId/events/property-values', () => { + it('returns values for a known property', async () => { + const res = await get( + `/query/${TEST_PROJECT_ID}/events/property-values?eventName=page_view&propertyKey=path`, + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body.values)).toBe(true); + }); + + it('returns 400 when required params are missing', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/events/property-values?eventName=page_view`); + expect(res.statusCode).toBe(400); + }); +}); + +// ─── Profiles ───────────────────────────────────────────────────────────────── + +describe('GET /query/:projectId/profiles', () => { + it('returns profiles array', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/profiles`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('includes fixture profiles', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/profiles`); + const body = res.json(); + const emails = body.map((p: any) => p.email); + expect(emails).toContain('alice@example.com'); + expect(emails).toContain('charlie@example.com'); + }); + + it('filters by browser via query params', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/profiles?browser=Firefox`); + expect(res.statusCode).toBe(200); + const body = res.json(); + // Charlie uses Firefox; Alice uses Chrome — only Charlie should appear + const emails = body.map((p: any) => p.email); + expect(emails).toContain('charlie@example.com'); + expect(emails).not.toContain('alice@example.com'); + }); +}); + +describe('GET /query/:projectId/profiles/:profileId', () => { + it('returns 404 for unknown profile', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/profiles/does-not-exist`); + expect(res.statusCode).toBe(404); + }); + + it('returns profile data for known profile', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.alice}`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body).toHaveProperty('profile'); + expect(body.profile.email).toBe('alice@example.com'); + }); +}); + +describe('GET /query/:projectId/profiles/:profileId/sessions', () => { + it('returns sessions for charlie', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.charlie}/sessions`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ─── Sessions ───────────────────────────────────────────────────────────────── + +describe('GET /query/:projectId/sessions', () => { + it('returns sessions array', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/sessions`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it('fixture has at least 3 sessions (alice-1, charlie-1, charlie-2)', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/sessions?limit=100`); + const body = res.json(); + expect(body.length).toBeGreaterThanOrEqual(3); + }); +}); + +// ─── Analytics overview ─────────────────────────────────────────────────────── + +describe('GET /query/:projectId/overview', () => { + it('returns analytics overview', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/overview`); + expect(res.statusCode).toBe(200); + const body = res.json(); + // Overview returns an object with at least some metrics + expect(typeof body).toBe('object'); + }); + + it('accepts interval param', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/overview?interval=day`); + expect(res.statusCode).toBe(200); + }); + + it('returns 400 for invalid interval', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/overview?interval=invalid`); + expect(res.statusCode).toBe(400); + }); +}); + +// ─── Funnel ─────────────────────────────────────────────────────────────────── + +describe('GET /query/:projectId/funnel', () => { + it('returns funnel data for valid steps', async () => { + const res = await get( + `/query/${TEST_PROJECT_ID}/funnel?steps=session_start&steps=session_end`, + ); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(typeof body).toBe('object'); + }); + + it('returns 400 when fewer than 2 steps are provided', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/funnel?steps[]=session_start`); + expect(res.statusCode).toBe(400); + }); + + it('returns 400 when steps param is missing entirely', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/funnel`); + expect(res.statusCode).toBe(400); + }); +}); + +// ─── Pages ──────────────────────────────────────────────────────────────────── + +describe('GET /query/:projectId/pages/top', () => { + it('returns top pages', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/pages/top`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); + +describe('GET /query/:projectId/pages/entry-exit', () => { + it('defaults to entry mode', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/pages/entry-exit`); + expect(res.statusCode).toBe(200); + }); + + it('accepts mode=exit', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/pages/entry-exit?mode=exit`); + expect(res.statusCode).toBe(200); + }); +}); + +// ─── Traffic ────────────────────────────────────────────────────────────────── + +describe('GET /query/:projectId/traffic/referrers', () => { + it('returns referrer breakdown', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/traffic/referrers`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); + +describe('GET /query/:projectId/traffic/geo', () => { + it('returns geo breakdown', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/traffic/geo`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); + +describe('GET /query/:projectId/traffic/devices', () => { + it('returns device breakdown', async () => { + const res = await get(`/query/${TEST_PROJECT_ID}/traffic/devices`); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + }); +}); diff --git a/apps/api/src/test-setup.ts b/apps/api/src/test-setup.ts new file mode 100644 index 000000000..a4327de6a --- /dev/null +++ b/apps/api/src/test-setup.ts @@ -0,0 +1,6 @@ +import { afterAll } from 'vitest'; +import { originalCh } from '@openpanel/db'; + +// Close the ClickHouse connection pool after all tests finish to allow the +// Vitest process to exit cleanly. +afterAll(() => originalCh.close()); diff --git a/apps/api/src/utils/parse-zod-query-string.ts b/apps/api/src/utils/parse-zod-query-string.ts index b7e976858..fd100afd3 100644 --- a/apps/api/src/utils/parse-zod-query-string.ts +++ b/apps/api/src/utils/parse-zod-query-string.ts @@ -1,23 +1,26 @@ import { getSafeJson } from '@openpanel/json'; +const parseScalar = (v: any): any => { + if (Array.isArray(v)) return v.map(parseScalar); + if (typeof v === 'object' && v !== null) return parseQueryString(v); + if ( + typeof v === 'string' && + /^-?[0-9]+(\.[0-9]+)?$/i.test(v) && + !Number.isNaN(Number.parseFloat(v)) + ) + return Number.parseFloat(v); + if (v === 'true') return true; + if (v === 'false') return false; + if (typeof v === 'string') { + const json = getSafeJson(v); + if (json !== null) return json; + return v; + } + return null; +}; + export const parseQueryString = (obj: Record): any => { return Object.fromEntries( - Object.entries(obj).map(([k, v]) => { - if (typeof v === 'object') return [k, parseQueryString(v)]; - if ( - /^-?[0-9]+(\.[0-9]+)?$/i.test(v) && - !Number.isNaN(Number.parseFloat(v)) - ) - return [k, Number.parseFloat(v)]; - if (v === 'true') return [k, true]; - if (v === 'false') return [k, false]; - if (typeof v === 'string') { - if (getSafeJson(v) !== null) { - return [k, getSafeJson(v)]; - } - return [k, v]; - } - return [k, null]; - }), + Object.entries(obj).map(([k, v]) => [k, parseScalar(v)]), ); }; diff --git a/apps/api/src/utils/rate-limiter.ts b/apps/api/src/utils/rate-limiter.ts index f0cff50bf..491a5fd62 100644 --- a/apps/api/src/utils/rate-limiter.ts +++ b/apps/api/src/utils/rate-limiter.ts @@ -22,7 +22,8 @@ export async function activateRateLimiter({ message: 'You have exceeded the rate limit for this endpoint.', }; }, - redis: getRedisCache(), + // In test mode use in-memory storage so tests don't need a running Redis + redis: process.env.NODE_ENV !== 'test' ? getRedisCache() : undefined, keyGenerator(req) { if (keyGenerator) { const key = keyGenerator(req as T); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index aaab48cc2..7c29b89c2 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -8,6 +8,6 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", "strictNullChecks": true }, - "include": ["."], + "include": [".", "../../test"], "exclude": ["node_modules", "dist"] } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 000000000..e4ff7745d --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,8 @@ +import { mergeConfig } from 'vitest/config'; +import { getSharedVitestConfig } from '../../vitest.shared'; + +export default mergeConfig(getSharedVitestConfig({ __dirname }), { + test: { + setupFiles: ['./src/test-setup.ts'], + }, +}); diff --git a/biome.json b/biome.json index ce71e9e01..d1a47dec9 100644 --- a/biome.json +++ b/biome.json @@ -60,7 +60,8 @@ }, "correctness": { "useExhaustiveDependencies": "off", - "noUnreachable": "off" + "noUnreachable": "off", + "noGlobalDirnameFilename": "off" }, "performance": { "noDelete": "off", diff --git a/packages/mcp/src/integration/setup.ts b/packages/mcp/src/integration/setup.ts index 3ebacc3a9..adf43d115 100644 --- a/packages/mcp/src/integration/setup.ts +++ b/packages/mcp/src/integration/setup.ts @@ -2,21 +2,19 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createClient } from '@openpanel/db'; +import { setupFixtures, teardownFixtures } from '../../../../test/clickhouse-fixtures'; + +export { FIXTURE } from '../../../../test/clickhouse-fixtures'; const __dirname = dirname(fileURLToPath(import.meta.url)); export const TEST_PROJECT_ID = 'mcp-integration-test'; -function getClient() { - const url = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; - return createClient({ url }); -} - -export async function setup() { - const client = await getClient(); +async function ensureSchema() { + const client = createClient({ + url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123', + }); - // Create tables — strip comment lines first so semicolons inside comments - // don't produce spurious empty statements when splitting. const sql = readFileSync(join(__dirname, 'clickhouse-schema.sql'), 'utf8'); const statements = sql .split('\n') @@ -25,280 +23,16 @@ export async function setup() { .split(';') .map((s) => s.trim()) .filter((s) => s.length > 0); - // Run all CREATE TABLE / CREATE DATABASE statements in parallel — they are - // independent so there's no ordering requirement. - await Promise.all(statements.map((statement) => client.command({ query: statement }))); - - // Clean up any leftover data from a previous run - await cleanTestData(client); - - const now = new Date(); - // minutesOffset shifts the time within the day so events get distinct timestamps - // (required for ClickHouse windowFunnel strict_increase mode) - const timeAgo = (days: number, minutesOffset = 0) => - new Date(now.getTime() - days * 86_400_000 - minutesOffset * 60_000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ''); - const daysAgo = (n: number) => timeAgo(n); - - // Profiles - await client.insert({ - table: 'openpanel.profiles', - values: [ - // Alice — active recently (event 2 days ago) - { - id: 'profile-alice', - project_id: TEST_PROJECT_ID, - first_name: 'Alice', - last_name: 'Smith', - email: 'alice@example.com', - avatar: '', - is_external: false, - properties: {}, - groups: [], - created_at: daysAgo(60), - }, - // Bob — inactive (no events in last 30 days) - { - id: 'profile-bob', - project_id: TEST_PROJECT_ID, - first_name: 'Bob', - last_name: "O'Brien", - email: 'bob@example.com', - avatar: '', - is_external: false, - properties: { country: 'SE' }, - groups: [], - created_at: daysAgo(90), - }, - // Charlie — performed 'purchase' and has many sessions - { - id: 'profile-charlie', - project_id: TEST_PROJECT_ID, - first_name: 'Charlie', - last_name: 'Brown', - email: 'charlie@example.com', - avatar: '', - is_external: false, - properties: {}, - groups: [], - created_at: daysAgo(30), - }, - ], - format: 'JSONEachRow', - }); - - // Helper to build a minimal event row - function event( - id: string, - name: string, - profileId: string, - sessionId: string, - deviceId: string, - daysBack: number, - overrides: Record = {}, - minutesOffset = 0, - ) { - return { - id, - project_id: TEST_PROJECT_ID, - profile_id: profileId, - name, - session_id: sessionId, - device_id: deviceId, - created_at: timeAgo(daysBack, minutesOffset), - path: '/', - origin: 'https://example.com', - referrer: '', - referrer_name: '', - referrer_type: '', - revenue: 0, - duration: 0, - properties: {}, - groups: [], - country: 'US', - city: '', - region: '', - sdk_name: 'web', - sdk_version: '1.0.0', - os: '', - os_version: '', - browser: 'Chrome', - browser_version: '', - device: 'desktop', - brand: '', - model: '', - ...overrides, - }; - } - - // Events - // Alice (2 days ago): session_start → page_view → session_end - // Charlie (5 days ago): session_start → screen_view → page_view → purchase → session_end - // Bob: no events (inactive) - // - // Charlie's expected metrics: - // sessions=1, screenViews=1, totalEvents=5 - // conversionEvents=2 (page_view + purchase; excludes session_start/screen_view/session_end) - await client.insert({ - table: 'openpanel.events', - values: [ - // Alice — events spaced 2 minutes apart so timestamps are strictly increasing - event('00000000-0000-0000-0000-000000000001', 'session_start', 'profile-alice', 'sess-alice-1', 'dev-alice', 2, {}, 4), - event('00000000-0000-0000-0000-000000000002', 'page_view', 'profile-alice', 'sess-alice-1', 'dev-alice', 2, { path: '/home', browser: 'Chrome' }, 2), - event('00000000-0000-0000-0000-000000000003', 'session_end', 'profile-alice', 'sess-alice-1', 'dev-alice', 2, { duration: 120000 }, 0), - // Charlie — events spaced 5 minutes apart so windowFunnel strict_increase works - event('00000000-0000-0000-0000-000000000004', 'session_start', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { browser: 'Firefox' }, 20), - event('00000000-0000-0000-0000-000000000005', 'screen_view', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { path: '/shop', browser: 'Firefox' }, 15), - event('00000000-0000-0000-0000-000000000006', 'page_view', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { path: '/shop', browser: 'Firefox' }, 10), - event('00000000-0000-0000-0000-000000000007', 'purchase', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { path: '/checkout', revenue: 9900, browser: 'Firefox' }, 5), - event('00000000-0000-0000-0000-000000000008', 'session_end', 'profile-charlie', 'sess-charlie-1', 'dev-charlie', 5, { duration: 300000, browser: 'Firefox' }, 0), - ], - format: 'JSONEachRow', - }); - - // Sessions (sign=1 = active row, sign=-1 = collapsed) - await client.insert({ - table: 'openpanel.sessions', - values: [ - { - id: 'sess-alice-1', - project_id: TEST_PROJECT_ID, - profile_id: 'profile-alice', - device_id: 'dev-alice', - created_at: daysAgo(2), - ended_at: daysAgo(2), - is_bounce: false, - entry_origin: 'https://example.com', - entry_path: '/home', - exit_origin: 'https://example.com', - exit_path: '/home', - screen_view_count: 1, - revenue: 0, - event_count: 1, - duration: 120, - country: 'US', - region: '', - city: '', - device: 'desktop', - brand: '', - model: '', - browser: 'Chrome', - browser_version: '', - os: '', - os_version: '', - utm_medium: '', - utm_source: '', - utm_campaign: '', - utm_content: '', - utm_term: '', - referrer: '', - referrer_name: '', - referrer_type: '', - sign: 1, - version: 1, - properties: {}, - }, - { - id: 'sess-charlie-1', - project_id: TEST_PROJECT_ID, - profile_id: 'profile-charlie', - device_id: 'dev-charlie', - created_at: daysAgo(5), - ended_at: daysAgo(5), - is_bounce: false, - entry_origin: 'https://example.com', - entry_path: '/shop', - exit_origin: 'https://example.com', - exit_path: '/checkout', - screen_view_count: 2, - revenue: 9900, - event_count: 2, - duration: 300, - country: 'US', - region: '', - city: '', - device: 'desktop', - brand: '', - model: '', - browser: 'Firefox', - browser_version: '', - os: '', - os_version: '', - utm_medium: '', - utm_source: '', - utm_campaign: '', - utm_content: '', - utm_term: '', - referrer: '', - referrer_name: '', - referrer_type: '', - sign: 1, - version: 1, - properties: {}, - }, - { - id: 'sess-charlie-2', - project_id: TEST_PROJECT_ID, - profile_id: 'profile-charlie', - device_id: 'dev-charlie', - created_at: daysAgo(10), - ended_at: daysAgo(10), - is_bounce: true, - entry_origin: 'https://example.com', - entry_path: '/shop', - exit_origin: 'https://example.com', - exit_path: '/shop', - screen_view_count: 1, - revenue: 0, - event_count: 1, - duration: 15, - country: 'US', - region: '', - city: '', - device: 'desktop', - brand: '', - model: '', - browser: 'Firefox', - browser_version: '', - os: '', - os_version: '', - utm_medium: '', - utm_source: '', - utm_campaign: '', - utm_content: '', - utm_term: '', - referrer: '', - referrer_name: '', - referrer_type: '', - sign: 1, - version: 1, - properties: {}, - }, - ], - format: 'JSONEachRow', - }); + await Promise.all(statements.map((statement) => client.command({ query: statement }))); await client.close(); } -export async function teardown() { - const client = await getClient(); - await cleanTestData(client); - await client.close(); +export async function setup() { + await ensureSchema(); + await setupFixtures(TEST_PROJECT_ID); } -async function cleanTestData(client: Awaited>) { - await Promise.all([ - client.command({ - query: `DELETE FROM openpanel.profiles WHERE project_id = '${TEST_PROJECT_ID}'`, - }), - client.command({ - query: `DELETE FROM openpanel.events WHERE project_id = '${TEST_PROJECT_ID}'`, - }), - client.command({ - query: `DELETE FROM openpanel.sessions WHERE project_id = '${TEST_PROJECT_ID}'`, - }), - ]); +export async function teardown() { + await teardownFixtures(TEST_PROJECT_ID); } diff --git a/packages/mcp/src/integration/tools.test.ts b/packages/mcp/src/integration/tools.test.ts index 3448557be..69432b74f 100644 --- a/packages/mcp/src/integration/tools.test.ts +++ b/packages/mcp/src/integration/tools.test.ts @@ -33,7 +33,7 @@ vi.mock('@openpanel/redis', async (importOriginal) => { }; }); -import { setup, teardown } from './setup'; +import { FIXTURE, setup, teardown } from './setup'; import { registerActiveUserTools } from '../tools/analytics/active-users'; import { registerEngagementTools } from '../tools/analytics/engagement'; import { registerEventNameTools } from '../tools/analytics/event-names'; @@ -137,7 +137,7 @@ describe('query_events', () => { }); expect(res.length).toBe(1); expect(res[0].name).toBe('purchase'); - expect(res[0].profile_id).toBe('profile-charlie'); + expect(res[0].profile_id).toBe(FIXTURE.profiles.charlie); expect(res[0].revenue).toBe(9900); }); @@ -148,10 +148,10 @@ describe('query_events', () => { projectId: TEST_PROJECT_ID, startDate: '2000-01-01', endDate: '2099-01-01', - profileId: 'profile-alice', + profileId: FIXTURE.profiles.alice, }); expect(res.length).toBe(3); - expect(res.every((e: any) => e.profile_id === 'profile-alice')).toBe(true); + expect(res.every((e: any) => e.profile_id === FIXTURE.profiles.alice)).toBe(true); }); it('filters by browser', async () => { @@ -191,10 +191,10 @@ describe('query_sessions', () => { projectId: TEST_PROJECT_ID, startDate: '2000-01-01', endDate: '2099-01-01', - profileId: 'profile-charlie', + profileId: FIXTURE.profiles.charlie, }); expect(res.length).toBe(2); - expect(res.every((s: any) => s.profile_id === 'profile-charlie')).toBe(true); + expect(res.every((s: any) => s.profile_id === FIXTURE.profiles.charlie)).toBe(true); }); it('filters by browser', async () => { @@ -207,7 +207,7 @@ describe('query_sessions', () => { browser: 'Chrome', }); expect(res.length).toBe(1); - expect(res[0].profile_id).toBe('profile-alice'); + expect(res[0].profile_id).toBe(FIXTURE.profiles.alice); }); }); @@ -286,7 +286,7 @@ describe('get_profile', () => { registerProfileTools(server as any, CTX); const res = await server.invoke('get_profile', { projectId: TEST_PROJECT_ID, - profileId: 'profile-charlie', + profileId: FIXTURE.profiles.charlie, }); expect(res.profile.first_name).toBe('Charlie'); expect(res.profile.email).toBe('charlie@example.com'); @@ -301,10 +301,10 @@ describe('get_profile_sessions', () => { registerProfileTools(server as any, CTX); const res = await server.invoke('get_profile_sessions', { projectId: TEST_PROJECT_ID, - profileId: 'profile-charlie', + profileId: FIXTURE.profiles.charlie, }); expect(res.sessions.length).toBe(2); - expect(res.sessions.every((s: any) => s.profile_id === 'profile-charlie')).toBe(true); + expect(res.sessions.every((s: any) => s.profile_id === FIXTURE.profiles.charlie)).toBe(true); }); }); @@ -314,11 +314,11 @@ describe('get_profile_metrics', () => { registerProfileMetricTools(server as any, CTX); const res = await server.invoke('get_profile_metrics', { projectId: TEST_PROJECT_ID, - profileId: 'profile-charlie', + profileId: FIXTURE.profiles.charlie, }); // No error — bug was getProfileMetrics returns single object, not array expect(res.error).toBeUndefined(); - expect(res.profileId).toBe('profile-charlie'); + expect(res.profileId).toBe(FIXTURE.profiles.charlie); expect(res.sessions).toBe(1); // 1 session_start event expect(res.screenViews).toBe(1); // 1 screen_view event expect(res.totalEvents).toBe(5); // session_start + screen_view + page_view + purchase + session_end @@ -333,7 +333,7 @@ describe('get_profile_metrics', () => { registerProfileMetricTools(server as any, CTX); const res = await server.invoke('get_profile_metrics', { projectId: TEST_PROJECT_ID, - profileId: 'profile-alice', + profileId: FIXTURE.profiles.alice, }); expect(res.error).toBeUndefined(); expect(res.sessions).toBe(1); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index 6b21fa2a3..2148d7758 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -5,6 +5,6 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", "strictNullChecks": true }, - "include": ["."], + "include": [".", "../../test"], "exclude": ["node_modules"] } diff --git a/test/clickhouse-fixtures.ts b/test/clickhouse-fixtures.ts new file mode 100644 index 000000000..d4985904a --- /dev/null +++ b/test/clickhouse-fixtures.ts @@ -0,0 +1,284 @@ +/** + * Shared ClickHouse fixture builder for integration tests. + * + * Call setupFixtures(projectId) / teardownFixtures(projectId) from any test + * suite. Each suite uses its own project ID so suites can run concurrently + * without stomping on each other's data. + * + * Fixture dataset (3 users, 8 events, 3 sessions): + * + * Alice — created 60 days ago, browser: Chrome, country: US + * 3 events 2 days ago: session_start → page_view(/home) → session_end + * 1 session (sess-alice-1, 2d ago, Chrome) + * + * Bob — created 90 days ago, browser: Chrome, country: SE — NO events (inactive) + * + * Charlie — created 30 days ago, browser: Firefox, country: US + * 5 events 5 days ago: session_start → screen_view → page_view(/shop) → purchase → session_end + * 2 sessions (sess-charlie-1 5d ago Firefox, sess-charlie-2 10d ago Firefox bounce) + * + * Event UUIDs live in the 00000000-0000-0000-0000-xxxxxxxxxxxx namespace. + * Because events are scoped by project_id, the same UUIDs are safe across + * different project IDs (ClickHouse's MergeTree ordering includes project_id). + */ + +import { createClient } from '../packages/db/src/clickhouse/client'; + +// --------------------------------------------------------------------------- +// Well-known fixture IDs — import these in tests instead of hard-coding strings +// --------------------------------------------------------------------------- + +export const FIXTURE = { + profiles: { + alice: 'profile-alice', + bob: 'profile-bob', + charlie: 'profile-charlie', + }, + sessions: { + alice1: 'sess-alice-1', + charlie1: 'sess-charlie-1', + charlie2: 'sess-charlie-2', + }, + events: { + alice: { + sessionStart: '00000000-0000-0000-0000-000000000001', + pageView: '00000000-0000-0000-0000-000000000002', + sessionEnd: '00000000-0000-0000-0000-000000000003', + }, + charlie: { + sessionStart: '00000000-0000-0000-0000-000000000004', + screenView: '00000000-0000-0000-0000-000000000005', + pageView: '00000000-0000-0000-0000-000000000006', + purchase: '00000000-0000-0000-0000-000000000007', + sessionEnd: '00000000-0000-0000-0000-000000000008', + }, + }, +} as const; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +type ChClient = ReturnType; + +function getClient() { + const url = process.env.CLICKHOUSE_URL ?? 'http://localhost:8123'; + return createClient({ url }); +} + +function timeAgo(now: Date, days: number, minutesOffset = 0) { + return new Date(now.getTime() - days * 86_400_000 - minutesOffset * 60_000) + .toISOString() + .replace('T', ' ') + .replace(/\.\d+Z$/, ''); +} + +function buildEvent( + now: Date, + projectId: string, + id: string, + name: string, + profileId: string, + sessionId: string, + daysBack: number, + minutesOffset = 0, + overrides: Record = {}, +) { + return { + id, + project_id: projectId, + profile_id: profileId, + name, + session_id: sessionId, + device_id: `dev-${profileId.replace('profile-', '')}`, + created_at: timeAgo(now, daysBack, minutesOffset), + path: '/', + origin: 'https://example.com', + referrer: '', + referrer_name: '', + referrer_type: '', + revenue: 0, + duration: 0, + properties: {}, + groups: [], + country: 'US', + city: '', + region: '', + sdk_name: 'web', + sdk_version: '1.0.0', + os: '', + os_version: '', + browser: 'Chrome', + browser_version: '', + device: 'desktop', + brand: '', + model: '', + ...overrides, + }; +} + +function buildSession( + now: Date, + projectId: string, + id: string, + profileId: string, + daysBack: number, + overrides: Record = {}, +) { + return { + id, + project_id: projectId, + profile_id: profileId, + device_id: `dev-${profileId.replace('profile-', '')}`, + created_at: timeAgo(now, daysBack), + ended_at: timeAgo(now, daysBack), + is_bounce: false, + entry_origin: 'https://example.com', + entry_path: '/home', + exit_origin: 'https://example.com', + exit_path: '/home', + screen_view_count: 1, + revenue: 0, + event_count: 1, + duration: 120, + country: 'US', + region: '', + city: '', + device: 'desktop', + brand: '', + model: '', + browser: 'Chrome', + browser_version: '', + os: '', + os_version: '', + utm_medium: '', + utm_source: '', + utm_campaign: '', + utm_content: '', + utm_term: '', + referrer: '', + referrer_name: '', + referrer_type: '', + sign: 1, + version: 1, + properties: {}, + ...overrides, + }; +} + +async function insertFixtures(client: ChClient, projectId: string) { + const now = new Date(); + + await client.insert({ + table: 'openpanel.profiles', + values: [ + { + id: FIXTURE.profiles.alice, + project_id: projectId, + first_name: 'Alice', + last_name: 'Smith', + email: 'alice@example.com', + avatar: '', + is_external: false, + // browser/country in properties so tests can filter profiles by these fields + properties: { browser: 'Chrome', country: 'US', device: 'desktop' }, + groups: [], + created_at: timeAgo(now, 60), + }, + { + id: FIXTURE.profiles.bob, + project_id: projectId, + first_name: 'Bob', + last_name: "O'Brien", + email: 'bob@example.com', + avatar: '', + is_external: false, + // Bob is intentionally inactive (no events) — useful for inactiveDays tests + properties: { browser: 'Chrome', country: 'SE', device: 'desktop' }, + groups: [], + created_at: timeAgo(now, 90), + }, + { + id: FIXTURE.profiles.charlie, + project_id: projectId, + first_name: 'Charlie', + last_name: 'Brown', + email: 'charlie@example.com', + avatar: '', + is_external: false, + properties: { browser: 'Firefox', country: 'US', device: 'desktop' }, + groups: [], + created_at: timeAgo(now, 30), + }, + ], + format: 'JSONEachRow', + }); + + // Alice: session_start → page_view → session_end (2 days ago, spaced 2 min apart) + // Charlie: session_start → screen_view → page_view → purchase → session_end (5 days ago, spaced 5 min apart) + // Events are spaced so windowFunnel strict_increase mode works correctly. + await client.insert({ + table: 'openpanel.events', + values: [ + buildEvent(now, projectId, FIXTURE.events.alice.sessionStart, 'session_start', FIXTURE.profiles.alice, FIXTURE.sessions.alice1, 2, 4), + buildEvent(now, projectId, FIXTURE.events.alice.pageView, 'page_view', FIXTURE.profiles.alice, FIXTURE.sessions.alice1, 2, 2, { path: '/home', browser: 'Chrome' }), + buildEvent(now, projectId, FIXTURE.events.alice.sessionEnd, 'session_end', FIXTURE.profiles.alice, FIXTURE.sessions.alice1, 2, 0, { duration: 120000 }), + + buildEvent(now, projectId, FIXTURE.events.charlie.sessionStart, 'session_start', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 20, { browser: 'Firefox' }), + buildEvent(now, projectId, FIXTURE.events.charlie.screenView, 'screen_view', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 15, { path: '/shop', browser: 'Firefox' }), + buildEvent(now, projectId, FIXTURE.events.charlie.pageView, 'page_view', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 10, { path: '/shop', browser: 'Firefox' }), + buildEvent(now, projectId, FIXTURE.events.charlie.purchase, 'purchase', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 5, { path: '/checkout', revenue: 9900, browser: 'Firefox' }), + buildEvent(now, projectId, FIXTURE.events.charlie.sessionEnd, 'session_end', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 0, { duration: 300000, browser: 'Firefox' }), + ], + format: 'JSONEachRow', + }); + + await client.insert({ + table: 'openpanel.sessions', + values: [ + buildSession(now, projectId, FIXTURE.sessions.alice1, FIXTURE.profiles.alice, 2), + buildSession(now, projectId, FIXTURE.sessions.charlie1, FIXTURE.profiles.charlie, 5, { + browser: 'Firefox', + entry_path: '/shop', + exit_path: '/checkout', + revenue: 9900, + duration: 300, + screen_view_count: 2, + event_count: 5, + }), + buildSession(now, projectId, FIXTURE.sessions.charlie2, FIXTURE.profiles.charlie, 10, { + browser: 'Firefox', + is_bounce: true, + entry_path: '/shop', + exit_path: '/shop', + duration: 15, + }), + ], + format: 'JSONEachRow', + }); +} + +async function deleteFixtures(client: ChClient, projectId: string) { + await Promise.all([ + client.command({ query: `DELETE FROM openpanel.profiles WHERE project_id = '${projectId}'` }), + client.command({ query: `DELETE FROM openpanel.events WHERE project_id = '${projectId}'` }), + client.command({ query: `DELETE FROM openpanel.sessions WHERE project_id = '${projectId}'` }), + ]); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function setupFixtures(projectId: string): Promise { + const client = getClient(); + await deleteFixtures(client, projectId); + await insertFixtures(client, projectId); + await client.close(); +} + +export async function teardownFixtures(projectId: string): Promise { + const client = getClient(); + await deleteFixtures(client, projectId); + await client.close(); +} From 602ed564fa3843f8aebd0b3cd331f5436bebd652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 13:06:18 +0200 Subject: [PATCH 06/18] openapi 1 --- apps/api/package.json | 5 +- apps/api/src/app.ts | 21 ++ apps/api/src/controllers/manage.controller.ts | 119 +++------- apps/api/src/controllers/track.controller.ts | 14 +- apps/api/src/routes/manage.router.ts | 33 ++- apps/api/src/routes/track.router.ts | 26 ++- pnpm-lock.yaml | 213 +++++++++++++++--- 7 files changed, 286 insertions(+), 145 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 8dd5248bd..aabf084c0 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,10 +20,11 @@ "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.1.0", "@fastify/rate-limit": "^10.3.0", + "@fastify/swagger": "^9.7.0", + "@fastify/swagger-ui": "^5.2.5", "@fastify/websocket": "^11.2.0", "@node-rs/argon2": "^2.0.2", "@openpanel/auth": "workspace:^", - "@openpanel/mcp": "workspace:*", "@openpanel/common": "workspace:*", "@openpanel/constants": "workspace:*", "@openpanel/db": "workspace:*", @@ -31,6 +32,7 @@ "@openpanel/integrations": "workspace:^", "@openpanel/json": "workspace:*", "@openpanel/logger": "workspace:*", + "@openpanel/mcp": "workspace:*", "@openpanel/payments": "workspace:*", "@openpanel/queue": "workspace:*", "@openpanel/redis": "workspace:*", @@ -42,6 +44,7 @@ "fastify": "^5.6.1", "fastify-metrics": "^12.1.0", "fastify-raw-body": "^5.0.0", + "fastify-zod-openapi": "^5.6.1", "groupmq": "catalog:", "jsonwebtoken": "^9.0.2", "ramda": "^0.29.1", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b26d24bae..b516ff4dc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,8 @@ import compress from '@fastify/compress'; import cookie from '@fastify/cookie'; import cors, { type FastifyCorsOptions } from '@fastify/cors'; +import fastifySwagger from '@fastify/swagger'; +import fastifySwaggerUI from '@fastify/swagger-ui'; import { EMPTY_SESSION, type SessionValidationResult, @@ -17,6 +19,12 @@ import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import type { FastifyBaseLogger, FastifyInstance, FastifyRequest } from 'fastify'; import Fastify from 'fastify'; import metricsPlugin from 'fastify-metrics'; +import { + fastifyZodOpenApiPlugin, + fastifyZodOpenApiTransformers, + serializerCompiler, + validatorCompiler, +} from 'fastify-zod-openapi'; import { healthcheck, liveness, @@ -77,6 +85,9 @@ export async function buildApp( : { loggerInstance: logger as unknown as FastifyBaseLogger }), }); + fastify.setValidatorCompiler(validatorCompiler); + fastify.setSerializerCompiler(serializerCompiler); + fastify.register(cors, () => { return ( req: FastifyRequest, @@ -108,6 +119,16 @@ export async function buildApp( fastify.register(compress, { global: false, encodings: ['gzip', 'deflate'] }); + await fastify.register(fastifyZodOpenApiPlugin); + await fastify.register(fastifySwagger, { + openapi: { + info: { title: 'OpenPanel API', version: '1.0.0' }, + openapi: '3.1.0', + }, + ...fastifyZodOpenApiTransformers, + }); + await fastify.register(fastifySwaggerUI, { routePrefix: '/documentation' }); + // Dashboard API fastify.register(async (instance) => { instance.register(cookie, { diff --git a/apps/api/src/controllers/manage.controller.ts b/apps/api/src/controllers/manage.controller.ts index 8df3bee28..028955f9a 100644 --- a/apps/api/src/controllers/manage.controller.ts +++ b/apps/api/src/controllers/manage.controller.ts @@ -11,8 +11,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { HttpError } from '@/utils/errors'; -// Validation schemas -const zCreateProject = z.object({ +// Validation schemas (exported for use in router) +export const zCreateProject = z.object({ name: z.string().min(1), domain: z.string().url().or(z.literal('')).or(z.null()).optional(), cors: z.array(z.string()).default([]), @@ -23,7 +23,7 @@ const zCreateProject = z.object({ .default([]), }); -const zUpdateProject = z.object({ +export const zUpdateProject = z.object({ name: z.string().min(1).optional(), domain: z.string().url().or(z.literal('')).or(z.null()).optional(), cors: z.array(z.string()).optional(), @@ -31,24 +31,24 @@ const zUpdateProject = z.object({ allowUnsafeRevenueTracking: z.boolean().optional(), }); -const zCreateClient = z.object({ +export const zCreateClient = z.object({ name: z.string().min(1), projectId: z.string().optional(), type: z.enum(['read', 'write', 'root']).optional().default('write'), }); -const zUpdateClient = z.object({ +export const zUpdateClient = z.object({ name: z.string().min(1).optional(), }); -const zCreateReference = z.object({ +export const zCreateReference = z.object({ projectId: z.string(), title: z.string().min(1), description: z.string().optional(), datetime: z.string(), }); -const zUpdateReference = z.object({ +export const zUpdateReference = z.object({ title: z.string().min(1).optional(), description: z.string().optional(), datetime: z.string().optional(), @@ -94,17 +94,7 @@ export async function createProject( request: FastifyRequest<{ Body: z.infer }>, reply: FastifyReply ) { - const parsed = zCreateProject.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.issues, - }); - } - - const { name, domain, cors, crossDomain, types } = parsed.data; + const { name, domain, cors, crossDomain, types } = request.body; // Generate a default client secret const secret = `sec_${crypto.randomBytes(10).toString('hex')}`; @@ -164,15 +154,7 @@ export async function updateProject( }>, reply: FastifyReply ) { - const parsed = zUpdateProject.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.issues, - }); - } + const body = request.body; // Verify project exists and belongs to organization const existing = await db.project.findFirst({ @@ -194,23 +176,22 @@ export async function updateProject( } const updateData: any = {}; - if (parsed.data.name !== undefined) { - updateData.name = parsed.data.name; + if (body.name !== undefined) { + updateData.name = body.name; } - if (parsed.data.domain !== undefined) { - updateData.domain = parsed.data.domain - ? stripTrailingSlash(parsed.data.domain) + if (body.domain !== undefined) { + updateData.domain = body.domain + ? stripTrailingSlash(body.domain) : null; } - if (parsed.data.cors !== undefined) { - updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c)); + if (body.cors !== undefined) { + updateData.cors = body.cors.map((c) => stripTrailingSlash(c)); } - if (parsed.data.crossDomain !== undefined) { - updateData.crossDomain = parsed.data.crossDomain; + if (body.crossDomain !== undefined) { + updateData.crossDomain = body.crossDomain; } - if (parsed.data.allowUnsafeRevenueTracking !== undefined) { - updateData.allowUnsafeRevenueTracking = - parsed.data.allowUnsafeRevenueTracking; + if (body.allowUnsafeRevenueTracking !== undefined) { + updateData.allowUnsafeRevenueTracking = body.allowUnsafeRevenueTracking; } const project = await db.project.update({ @@ -314,17 +295,7 @@ export async function createClient( request: FastifyRequest<{ Body: z.infer }>, reply: FastifyReply ) { - const parsed = zCreateClient.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.issues, - }); - } - - const { name, projectId, type } = parsed.data; + const { name, projectId, type } = request.body; // If projectId is provided, verify it belongs to organization if (projectId) { @@ -370,16 +341,6 @@ export async function updateClient( }>, reply: FastifyReply ) { - const parsed = zUpdateClient.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.issues, - }); - } - // Verify client exists and belongs to organization const existing = await db.client.findFirst({ where: { @@ -393,8 +354,8 @@ export async function updateClient( } const updateData: any = {}; - if (parsed.data.name !== undefined) { - updateData.name = parsed.data.name; + if (request.body.name !== undefined) { + updateData.name = request.body.name; } const client = await db.client.update({ @@ -512,17 +473,7 @@ export async function createReference( request: FastifyRequest<{ Body: z.infer }>, reply: FastifyReply ) { - const parsed = zCreateReference.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.issues, - }); - } - - const { projectId, title, description, datetime } = parsed.data; + const { projectId, title, description, datetime } = request.body; // Verify project belongs to organization const project = await db.project.findFirst({ @@ -555,15 +506,7 @@ export async function updateReference( }>, reply: FastifyReply ) { - const parsed = zUpdateReference.safeParse(request.body); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid request body', - details: parsed.error.issues, - }); - } + const body = request.body; // Verify reference exists and belongs to organization const existing = await db.reference.findUnique({ @@ -588,14 +531,14 @@ export async function updateReference( } const updateData: any = {}; - if (parsed.data.title !== undefined) { - updateData.title = parsed.data.title; + if (body.title !== undefined) { + updateData.title = body.title; } - if (parsed.data.description !== undefined) { - updateData.description = parsed.data.description ?? null; + if (body.description !== undefined) { + updateData.description = body.description ?? null; } - if (parsed.data.datetime !== undefined) { - updateData.date = new Date(parsed.data.datetime); + if (body.datetime !== undefined) { + updateData.date = new Date(body.datetime); } const reference = await db.reference.update({ diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 42ef4879f..2639d4c71 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -22,7 +22,6 @@ import { type IReplayPayload, type ITrackHandlerPayload, type ITrackPayload, - zTrackHandlerPayload, } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { assocPath, pathOr, pick } from 'ramda'; @@ -373,18 +372,7 @@ export async function handler( }>, reply: FastifyReply ) { - // Validate request body with Zod - const validationResult = zTrackHandlerPayload.safeParse(request.body); - if (!validationResult.success) { - return reply.status(400).send({ - status: 400, - error: 'Bad Request', - message: 'Validation failed', - errors: validationResult.error.issues, - }); - } - - const validatedBody = validationResult.data; + const validatedBody = request.body; // Handle alias (not supported) if (validatedBody.type === 'alias') { diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts index 70ecfd478..2e441413c 100644 --- a/apps/api/src/routes/manage.router.ts +++ b/apps/api/src/routes/manage.router.ts @@ -1,10 +1,22 @@ +import { Prisma } from '@openpanel/db'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { z } from 'zod'; import * as controller from '@/controllers/manage.controller'; +import { + zCreateClient, + zCreateProject, + zCreateReference, + zUpdateClient, + zUpdateProject, + zUpdateReference, +} from '@/controllers/manage.controller'; import { validateManageRequest } from '@/utils/auth'; import { activateRateLimiter } from '@/utils/rate-limiter'; -import { Prisma } from '@openpanel/db'; -import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; -const manageRouter: FastifyPluginCallback = async (fastify) => { +const idParam = z.object({ id: z.string() }); + +const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { await activateRateLimiter({ fastify, max: 20, @@ -39,30 +51,35 @@ const manageRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'GET', url: '/projects', + schema: { tags: ['manage'] }, handler: controller.listProjects, }); fastify.route({ method: 'GET', url: '/projects/:id', + schema: { params: idParam, tags: ['manage'] }, handler: controller.getProject, }); fastify.route({ method: 'POST', url: '/projects', + schema: { body: zCreateProject, tags: ['manage'] }, handler: controller.createProject, }); fastify.route({ method: 'PATCH', url: '/projects/:id', + schema: { params: idParam, body: zUpdateProject, tags: ['manage'] }, handler: controller.updateProject, }); fastify.route({ method: 'DELETE', url: '/projects/:id', + schema: { params: idParam, tags: ['manage'] }, handler: controller.deleteProject, }); @@ -70,30 +87,35 @@ const manageRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'GET', url: '/clients', + schema: { tags: ['manage'] }, handler: controller.listClients, }); fastify.route({ method: 'GET', url: '/clients/:id', + schema: { params: idParam, tags: ['manage'] }, handler: controller.getClient, }); fastify.route({ method: 'POST', url: '/clients', + schema: { body: zCreateClient, tags: ['manage'] }, handler: controller.createClient, }); fastify.route({ method: 'PATCH', url: '/clients/:id', + schema: { params: idParam, body: zUpdateClient, tags: ['manage'] }, handler: controller.updateClient, }); fastify.route({ method: 'DELETE', url: '/clients/:id', + schema: { params: idParam, tags: ['manage'] }, handler: controller.deleteClient, }); @@ -101,30 +123,35 @@ const manageRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'GET', url: '/references', + schema: { tags: ['manage'] }, handler: controller.listReferences, }); fastify.route({ method: 'GET', url: '/references/:id', + schema: { params: idParam, tags: ['manage'] }, handler: controller.getReference, }); fastify.route({ method: 'POST', url: '/references', + schema: { body: zCreateReference, tags: ['manage'] }, handler: controller.createReference, }); fastify.route({ method: 'PATCH', url: '/references/:id', + schema: { params: idParam, body: zUpdateReference, tags: ['manage'] }, handler: controller.updateReference, }); fastify.route({ method: 'DELETE', url: '/references/:id', + schema: { params: idParam, tags: ['manage'] }, handler: controller.deleteReference, }); }; diff --git a/apps/api/src/routes/track.router.ts b/apps/api/src/routes/track.router.ts index 1bb04c4b9..a50e354cd 100644 --- a/apps/api/src/routes/track.router.ts +++ b/apps/api/src/routes/track.router.ts @@ -1,10 +1,12 @@ -import type { FastifyPluginCallback } from 'fastify'; +import { zTrackHandlerPayload } from '@openpanel/validation'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { z } from 'zod'; import { fetchDeviceId, handler } from '@/controllers/track.controller'; import { clientHook } from '@/hooks/client.hook'; import { duplicateHook } from '@/hooks/duplicate.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; -const trackRouter: FastifyPluginCallback = async (fastify) => { +const trackRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preValidation', duplicateHook); fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); @@ -12,25 +14,27 @@ const trackRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'POST', url: '/', + schema: { + body: zTrackHandlerPayload, + tags: ['track'], + }, handler, }); fastify.route({ method: 'GET', url: '/device-id', - handler: fetchDeviceId, schema: { + tags: ['track'], response: { - 200: { - type: 'object', - properties: { - deviceId: { type: 'string' }, - sessionId: { type: 'string' }, - message: { type: 'string', optional: true }, - }, - }, + 200: z.object({ + deviceId: z.string(), + sessionId: z.string(), + message: z.string().optional(), + }), }, }, + handler: fetchDeviceId, }); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d011727c..3c0fd55a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -133,6 +133,12 @@ importers: '@fastify/rate-limit': specifier: ^10.3.0 version: 10.3.0 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.5 + version: 5.2.5 '@fastify/websocket': specifier: ^11.2.0 version: 11.2.0 @@ -199,6 +205,9 @@ importers: fastify-raw-body: specifier: ^5.0.0 version: 5.0.0 + fastify-zod-openapi: + specifier: ^5.6.1 + version: 5.6.1(@fastify/swagger-ui@5.2.5)(@fastify/swagger@9.7.0)(fastify@5.6.1)(zod@4.1.13) groupmq: specifier: 'catalog:' version: 2.0.0-next.1(ioredis@5.8.2) @@ -269,6 +278,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1) apps/public: dependencies: @@ -1436,6 +1448,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1) packages/payments: dependencies: @@ -4864,6 +4879,18 @@ packages: '@fastify/rate-limit@10.3.0': resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@9.1.0': + resolution: {integrity: sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==} + + '@fastify/swagger-ui@5.2.5': + resolution: {integrity: sha512-ky3I0LAkXKX/prwSDpoQ3kscBKsj2Ha6Gp1/JfgQSqyx0bm9F2bE//XmGVGj2cR9l5hUjZYn60/hqn7e+OLgWQ==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + '@fastify/websocket@11.2.0': resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} @@ -12394,6 +12421,20 @@ packages: resolution: {integrity: sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==} engines: {node: '>= 10'} + fastify-zod-openapi@5.6.1: + resolution: {integrity: sha512-K0tzRYEViPuCV3aKu5Zcgqsew8k0OGzEqu0p1+7P+EvNGXGP7MvcyWNVoq31LUadaT0HiUtD+65tM4sKEQz0Qg==} + engines: {node: '>=20'} + peerDependencies: + '@fastify/swagger': ^9.0.0 + '@fastify/swagger-ui': ^5.0.1 + fastify: '5' + zod: ^3.25.74 || ^4.0.0 + peerDependenciesMeta: + '@fastify/swagger': + optional: true + '@fastify/swagger-ui': + optional: true + fastify@5.6.1: resolution: {integrity: sha512-WjjlOciBF0K8pDUPZoGPhqhKrQJ02I8DKaDIfO51EL0kbSMwQFl85cRwhOvmSDWoukNOdTo27gLN549pLCcH7Q==} @@ -13776,6 +13817,10 @@ packages: json-schema-ref-resolver@2.0.1: resolution: {integrity: sha512-HG0SIB9X4J8bwbxCbnd5FfPEbcXAJYTi1pBJeP/QPON+w8ovSME8iRG+ElHNxZNX2Qh6eYn1GdzJFS4cDFfx0Q==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -14249,9 +14294,6 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -15240,6 +15282,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + ora@3.4.0: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} @@ -19031,10 +19076,6 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yaml@2.3.4: - resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} - engines: {node: '>= 14'} - yaml@2.8.2: resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} engines: {node: '>= 14.6'} @@ -19115,6 +19156,12 @@ packages: zip@1.2.0: resolution: {integrity: sha512-8B4Z9BXJKkI8BkHhKvQan4rwCzUENnj95YHFYrI7F1NbqKCIdW86kujctzEB+kJ6XapHPiAhiZ9xi5GbW5SPdw==} + zod-openapi@5.4.6: + resolution: {integrity: sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A==} + engines: {node: '>=20'} + peerDependencies: + zod: ^3.25.74 || ^4.0.0 + zod-to-json-schema@3.24.5: resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} peerDependencies: @@ -20039,7 +20086,7 @@ snapshots: '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -20059,7 +20106,7 @@ snapshots: '@babel/traverse': 7.28.5 '@babel/types': 7.28.2 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -23327,6 +23374,41 @@ snapshots: fastify-plugin: 5.0.1 toad-cache: 3.7.0 + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@9.1.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.0.1 + fastify-plugin: 5.0.1 + fastq: 1.17.1 + glob: 13.0.3 + + '@fastify/swagger-ui@5.2.5': + dependencies: + '@fastify/static': 9.1.0 + fastify-plugin: 5.0.1 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.0.1 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.8.2 + transitivePeerDependencies: + - supports-color + '@fastify/websocket@11.2.0': dependencies: duplexify: 4.1.3 @@ -28308,7 +28390,7 @@ snapshots: enhanced-resolve: 5.18.3 jiti: 2.6.1 lightningcss: 1.30.1 - magic-string: 0.30.17 + magic-string: 0.30.21 source-map-js: 1.2.1 tailwindcss: 4.1.12 @@ -29669,7 +29751,7 @@ snapshots: '@vitest/snapshot@1.6.1': dependencies: - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 1.1.2 pretty-format: 29.7.0 @@ -32872,11 +32954,11 @@ snapshots: fast-json-stringify@6.0.1: dependencies: '@fastify/merge-json-schemas': 0.2.1 - ajv: 8.12.0 - ajv-formats: 3.0.1(ajv@8.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.0.6 json-schema-ref-resolver: 2.0.1 - rfdc: 1.3.1 + rfdc: 1.4.1 fast-npm-meta@0.4.7: {} @@ -32912,6 +32994,18 @@ snapshots: raw-body: 3.0.0 secure-json-parse: 2.7.0 + fastify-zod-openapi@5.6.1(@fastify/swagger-ui@5.2.5)(@fastify/swagger@9.7.0)(fastify@5.6.1)(zod@4.1.13): + dependencies: + '@fastify/error': 4.0.0 + fast-json-stringify: 6.0.1 + fastify: 5.6.1 + fastify-plugin: 5.0.1 + zod: 4.1.13 + zod-openapi: 5.4.6(zod@4.1.13) + optionalDependencies: + '@fastify/swagger': 9.7.0 + '@fastify/swagger-ui': 5.2.5 + fastify@5.6.1: dependencies: '@fastify/ajv-compiler': 4.0.2 @@ -33903,7 +33997,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.1 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -33919,7 +34013,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -34572,6 +34666,14 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.0.6 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -34991,10 +35093,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - magic-string@0.30.19: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -36153,7 +36251,7 @@ snapshots: klona: 2.0.6 knitwork: 1.2.0 listhen: 1.9.0 - magic-string: 0.30.19 + magic-string: 0.30.21 magicast: 0.3.5 mime: 4.1.0 mlly: 1.8.0 @@ -36173,7 +36271,7 @@ snapshots: serve-placeholder: 2.0.2 serve-static: 2.2.0 source-map: 0.7.6 - std-env: 3.9.0 + std-env: 3.10.0 ufo: 1.6.1 ultrahtml: 1.6.0 uncrypto: 0.1.3 @@ -36663,6 +36761,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-types@12.1.3: {} + ora@3.4.0: dependencies: chalk: 2.4.2 @@ -37087,7 +37187,7 @@ snapshots: postcss-load-config@4.0.2(postcss@8.5.6): dependencies: lilconfig: 3.1.0 - yaml: 2.3.4 + yaml: 2.8.2 optionalDependencies: postcss: 8.5.6 @@ -40284,7 +40384,7 @@ snapshots: vite-node@1.6.1(@types/node@20.19.24)(lightningcss@1.30.2)(terser@5.27.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 pathe: 1.1.2 picocolors: 1.1.1 vite: 5.4.21(@types/node@20.19.24)(lightningcss@1.30.2)(terser@5.27.1) @@ -40299,6 +40399,24 @@ snapshots: - supports-color - terser + vite-node@1.6.1(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@2.1.9(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1): dependencies: cac: 6.7.14 @@ -40484,13 +40602,13 @@ snapshots: '@vitest/utils': 1.6.1 acorn-walk: 8.3.2 chai: 4.5.0 - debug: 4.4.1 + debug: 4.4.3 execa: 8.0.1 local-pkg: 0.5.1 - magic-string: 0.30.19 + magic-string: 0.30.21 pathe: 1.1.2 picocolors: 1.1.1 - std-env: 3.9.0 + std-env: 3.10.0 strip-literal: 2.1.1 tinybench: 2.9.0 tinypool: 0.8.4 @@ -40510,6 +40628,41 @@ snapshots: - supports-color - terser + vitest@1.6.1(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.2 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1) + vite-node: 1.6.1(@types/node@24.10.1)(lightningcss@1.30.2)(terser@5.27.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + jsdom: 26.1.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@2.1.9(@types/node@24.10.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.27.1): dependencies: '@vitest/expect': 2.1.9 @@ -40895,8 +41048,6 @@ snapshots: yaml@1.10.2: {} - yaml@2.3.4: {} - yaml@2.8.2: {} yargs-parser@18.1.3: @@ -41023,6 +41174,10 @@ snapshots: dependencies: bops: 0.1.1 + zod-openapi@5.4.6(zod@4.1.13): + dependencies: + zod: 4.1.13 + zod-to-json-schema@3.24.5(zod@3.25.76): dependencies: zod: 3.25.76 From 26174d6d5d7aa0041d8acf5832721a00b732092f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 14:20:32 +0200 Subject: [PATCH 07/18] restructure api --- apps/api/src/app.ts | 22 +- apps/api/src/controllers/export.controller.ts | 50 +- .../src/controllers/insights.controller.ts | 129 ++-- apps/api/src/controllers/query.controller.ts | 251 +++----- apps/api/src/routes/event.router.ts | 9 +- apps/api/src/routes/export.router.ts | 49 +- apps/api/src/routes/insights.router.ts | 578 ++++++++++++++++-- apps/api/src/routes/manage.router.ts | 48 +- apps/api/src/routes/profile.router.ts | 18 +- apps/api/src/routes/query.router.ts | 85 --- apps/api/src/routes/track.router.ts | 6 +- 11 files changed, 771 insertions(+), 474 deletions(-) delete mode 100644 apps/api/src/routes/query.router.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b516ff4dc..a61f16691 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -46,7 +46,6 @@ import mcpRouter from './routes/mcp.router'; import miscRouter from './routes/misc.router'; import oauthRouter from './routes/oauth-callback.router'; import profileRouter from './routes/profile.router'; -import queryRouter from './routes/query.router'; import trackRouter from './routes/track.router'; import webhookRouter from './routes/webhook.router'; import { HttpError } from './utils/errors'; @@ -119,16 +118,6 @@ export async function buildApp( fastify.register(compress, { global: false, encodings: ['gzip', 'deflate'] }); - await fastify.register(fastifyZodOpenApiPlugin); - await fastify.register(fastifySwagger, { - openapi: { - info: { title: 'OpenPanel API', version: '1.0.0' }, - openapi: '3.1.0', - }, - ...fastifyZodOpenApiTransformers, - }); - await fastify.register(fastifySwaggerUI, { routePrefix: '/documentation' }); - // Dashboard API fastify.register(async (instance) => { instance.register(cookie, { @@ -193,6 +182,16 @@ export async function buildApp( // Public API fastify.register(async (instance) => { + await instance.register(fastifyZodOpenApiPlugin); + await instance.register(fastifySwagger, { + openapi: { + info: { title: 'OpenPanel API', version: '1.0.0' }, + openapi: '3.1.0', + }, + ...fastifyZodOpenApiTransformers, + }); + await instance.register(fastifySwaggerUI, { routePrefix: '/documentation' }); + // Prometheus metrics: skip in tests (causes global state conflicts across test runs) if (!testing) { instance.register(metricsPlugin, { endpoint: '/metrics' }); @@ -205,7 +204,6 @@ export async function buildApp( instance.register(insightsRouter, { prefix: '/insights' }); instance.register(trackRouter, { prefix: '/track' }); instance.register(manageRouter, { prefix: '/manage' }); - instance.register(queryRouter, { prefix: '/query' }); instance.get('/healthcheck', healthcheck); instance.get('/healthz/live', liveness); diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts index 2dc8d4144..d1f8de94a 100644 --- a/apps/api/src/controllers/export.controller.ts +++ b/apps/api/src/controllers/export.controller.ts @@ -12,7 +12,6 @@ import { zChartEvent, zReport } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { HttpError } from '@/utils/errors'; -import { parseQueryString } from '@/utils/parse-zod-query-string'; async function getProjectId( request: FastifyRequest<{ @@ -61,7 +60,7 @@ async function getProjectId( return projectId; } -const eventsScheme = z.object({ +export const eventsScheme = z.object({ project_id: z.string().optional(), projectId: z.string().optional(), profileId: z.string().optional(), @@ -96,39 +95,22 @@ export async function events( }>, reply: FastifyReply ) { - const query = eventsScheme.safeParse(request.query); - - if (query.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: query.error.issues, - }); - } - const projectId = await getProjectId(request); - const limit = query.data.limit; - const page = Math.max(query.data.page, 1); + const { limit, page: rawPage, event, start, end, profileId, includes } = request.query; const take = Math.max(Math.min(limit, 1000), 1); - const cursor = page - 1; + const cursor = Math.max(rawPage, 1) - 1; const options: GetEventListOptions = { projectId, - events: (Array.isArray(query.data.event) - ? query.data.event - : [query.data.event] - ).filter((s): s is string => typeof s === 'string'), - startDate: query.data.start ? new Date(query.data.start) : undefined, - endDate: query.data.end ? new Date(query.data.end) : undefined, + events: (Array.isArray(event) ? event : [event]).filter((s): s is string => typeof s === 'string'), + startDate: start ? new Date(start) : undefined, + endDate: end ? new Date(end) : undefined, cursor, take, - profileId: query.data.profileId, + profileId, select: { profile: false, meta: false, - ...query.data.includes?.reduce( - (acc, key) => ({ ...acc, [key]: true }), - {} - ), + ...includes?.reduce((acc, key) => ({ ...acc, [key]: true }), {}), }, }; @@ -148,7 +130,7 @@ export async function events( }); } -const chartSchemeFull = zReport +export const chartSchemeFull = zReport .pick({ breakdowns: true, interval: true, @@ -185,23 +167,13 @@ const chartSchemeFull = zReport export async function charts( request: FastifyRequest<{ - Querystring: Record; + Querystring: z.infer; }>, reply: FastifyReply ) { - const query = chartSchemeFull.safeParse(parseQueryString(request.query)); - - if (query.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: query.error.issues, - }); - } - const projectId = await getProjectId(request); const { timezone } = await getSettingsForProject(projectId); - const { events, series, ...rest } = query.data; + const { events, series, ...rest } = request.query; // Use series if available, otherwise fall back to events (backward compat) const eventSeries = (series ?? events ?? []).map((event: any) => ({ diff --git a/apps/api/src/controllers/insights.controller.ts b/apps/api/src/controllers/insights.controller.ts index b8051ae38..0f276418d 100644 --- a/apps/api/src/controllers/insights.controller.ts +++ b/apps/api/src/controllers/insights.controller.ts @@ -1,4 +1,3 @@ -import { parseQueryString } from '@/utils/parse-zod-query-string'; import { getDefaultIntervalByDates } from '@openpanel/constants'; import { eventBuffer, @@ -10,13 +9,13 @@ import { zChartEventFilter, zRange } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; -const zGetMetricsQuery = z.object({ +export const zGetMetricsQuery = z.object({ startDate: z.string().nullish(), endDate: z.string().nullish(), range: zRange.default('7d'), filters: z.array(zChartEventFilter).default([]), }); -// Website stats - main metrics overview + export async function getMetrics( request: FastifyRequest<{ Params: { projectId: string }; @@ -25,35 +24,21 @@ export async function getMetrics( reply: FastifyReply, ) { const { timezone } = await getSettingsForProject(request.params.projectId); - const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query)); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: parsed.error, - }); - } - - const { startDate, endDate } = getChartStartEndDate(parsed.data, timezone); - + const { startDate, endDate } = getChartStartEndDate(request.query, timezone); reply.send( await overviewService.getMetrics({ projectId: request.params.projectId, - filters: parsed.data.filters, - startDate: startDate, - endDate: endDate, + filters: request.query.filters, + startDate, + endDate, interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day', timezone, }), ); } -// Live visitors (real-time) export async function getLiveVisitors( - request: FastifyRequest<{ - Params: { projectId: string }; - }>, + request: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply, ) { reply.send({ @@ -70,7 +55,6 @@ export const zGetTopPagesQuery = z.object({ limit: z.number().default(10), }); -// Page views with top pages export async function getPages( request: FastifyRequest<{ Params: { projectId: string }; @@ -80,93 +64,66 @@ export async function getPages( ) { const { timezone } = await getSettingsForProject(request.params.projectId); const { startDate, endDate } = getChartStartEndDate(request.query, timezone); - const parsed = zGetTopPagesQuery.safeParse(parseQueryString(request.query)); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: parsed.error, - }); - } - return overviewService.getTopPages({ projectId: request.params.projectId, - filters: parsed.data.filters, - startDate: startDate, - endDate: endDate, + filters: request.query.filters, + startDate, + endDate, timezone, }); } -const zGetOverviewGenericQuery = z.object({ +export const overviewColumns = [ + 'referrer', + 'referrer_name', + 'referrer_type', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'region', + 'country', + 'city', + 'device', + 'brand', + 'model', + 'browser', + 'browser_version', + 'os', + 'os_version', +] as const; + +export type OverviewColumn = (typeof overviewColumns)[number]; + +// Querystring schema for the dynamic overview generic routes. +// `column` is injected from the route factory, not from the querystring. +export const zOverviewGenericQuerystring = z.object({ filters: z.array(zChartEventFilter).default([]), startDate: z.string().nullish(), endDate: z.string().nullish(), range: zRange.default('7d'), - column: z.enum([ - // Referrers - 'referrer', - 'referrer_name', - 'referrer_type', - 'utm_source', - 'utm_medium', - 'utm_campaign', - 'utm_term', - 'utm_content', - // Geo - 'region', - 'country', - 'city', - // Device - 'device', - 'brand', - 'model', - 'browser', - 'browser_version', - 'os', - 'os_version', - ]), cursor: z.number().optional(), limit: z.number().default(10), }); -export function getOverviewGeneric( - column: z.infer['column'], -) { +export function getOverviewGeneric(column: OverviewColumn) { return async ( request: FastifyRequest<{ - Params: { projectId: string; key: string }; - Querystring: z.infer; + Params: { projectId: string }; + Querystring: z.infer; }>, reply: FastifyReply, ) => { const { timezone } = await getSettingsForProject(request.params.projectId); - const { startDate, endDate } = getChartStartEndDate( - request.query, - timezone, - ); - const parsed = zGetOverviewGenericQuery.safeParse({ - ...parseQueryString(request.query), - column, - }); - - if (parsed.success === false) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: parsed.error, - }); - } - - // TODO: Implement overview generic endpoint + const { startDate, endDate } = getChartStartEndDate(request.query, timezone); reply.send( await overviewService.getTopGeneric({ column, projectId: request.params.projectId, - filters: parsed.data.filters, - startDate: startDate, - endDate: endDate, + filters: request.query.filters, + startDate, + endDate, timezone, }), ); diff --git a/apps/api/src/controllers/query.controller.ts b/apps/api/src/controllers/query.controller.ts index 9808743a3..95951119d 100644 --- a/apps/api/src/controllers/query.controller.ts +++ b/apps/api/src/controllers/query.controller.ts @@ -1,4 +1,3 @@ -import { parseQueryString } from '@/utils/parse-zod-query-string'; import { findGroupsCore, findProfilesCore, @@ -63,7 +62,7 @@ function resolveQueryProjectId( return client.projectId; } -const zDateRange = z.object({ +export const zDateRange = z.object({ startDate: z.string().optional(), endDate: z.string().optional(), // Convenience shorthand matching the insights API (e.g. ?range=7d, ?range=30d). @@ -106,14 +105,6 @@ function getClientType( return req.client!.type === ClientType.root ? 'root' : 'read'; } -function badRequest(reply: FastifyReply, error: z.ZodError) { - return reply.status(400).send({ - error: 'Bad Request', - message: 'Invalid query parameters', - details: error.issues, - }); -} - // --------------------------------------------------------------------------- // Projects // --------------------------------------------------------------------------- @@ -136,37 +127,33 @@ export async function listProjects( // Analytics — overview // --------------------------------------------------------------------------- -const zOverviewQuery = zDateRange.extend({ +export const zOverviewQuery = zDateRange.extend({ interval: z.enum(['hour', 'day', 'week', 'month']).optional(), }); export async function getOverview( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zOverviewQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); - return reply.send(await getAnalyticsOverviewCore({ projectId, startDate, endDate, interval: parsed.data.interval })); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getAnalyticsOverviewCore({ projectId, startDate, endDate, interval: req.query.interval })); } // --------------------------------------------------------------------------- // Analytics — active users // --------------------------------------------------------------------------- -const zActiveUsersQuery = z.object({ +export const zActiveUsersQuery = z.object({ days: z.number().int().min(1).max(90).default(7), }); export async function getActiveUsers( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zActiveUsersQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await getRollingActiveUsersCore({ projectId, days: parsed.data.days })); + return reply.send(await getRollingActiveUsersCore({ projectId, days: req.query.days })); } // --------------------------------------------------------------------------- @@ -198,13 +185,11 @@ export async function getRetentionCohort( // --------------------------------------------------------------------------- export async function getTopPages( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zDateRange.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send(await getTopPagesCore({ projectId, startDate, endDate })); } @@ -212,26 +197,24 @@ export async function getTopPages( // Analytics — pages (entry/exit) // --------------------------------------------------------------------------- -const zEntryExitQuery = zDateRange.extend({ +export const zEntryExitQuery = zDateRange.extend({ mode: z.enum(['entry', 'exit']).default('entry'), }); export async function getEntryExitPages( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zEntryExitQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); - return reply.send(await getEntryExitPagesCore({ projectId, startDate, endDate, mode: parsed.data.mode })); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getEntryExitPagesCore({ projectId, startDate, endDate, mode: req.query.mode })); } // --------------------------------------------------------------------------- // Analytics — page performance // --------------------------------------------------------------------------- -const zPagePerfQuery = zDateRange.extend({ +export const zPagePerfQuery = zDateRange.extend({ search: z.string().optional(), sortBy: z.enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']).optional(), sortOrder: z.enum(['asc', 'desc']).optional(), @@ -239,15 +222,13 @@ const zPagePerfQuery = zDateRange.extend({ }); export async function getPagePerformance( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zPagePerfQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getPagePerformanceCore({ projectId, startDate, endDate, ...parsed.data }), + await getPagePerformanceCore({ projectId, startDate, endDate, ...req.query }), ); } @@ -255,7 +236,7 @@ export async function getPagePerformance( // Analytics — funnel // --------------------------------------------------------------------------- -const zFunnelQuery = zDateRange.extend({ +export const zFunnelQuery = zDateRange.extend({ steps: z .union([z.array(z.string()), z.string().transform((s) => [s])]) .refine((a) => a.length >= 2 && a.length <= 10, { @@ -266,21 +247,19 @@ const zFunnelQuery = zDateRange.extend({ }); export async function getFunnel( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zFunnelQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( await getFunnelCore({ projectId, startDate, endDate, - steps: parsed.data.steps, - windowHours: parsed.data.windowHours, - groupBy: parsed.data.groupBy, + steps: req.query.steps, + windowHours: req.query.windowHours, + groupBy: req.query.groupBy, }), ); } @@ -293,54 +272,48 @@ const referrerColumns = ['referrer_name', 'referrer_type', 'referrer', 'utm_sour const geoColumns = ['country', 'region', 'city'] as const; const deviceColumns = ['device', 'browser', 'os'] as const; -const zReferrerQuery = zDateRange.extend({ +export const zReferrerQuery = zDateRange.extend({ breakdown: z.enum(referrerColumns).default('referrer_name'), }); -const zGeoQuery = zDateRange.extend({ +export const zGeoQuery = zDateRange.extend({ breakdown: z.enum(geoColumns).default('country'), }); -const zDeviceQuery = zDateRange.extend({ +export const zDeviceQuery = zDateRange.extend({ breakdown: z.enum(deviceColumns).default('device'), }); export async function getTrafficReferrers( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zReferrerQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), + await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn }), ); } export async function getTrafficGeo( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGeoQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), + await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn }), ); } export async function getTrafficDevices( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zDeviceQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getTrafficBreakdownCore({ projectId, startDate, endDate, column: parsed.data.breakdown as TrafficColumn }), + await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn }), ); } @@ -348,7 +321,7 @@ export async function getTrafficDevices( // Analytics — user flow // --------------------------------------------------------------------------- -const zUserFlowQuery = zDateRange.extend({ +export const zUserFlowQuery = zDateRange.extend({ startEvent: z.string(), endEvent: z.string().optional(), mode: z.enum(['after', 'before', 'between']).default('after'), @@ -362,15 +335,13 @@ const zUserFlowQuery = zDateRange.extend({ }); export async function getUserFlow( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zUserFlowQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getUserFlowCore({ projectId, startDate, endDate, ...parsed.data }), + await getUserFlowCore({ projectId, startDate, endDate, ...req.query }), ); } @@ -390,7 +361,7 @@ export async function getEngagement( // Events // --------------------------------------------------------------------------- -const zEventsQuery = zDateRange.extend({ +export const zEventsQuery = zDateRange.extend({ eventNames: z .union([z.array(z.string()), z.string().transform((s) => [s])]) .optional(), @@ -409,15 +380,11 @@ const zEventsQuery = zDateRange.extend({ }); export async function queryEvents( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zEventsQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await queryEventsCore({ projectId, ...parsed.data }), - ); + return reply.send(await queryEventsCore({ projectId, ...req.query })); } export async function listEventNames( @@ -428,42 +395,36 @@ export async function listEventNames( return reply.send(await listEventNamesCore(projectId)); } -const zEventPropertiesQuery = z.object({ +export const zEventPropertiesQuery = z.object({ eventName: z.string().optional(), }); export async function listEventProperties( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zEventPropertiesQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await listEventPropertiesCore({ projectId, eventName: parsed.data.eventName })); + return reply.send(await listEventPropertiesCore({ projectId, eventName: req.query.eventName })); } -const zPropertyValuesQuery = z.object({ +export const zPropertyValuesQuery = z.object({ eventName: z.string(), propertyKey: z.string(), }); export async function getEventPropertyValues( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zPropertyValuesQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await getEventPropertyValuesCore({ projectId, ...parsed.data }), - ); + return reply.send(await getEventPropertyValuesCore({ projectId, ...req.query })); } // --------------------------------------------------------------------------- // Profiles // --------------------------------------------------------------------------- -const zProfilesQuery = z.object({ +export const zProfilesQuery = z.object({ name: z.string().optional(), email: z.string().optional(), country: z.string().optional(), @@ -478,47 +439,39 @@ const zProfilesQuery = z.object({ }); export async function findProfiles( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zProfilesQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await findProfilesCore({ projectId, ...parsed.data })); + return reply.send(await findProfilesCore({ projectId, ...req.query })); } -const zGetProfileQuery = z.object({ +export const zGetProfileQuery = z.object({ eventLimit: z.number().int().min(1).max(100).default(20), }); export async function getProfile( - req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, + req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGetProfileQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const result = await getProfileWithEvents(projectId, req.params.profileId, parsed.data.eventLimit); + const result = await getProfileWithEvents(projectId, req.params.profileId, req.query.eventLimit); if (!result.profile) { return reply.status(404).send({ error: 'Profile not found', profileId: req.params.profileId }); } - // Transform snake_case MCP key to camelCase for REST consumers return reply.send({ profile: result.profile, recentEvents: result.recent_events }); } -const zProfileSessionsQuery = z.object({ +export const zProfileSessionsQuery = z.object({ limit: z.number().int().min(1).max(100).default(20), }); export async function getProfileSessions( - req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, + req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zProfileSessionsQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const sessions = await getProfileSessionsCore(projectId, req.params.profileId, parsed.data.limit); - return reply.send(sessions); + return reply.send(await getProfileSessionsCore(projectId, req.params.profileId, req.query.limit)); } export async function getProfileMetrics( @@ -535,7 +488,7 @@ export async function getProfileMetrics( // Sessions // --------------------------------------------------------------------------- -const zSessionsQuery = zDateRange.extend({ +export const zSessionsQuery = zDateRange.extend({ country: z.string().optional(), city: z.string().optional(), device: z.string().optional(), @@ -549,13 +502,11 @@ const zSessionsQuery = zDateRange.extend({ }); export async function querySessions( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zSessionsQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await querySessionsCore({ projectId, ...parsed.data })); + return reply.send(await querySessionsCore({ projectId, ...req.query })); } // --------------------------------------------------------------------------- @@ -570,35 +521,31 @@ export async function listGroupTypes( return reply.send(await listGroupTypesCore(projectId)); } -const zGroupsQuery = z.object({ +export const zGroupsQuery = z.object({ type: z.string().optional(), search: z.string().optional(), limit: z.number().int().min(1).max(100).default(20), }); export async function findGroups( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGroupsQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await findGroupsCore({ projectId, ...parsed.data })); + return reply.send(await findGroupsCore({ projectId, ...req.query })); } -const zGetGroupQuery = z.object({ +export const zGetGroupQuery = z.object({ memberLimit: z.number().int().min(1).max(50).default(10), }); export async function getGroup( - req: FastifyRequest<{ Params: { projectId?: string; groupId: string } }>, + req: FastifyRequest<{ Params: { projectId?: string; groupId: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGetGroupQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); return reply.send( - await getGroupCore({ projectId, groupId: req.params.groupId, memberLimit: parsed.data.memberLimit }), + await getGroupCore({ projectId, groupId: req.params.groupId, memberLimit: req.query.memberLimit }), ); } @@ -641,103 +588,89 @@ export async function getReportData( // Google Search Console // --------------------------------------------------------------------------- -const zGscOverviewQuery = zDateRange.extend({ +export const zGscOverviewQuery = zDateRange.extend({ interval: z.enum(['day', 'week', 'month']).default('day'), }); export async function gscOverview( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGscOverviewQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); - return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: parsed.data.interval })); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: req.query.interval })); } -const zGscLimitQuery = zDateRange.extend({ +export const zGscLimitQuery = zDateRange.extend({ limit: z.number().int().min(1).max(1000).default(100), }); export async function gscPages( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGscLimitQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); - return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: parsed.data.limit })); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: req.query.limit })); } -const zGscPageDetailsQuery = zDateRange.extend({ +export const zGscPageDetailsQuery = zDateRange.extend({ page: z.string().url(), }); export async function gscPageDetails( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGscPageDetailsQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); - return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: parsed.data.page })); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: req.query.page })); } export async function gscQueries( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGscLimitQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); - return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: parsed.data.limit })); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: req.query.limit })); } -const zGscQueryDetailsQuery = zDateRange.extend({ +export const zGscQueryDetailsQuery = zDateRange.extend({ query: z.string(), }); export async function gscQueryDetails( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGscQueryDetailsQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: parsed.data.query }), + await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: req.query.query }), ); } -const zGscOpportunitiesQuery = zDateRange.extend({ +export const zGscOpportunitiesQuery = zDateRange.extend({ minImpressions: z.number().int().min(1).default(50), }); export async function gscQueryOpportunities( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zGscOpportunitiesQuery.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: parsed.data.minImpressions }), + await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: req.query.minImpressions }), ); } export async function gscCannibalization( - req: FastifyRequest<{ Params: { projectId?: string } }>, + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, reply: FastifyReply, ) { - const parsed = zDateRange.safeParse(parseQueryString(req.query as Record)); - if (!parsed.success) return badRequest(reply, parsed.error); const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, parsed.data); + const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send(await gscGetCannibalizationCore({ projectId, startDate, endDate })); } diff --git a/apps/api/src/routes/event.router.ts b/apps/api/src/routes/event.router.ts index 5efa52add..ef8fc341f 100644 --- a/apps/api/src/routes/event.router.ts +++ b/apps/api/src/routes/event.router.ts @@ -1,11 +1,10 @@ +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; import * as controller from '@/controllers/event.controller'; -import type { FastifyPluginCallback } from 'fastify'; - import { clientHook } from '@/hooks/client.hook'; import { duplicateHook } from '@/hooks/duplicate.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; -const eventRouter: FastifyPluginCallback = async (fastify) => { +const eventRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preValidation', duplicateHook); fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); @@ -13,6 +12,10 @@ const eventRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'POST', url: '/', + schema: { + tags: ['ingestion'], + description: 'Deprecated direct event ingestion endpoint. Use /track instead.', + }, handler: controller.postEvent, }); }; diff --git a/apps/api/src/routes/export.router.ts b/apps/api/src/routes/export.router.ts index 3dafb42b8..ff3d0aed7 100644 --- a/apps/api/src/routes/export.router.ts +++ b/apps/api/src/routes/export.router.ts @@ -1,15 +1,19 @@ +import { Prisma } from '@openpanel/db'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { + chartSchemeFull, + eventsScheme, +} from '@/controllers/export.controller'; import * as controller from '@/controllers/export.controller'; import { validateExportRequest } from '@/utils/auth'; +import { parseQueryString } from '@/utils/parse-zod-query-string'; import { activateRateLimiter } from '@/utils/rate-limiter'; -import { Prisma } from '@openpanel/db'; -import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; -const exportRouter: FastifyPluginCallback = async (fastify) => { - await activateRateLimiter({ - fastify, - max: 100, - timeWindow: '10 seconds', - }); +const TAGS = ['export'] as const; + +const exportRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { + await activateRateLimiter({ fastify, max: 100, timeWindow: '10 seconds' }); fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { try { @@ -17,33 +21,38 @@ const exportRouter: FastifyPluginCallback = async (fastify) => { req.client = client; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - return reply.status(401).send({ - error: 'Unauthorized', - message: 'Client ID seems to be malformed', - }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Client ID seems to be malformed' }); } - if (e instanceof Error) { - return reply - .status(401) - .send({ error: 'Unauthorized', message: e.message }); + return reply.status(401).send({ error: 'Unauthorized', message: e.message }); } - - return reply - .status(401) - .send({ error: 'Unauthorized', message: 'Unexpected error' }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Unexpected error' }); } }); + fastify.addHook('preValidation', async (req) => { + req.query = parseQueryString(req.query as Record) as typeof req.query; + }); + fastify.route({ method: 'GET', url: '/events', + schema: { + tags: TAGS, + description: 'Export a paginated list of raw events with optional filtering by date, profile, and event type.', + querystring: eventsScheme, + }, handler: controller.events, }); fastify.route({ method: 'GET', url: '/charts', + schema: { + tags: TAGS, + description: 'Export aggregated chart/analytics data for a series of events over a time range.', + querystring: chartSchemeFull, + }, handler: controller.charts, }); }; diff --git a/apps/api/src/routes/insights.router.ts b/apps/api/src/routes/insights.router.ts index ccd2dd390..0e14b6e05 100644 --- a/apps/api/src/routes/insights.router.ts +++ b/apps/api/src/routes/insights.router.ts @@ -1,15 +1,54 @@ -import * as controller from '@/controllers/insights.controller'; +import { Prisma } from '@openpanel/db'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; +import { z } from 'zod'; +import * as insights from '@/controllers/insights.controller'; +import { + overviewColumns, + zGetMetricsQuery, + zGetTopPagesQuery, + zOverviewGenericQuerystring, +} from '@/controllers/insights.controller'; +import * as query from '@/controllers/query.controller'; +import { + zActiveUsersQuery, + zDateRange, + zDeviceQuery, + zEntryExitQuery, + zEventsQuery, + zEventPropertiesQuery, + zFunnelQuery, + zGeoQuery, + zGetGroupQuery, + zGetProfileQuery, + zGroupsQuery, + zGscLimitQuery, + zGscOpportunitiesQuery, + zGscOverviewQuery, + zGscPageDetailsQuery, + zGscQueryDetailsQuery, + zOverviewQuery, + zPagePerfQuery, + zProfilesQuery, + zProfileSessionsQuery, + zPropertyValuesQuery, + zReferrerQuery, + zSessionsQuery, + zUserFlowQuery, +} from '@/controllers/query.controller'; import { validateExportRequest } from '@/utils/auth'; +import { parseQueryString } from '@/utils/parse-zod-query-string'; import { activateRateLimiter } from '@/utils/rate-limiter'; -import { Prisma } from '@openpanel/db'; -import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; -const insightsRouter: FastifyPluginCallback = async (fastify) => { - await activateRateLimiter({ - fastify, - max: 100, - timeWindow: '10 seconds', - }); +const projectIdParam = z.object({ projectId: z.string() }); +const profileParam = z.object({ projectId: z.string(), profileId: z.string() }); +const groupParam = z.object({ projectId: z.string(), groupId: z.string() }); +const reportParam = z.object({ projectId: z.string(), reportId: z.string() }); + +const TAGS = ['insights'] as const; + +const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { + await activateRateLimiter({ fastify, max: 100, timeWindow: '10 seconds' }); fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { try { @@ -17,72 +56,505 @@ const insightsRouter: FastifyPluginCallback = async (fastify) => { req.client = client; } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - return reply.status(401).send({ - error: 'Unauthorized', - message: 'Client ID seems to be malformed', - }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Client ID seems to be malformed' }); } - if (e instanceof Error) { - return reply - .status(401) - .send({ error: 'Unauthorized', message: e.message }); + return reply.status(401).send({ error: 'Unauthorized', message: e.message }); } - - return reply - .status(401) - .send({ error: 'Unauthorized', message: 'Unexpected error' }); + return reply.status(401).send({ error: 'Unauthorized', message: 'Unexpected error' }); } }); - // Website stats - main metrics overview + // Run parseQueryString before Fastify schema validation so coercion + // (string→number, JSON-encoded arrays, etc.) is handled automatically. + fastify.addHook('preValidation', async (req) => { + req.query = parseQueryString(req.query as Record) as typeof req.query; + }); + + // --------------------------------------------------------------------------- + // Analytics — overview + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/overview', + schema: { + tags: TAGS, + description: 'Get an overview of key metrics for the project (sessions, pageviews, bounce rate, duration).', + params: projectIdParam, + querystring: zOverviewQuery, + }, + handler: query.getOverview, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/active_users', + schema: { + tags: TAGS, + description: 'Get rolling active user counts over the last N days.', + params: projectIdParam, + querystring: zActiveUsersQuery, + }, + handler: query.getActiveUsers, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/retention', + schema: { + tags: TAGS, + description: 'Get weekly retention series data.', + params: projectIdParam, + }, + handler: query.getRetentionSeries, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/retention/cohort', + schema: { + tags: TAGS, + description: 'Get retention cohort data.', + params: projectIdParam, + }, + handler: query.getRetentionCohort, + }); + + // --------------------------------------------------------------------------- + // Analytics — pages + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/pages/top', + schema: { + tags: TAGS, + description: 'Get the top pages by pageviews for the given date range.', + params: projectIdParam, + querystring: zDateRange, + }, + handler: query.getTopPages, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/pages/entry_exit', + schema: { + tags: TAGS, + description: 'Get entry or exit pages ranked by session count.', + params: projectIdParam, + querystring: zEntryExitQuery, + }, + handler: query.getEntryExitPages, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/pages/performance', + schema: { + tags: TAGS, + description: 'Get page-level performance metrics (bounce rate, avg duration, sessions).', + params: projectIdParam, + querystring: zPagePerfQuery, + }, + handler: query.getPagePerformance, + }); + + // --------------------------------------------------------------------------- + // Analytics — metrics overview (legacy insights routes) + // --------------------------------------------------------------------------- + fastify.route({ method: 'GET', url: '/:projectId/metrics', - handler: controller.getMetrics, + schema: { + tags: TAGS, + description: 'Get aggregated website metrics including sessions, pageviews, and bounce rate.', + params: projectIdParam, + querystring: zGetMetricsQuery, + }, + handler: insights.getMetrics, }); - // Live visitors (real-time) fastify.route({ method: 'GET', url: '/:projectId/live', - handler: controller.getLiveVisitors, + schema: { + tags: TAGS, + description: 'Get the current number of live (active) visitors.', + params: projectIdParam, + }, + handler: insights.getLiveVisitors, }); - // Page views with top pages fastify.route({ method: 'GET', url: '/:projectId/pages', - handler: controller.getPages, - }); - - const overviewMetrics = [ - 'referrer_name', - 'referrer', - 'referrer_type', - 'utm_source', - 'utm_medium', - 'utm_campaign', - 'utm_term', - 'utm_content', - 'device', - 'browser', - 'browser_version', - 'os', - 'os_version', - 'brand', - 'model', - 'country', - 'region', - 'city', - ] as const; - - overviewMetrics.forEach((key) => { + schema: { + tags: TAGS, + description: 'Get top pages with pageview counts for the selected date range.', + params: projectIdParam, + querystring: zGetTopPagesQuery, + }, + handler: insights.getPages, + }); + + for (const column of overviewColumns) { fastify.route({ method: 'GET', - url: `/:projectId/${key}`, - handler: controller.getOverviewGeneric(key), + url: `/:projectId/${column}`, + schema: { + tags: TAGS, + description: `Get top values for the "${column}" dimension.`, + params: projectIdParam, + querystring: zOverviewGenericQuerystring, + }, + handler: insights.getOverviewGeneric(column), }); + } + + // --------------------------------------------------------------------------- + // Analytics — funnel + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/funnel', + schema: { + tags: TAGS, + description: 'Get funnel conversion rates for a sequence of events.', + params: projectIdParam, + querystring: zFunnelQuery, + }, + handler: query.getFunnel, + }); + + // --------------------------------------------------------------------------- + // Analytics — traffic + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/traffic/referrers', + schema: { + tags: TAGS, + description: 'Get traffic breakdown by referrer source.', + params: projectIdParam, + querystring: zReferrerQuery, + }, + handler: query.getTrafficReferrers, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/traffic/geo', + schema: { + tags: TAGS, + description: 'Get traffic breakdown by geographic dimension (country, region, city).', + params: projectIdParam, + querystring: zGeoQuery, + }, + handler: query.getTrafficGeo, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/traffic/devices', + schema: { + tags: TAGS, + description: 'Get traffic breakdown by device type, browser, or OS.', + params: projectIdParam, + querystring: zDeviceQuery, + }, + handler: query.getTrafficDevices, + }); + + // --------------------------------------------------------------------------- + // Analytics — user flow & engagement + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/user_flow', + schema: { + tags: TAGS, + description: 'Get user flow paths before, after, or between specified events.', + params: projectIdParam, + querystring: zUserFlowQuery, + }, + handler: query.getUserFlow, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/engagement', + schema: { + tags: TAGS, + description: 'Get engagement metrics for the project.', + params: projectIdParam, + }, + handler: query.getEngagement, + }); + + // --------------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/events', + schema: { + tags: TAGS, + description: 'Query events with optional filters for date range, profile, and properties.', + params: projectIdParam, + querystring: zEventsQuery, + }, + handler: query.queryEvents, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/events/names', + schema: { + tags: TAGS, + description: 'List all distinct event names tracked in the project.', + params: projectIdParam, + }, + handler: query.listEventNames, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/events/properties', + schema: { + tags: TAGS, + description: 'List all property keys for a given event name.', + params: projectIdParam, + querystring: zEventPropertiesQuery, + }, + handler: query.listEventProperties, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/events/property_values', + schema: { + tags: TAGS, + description: 'Get the top values for a specific event property key.', + params: projectIdParam, + querystring: zPropertyValuesQuery, + }, + handler: query.getEventPropertyValues, + }); + + // --------------------------------------------------------------------------- + // Profiles + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles', + schema: { + tags: TAGS, + description: 'Search and filter user profiles.', + params: projectIdParam, + querystring: zProfilesQuery, + }, + handler: query.findProfiles, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles/:profileId', + schema: { + tags: TAGS, + description: 'Get a single user profile with their recent events.', + params: profileParam, + querystring: zGetProfileQuery, + }, + handler: query.getProfile, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles/:profileId/sessions', + schema: { + tags: TAGS, + description: 'Get sessions for a specific user profile.', + params: profileParam, + querystring: zProfileSessionsQuery, + }, + handler: query.getProfileSessions, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/profiles/:profileId/metrics', + schema: { + tags: TAGS, + description: 'Get aggregated metrics for a specific user profile.', + params: profileParam, + }, + handler: query.getProfileMetrics, + }); + + // --------------------------------------------------------------------------- + // Sessions + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/sessions', + schema: { + tags: TAGS, + description: 'Query sessions with optional filters.', + params: projectIdParam, + querystring: zSessionsQuery, + }, + handler: query.querySessions, + }); + + // --------------------------------------------------------------------------- + // Groups + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/groups/types', + schema: { + tags: TAGS, + description: 'List all group types defined in the project.', + params: projectIdParam, + }, + handler: query.listGroupTypes, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/groups', + schema: { + tags: TAGS, + description: 'Search and filter groups.', + params: projectIdParam, + querystring: zGroupsQuery, + }, + handler: query.findGroups, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/groups/:groupId', + schema: { + tags: TAGS, + description: 'Get a single group with its members.', + params: groupParam, + querystring: zGetGroupQuery, + }, + handler: query.getGroup, + }); + + // --------------------------------------------------------------------------- + // Dashboards & reports + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/reports/:reportId/data', + schema: { + tags: TAGS, + description: 'Get the data for a saved report.', + params: reportParam, + }, + handler: query.getReportData, + }); + + // --------------------------------------------------------------------------- + // Google Search Console + // --------------------------------------------------------------------------- + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/overview', + schema: { + tags: TAGS, + description: 'Get a Google Search Console performance overview (clicks, impressions, CTR, position).', + params: projectIdParam, + querystring: zGscOverviewQuery, + }, + handler: query.gscOverview, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/pages', + schema: { + tags: TAGS, + description: 'Get top pages from Google Search Console.', + params: projectIdParam, + querystring: zGscLimitQuery, + }, + handler: query.gscPages, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/pages/details', + schema: { + tags: TAGS, + description: 'Get detailed GSC metrics for a specific page URL.', + params: projectIdParam, + querystring: zGscPageDetailsQuery, + }, + handler: query.gscPageDetails, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/queries', + schema: { + tags: TAGS, + description: 'Get top search queries from Google Search Console.', + params: projectIdParam, + querystring: zGscLimitQuery, + }, + handler: query.gscQueries, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/queries/details', + schema: { + tags: TAGS, + description: 'Get detailed GSC metrics for a specific search query.', + params: projectIdParam, + querystring: zGscQueryDetailsQuery, + }, + handler: query.gscQueryDetails, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/queries/opportunities', + schema: { + tags: TAGS, + description: 'Get GSC query opportunities (high impressions, low CTR).', + params: projectIdParam, + querystring: zGscOpportunitiesQuery, + }, + handler: query.gscQueryOpportunities, + }); + + fastify.route({ + method: 'GET', + url: '/:projectId/gsc/cannibalization', + schema: { + tags: TAGS, + description: 'Detect keyword cannibalization across pages in Google Search Console.', + params: projectIdParam, + querystring: zDateRange, + }, + handler: query.gscCannibalization, }); }; diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts index 2e441413c..895dae51d 100644 --- a/apps/api/src/routes/manage.router.ts +++ b/apps/api/src/routes/manage.router.ts @@ -3,6 +3,7 @@ import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; import { z } from 'zod'; import * as controller from '@/controllers/manage.controller'; +import { listDashboards, listReports } from '@/controllers/query.controller'; import { zCreateClient, zCreateProject, @@ -51,35 +52,35 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.route({ method: 'GET', url: '/projects', - schema: { tags: ['manage'] }, + schema: { tags: ['manage'], description: 'List all projects for the organization.' }, handler: controller.listProjects, }); fastify.route({ method: 'GET', url: '/projects/:id', - schema: { params: idParam, tags: ['manage'] }, + schema: { params: idParam, tags: ['manage'], description: 'Get a single project by ID.' }, handler: controller.getProject, }); fastify.route({ method: 'POST', url: '/projects', - schema: { body: zCreateProject, tags: ['manage'] }, + schema: { body: zCreateProject, tags: ['manage'], description: 'Create a new project and its first write client.' }, handler: controller.createProject, }); fastify.route({ method: 'PATCH', url: '/projects/:id', - schema: { params: idParam, body: zUpdateProject, tags: ['manage'] }, + schema: { params: idParam, body: zUpdateProject, tags: ['manage'], description: 'Update project settings (name, domain, CORS, tracking options).' }, handler: controller.updateProject, }); fastify.route({ method: 'DELETE', url: '/projects/:id', - schema: { params: idParam, tags: ['manage'] }, + schema: { params: idParam, tags: ['manage'], description: 'Soft-delete a project (scheduled for removal in 24 hours).' }, handler: controller.deleteProject, }); @@ -87,35 +88,35 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.route({ method: 'GET', url: '/clients', - schema: { tags: ['manage'] }, + schema: { tags: ['manage'], description: 'List all API clients for the organization, optionally filtered by project.' }, handler: controller.listClients, }); fastify.route({ method: 'GET', url: '/clients/:id', - schema: { params: idParam, tags: ['manage'] }, + schema: { params: idParam, tags: ['manage'], description: 'Get a single API client by ID.' }, handler: controller.getClient, }); fastify.route({ method: 'POST', url: '/clients', - schema: { body: zCreateClient, tags: ['manage'] }, + schema: { body: zCreateClient, tags: ['manage'], description: 'Create a new API client (read, write, or root type) and return its generated secret.' }, handler: controller.createClient, }); fastify.route({ method: 'PATCH', url: '/clients/:id', - schema: { params: idParam, body: zUpdateClient, tags: ['manage'] }, + schema: { params: idParam, body: zUpdateClient, tags: ['manage'], description: 'Update an API client name.' }, handler: controller.updateClient, }); fastify.route({ method: 'DELETE', url: '/clients/:id', - schema: { params: idParam, tags: ['manage'] }, + schema: { params: idParam, tags: ['manage'], description: 'Delete an API client.' }, handler: controller.deleteClient, }); @@ -123,14 +124,14 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.route({ method: 'GET', url: '/references', - schema: { tags: ['manage'] }, + schema: { tags: ['manage'], description: 'List annotation references for a project.' }, handler: controller.listReferences, }); fastify.route({ method: 'GET', url: '/references/:id', - schema: { params: idParam, tags: ['manage'] }, + schema: { params: idParam, tags: ['manage'], description: 'Get a single annotation reference by ID.' }, handler: controller.getReference, }); @@ -154,6 +155,29 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { schema: { params: idParam, tags: ['manage'] }, handler: controller.deleteReference, }); + + // Dashboards & reports + fastify.route({ + method: 'GET', + url: '/projects/:projectId/dashboards', + schema: { + params: z.object({ projectId: z.string() }), + tags: ['manage'], + description: 'List all dashboards for a project.', + }, + handler: listDashboards, + }); + + fastify.route({ + method: 'GET', + url: '/projects/:projectId/dashboards/:dashboardId/reports', + schema: { + params: z.object({ projectId: z.string(), dashboardId: z.string() }), + tags: ['manage'], + description: 'List all reports in a dashboard.', + }, + handler: listReports, + }); }; export default manageRouter; diff --git a/apps/api/src/routes/profile.router.ts b/apps/api/src/routes/profile.router.ts index 990f8b10f..8060f6975 100644 --- a/apps/api/src/routes/profile.router.ts +++ b/apps/api/src/routes/profile.router.ts @@ -1,29 +1,41 @@ +import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; import * as controller from '@/controllers/profile.controller'; import { clientHook } from '@/hooks/client.hook'; import { isBotHook } from '@/hooks/is-bot.hook'; -import type { FastifyPluginCallback } from 'fastify'; -const eventRouter: FastifyPluginCallback = async (fastify) => { +const profileRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); fastify.route({ method: 'POST', url: '/', + schema: { + tags: ['ingestion'], + description: 'Identify or update a user profile.', + }, handler: controller.updateProfile, }); fastify.route({ method: 'POST', url: '/increment', + schema: { + tags: ['ingestion'], + description: 'Increment a numeric property on a user profile.', + }, handler: controller.incrementProfileProperty, }); fastify.route({ method: 'POST', url: '/decrement', + schema: { + tags: ['ingestion'], + description: 'Decrement a numeric property on a user profile.', + }, handler: controller.decrementProfileProperty, }); }; -export default eventRouter; +export default profileRouter; diff --git a/apps/api/src/routes/query.router.ts b/apps/api/src/routes/query.router.ts deleted file mode 100644 index 3b3193d05..000000000 --- a/apps/api/src/routes/query.router.ts +++ /dev/null @@ -1,85 +0,0 @@ -import * as controller from '@/controllers/query.controller'; -import { validateExportRequest } from '@/utils/auth'; -import { activateRateLimiter } from '@/utils/rate-limiter'; -import { Prisma } from '@openpanel/db'; -import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; - -const queryRouter: FastifyPluginCallback = async (fastify) => { - await activateRateLimiter({ - fastify, - max: 60, - timeWindow: '10 seconds', - }); - - fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { - try { - const client = await validateExportRequest(req.headers); - req.client = client; - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - return reply.status(401).send({ - error: 'Unauthorized', - message: 'Client ID seems to be malformed', - }); - } - if (e instanceof Error) { - return reply.status(401).send({ error: 'Unauthorized', message: e.message }); - } - return reply.status(401).send({ error: 'Unauthorized', message: 'Unexpected error' }); - } - }); - - // Projects - fastify.get('/projects', controller.listProjects); - - // Analytics - fastify.get('/:projectId/overview', controller.getOverview); - fastify.get('/:projectId/active-users', controller.getActiveUsers); - fastify.get('/:projectId/retention', controller.getRetentionSeries); - fastify.get('/:projectId/retention/cohort', controller.getRetentionCohort); - fastify.get('/:projectId/pages/top', controller.getTopPages); - fastify.get('/:projectId/pages/entry-exit', controller.getEntryExitPages); - fastify.get('/:projectId/pages/performance', controller.getPagePerformance); - fastify.get('/:projectId/funnel', controller.getFunnel); - fastify.get('/:projectId/traffic/referrers', controller.getTrafficReferrers); - fastify.get('/:projectId/traffic/geo', controller.getTrafficGeo); - fastify.get('/:projectId/traffic/devices', controller.getTrafficDevices); - fastify.get('/:projectId/user-flow', controller.getUserFlow); - fastify.get('/:projectId/engagement', controller.getEngagement); - - // Events - fastify.get('/:projectId/events', controller.queryEvents); - fastify.get('/:projectId/events/names', controller.listEventNames); - fastify.get('/:projectId/events/properties', controller.listEventProperties); - fastify.get('/:projectId/events/property-values', controller.getEventPropertyValues); - - // Profiles - fastify.get('/:projectId/profiles', controller.findProfiles); - fastify.get('/:projectId/profiles/:profileId', controller.getProfile); - fastify.get('/:projectId/profiles/:profileId/sessions', controller.getProfileSessions); - fastify.get('/:projectId/profiles/:profileId/metrics', controller.getProfileMetrics); - - // Sessions - fastify.get('/:projectId/sessions', controller.querySessions); - - // Groups - fastify.get('/:projectId/groups/types', controller.listGroupTypes); - fastify.get('/:projectId/groups', controller.findGroups); - fastify.get('/:projectId/groups/:groupId', controller.getGroup); - - // Dashboards & reports - fastify.get('/:projectId/dashboards', controller.listDashboards); - fastify.get('/:projectId/dashboards/:dashboardId/reports', controller.listReports); - fastify.get('/:projectId/reports/:reportId/data', controller.getReportData); - - // Google Search Console - fastify.get('/:projectId/gsc/overview', controller.gscOverview); - fastify.get('/:projectId/gsc/pages', controller.gscPages); - fastify.get('/:projectId/gsc/pages/details', controller.gscPageDetails); - fastify.get('/:projectId/gsc/queries', controller.gscQueries); - fastify.get('/:projectId/gsc/queries/details', controller.gscQueryDetails); - fastify.get('/:projectId/gsc/queries/opportunities', controller.gscQueryOpportunities); - fastify.get('/:projectId/gsc/cannibalization', controller.gscCannibalization); -}; - -export default queryRouter; diff --git a/apps/api/src/routes/track.router.ts b/apps/api/src/routes/track.router.ts index a50e354cd..19a788805 100644 --- a/apps/api/src/routes/track.router.ts +++ b/apps/api/src/routes/track.router.ts @@ -16,7 +16,8 @@ const trackRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { url: '/', schema: { body: zTrackHandlerPayload, - tags: ['track'], + tags: ['ingestion'], + description: 'Ingest a tracking event (track, identify, group, increment, decrement, replay).', }, handler, }); @@ -25,7 +26,8 @@ const trackRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { method: 'GET', url: '/device-id', schema: { - tags: ['track'], + tags: ['ingestion'], + description: 'Get or generate a stable device ID and session ID for the current visitor.', response: { 200: z.object({ deviceId: z.string(), From 3b43f44ce138b7b2a2c39fefe56b47de24831732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 15:05:38 +0200 Subject: [PATCH 08/18] dates --- apps/api/src/controllers/query.controller.ts | 404 +++++++++++++----- packages/db/index.ts | 2 + packages/db/src/engine/index.ts | 6 +- packages/db/src/engine/normalize.ts | 2 +- packages/db/src/services/chart.service.ts | 239 +---------- packages/db/src/services/dashboard.service.ts | 11 + packages/db/src/services/date.service.ts | 245 +++++++++++ packages/db/src/services/event.service.ts | 152 +++++++ packages/db/src/services/funnel.service.ts | 75 ++++ packages/db/src/services/group.service.ts | 44 ++ packages/db/src/services/gsc.service.ts | 194 +++++++++ packages/db/src/services/overview.service.ts | 64 +++ packages/db/src/services/pages.service.ts | 80 ++++ packages/db/src/services/profile.service.ts | 168 ++++++++ packages/db/src/services/project.service.ts | 41 ++ packages/db/src/services/reports.service.ts | 70 +++ packages/db/src/services/retention.service.ts | 60 +++ packages/db/src/services/sankey.service.ts | 53 +++ packages/db/src/services/session.service.ts | 73 ++++ packages/mcp/index.ts | 69 --- .../mcp/src/tools/analytics/active-users.ts | 30 +- .../mcp/src/tools/analytics/engagement.ts | 35 +- .../mcp/src/tools/analytics/event-names.ts | 24 +- packages/mcp/src/tools/analytics/events.ts | 92 +--- packages/mcp/src/tools/analytics/funnel.ts | 80 +--- packages/mcp/src/tools/analytics/groups.ts | 53 +-- packages/mcp/src/tools/analytics/overview.ts | 41 +- .../src/tools/analytics/page-performance.ts | 44 +- packages/mcp/src/tools/analytics/pages.ts | 43 +- .../src/tools/analytics/profile-metrics.ts | 28 +- packages/mcp/src/tools/analytics/profiles.ts | 151 +------ .../src/tools/analytics/property-values.ts | 46 +- packages/mcp/src/tools/analytics/reports.ts | 98 +---- packages/mcp/src/tools/analytics/retention.ts | 6 +- packages/mcp/src/tools/analytics/sessions.ts | 77 +--- packages/mcp/src/tools/analytics/traffic.ts | 56 +-- packages/mcp/src/tools/analytics/user-flow.ts | 86 +--- packages/mcp/src/tools/gsc/cannibalization.ts | 20 +- packages/mcp/src/tools/gsc/overview.ts | 55 +-- packages/mcp/src/tools/gsc/pages.ts | 30 +- packages/mcp/src/tools/gsc/queries.ts | 73 +--- packages/mcp/src/tools/projects.ts | 45 +- 42 files changed, 1711 insertions(+), 1554 deletions(-) create mode 100644 packages/db/src/services/date.service.ts create mode 100644 packages/db/src/services/gsc.service.ts diff --git a/apps/api/src/controllers/query.controller.ts b/apps/api/src/controllers/query.controller.ts index 95951119d..ac3395e02 100644 --- a/apps/api/src/controllers/query.controller.ts +++ b/apps/api/src/controllers/query.controller.ts @@ -1,6 +1,10 @@ +import type { IServiceClientWithProject } from '@openpanel/db'; import { + ClientType, findGroupsCore, findProfilesCore, + getAnalyticsOverviewCore, + getChartStartEndDate, getEngagementCore, getEntryExitPagesCore, getEventPropertyValuesCore, @@ -8,11 +12,12 @@ import { getGroupCore, getPagePerformanceCore, getProfileMetricsCore, - getProfileWithEvents, getProfileSessionsCore, + getProfileWithEvents, getReportDataCore, getRetentionCohortCore, getRollingActiveUsersCore, + getSettingsForProject, getTopPagesCore, getTrafficBreakdownCore, getUserFlowCore, @@ -30,14 +35,11 @@ import { listGroupTypesCore, listProjectsCore, listReportsCore, - getAnalyticsOverviewCore, queryEventsCore, querySessionsCore, resolveDateRange, type TrafficColumn, -} from '@openpanel/mcp'; -import { ClientType, getChartStartEndDate, getSettingsForProject } from '@openpanel/db'; -import type { IServiceClientWithProject } from '@openpanel/db'; +} from '@openpanel/db'; import { zRange } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; @@ -48,7 +50,7 @@ import { z } from 'zod'; function resolveQueryProjectId( client: IServiceClientWithProject, - urlProjectId: string | undefined, + urlProjectId: string | undefined ): string { if (client.type === ClientType.root) { if (!urlProjectId) { @@ -75,7 +77,7 @@ type DateRangeInput = z.infer; async function resolveDates( projectId: string, - data: DateRangeInput, + data: DateRangeInput ): Promise<{ startDate: string; endDate: string }> { // Explicit dates always win — range is only a shorthand when no startDate is given if (!data.range || data.startDate) { @@ -84,7 +86,10 @@ async function resolveDates( const { timezone } = await getSettingsForProject(projectId); // data.range is guaranteed non-nullish here (checked above); cast to satisfy // getChartStartEndDate which expects a non-optional range with a default value. - return getChartStartEndDate({ startDate: data.startDate, endDate: data.endDate, range: data.range! }, timezone); + return getChartStartEndDate( + { startDate: data.startDate, endDate: data.endDate, range: data.range! }, + timezone + ); } type RequestWithProjectParam = FastifyRequest<{ @@ -99,9 +104,7 @@ function getOrgId(req: RequestWithProjectParam): string { return req.client!.organizationId; } -function getClientType( - req: RequestWithProjectParam, -): 'root' | 'read' { +function getClientType(req: RequestWithProjectParam): 'root' | 'read' { return req.client!.type === ClientType.root ? 'root' : 'read'; } @@ -109,17 +112,14 @@ function getClientType( // Projects // --------------------------------------------------------------------------- -export async function listProjects( - req: FastifyRequest, - reply: FastifyReply, -) { +export async function listProjects(req: FastifyRequest, reply: FastifyReply) { const client = req.client!; return reply.send( await listProjectsCore({ clientType: getClientType(req as RequestWithProjectParam), organizationId: client.organizationId, projectId: client.projectId ?? null, - }), + }) ); } @@ -132,12 +132,22 @@ export const zOverviewQuery = zDateRange.extend({ }); export async function getOverview( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await getAnalyticsOverviewCore({ projectId, startDate, endDate, interval: req.query.interval })); + return reply.send( + await getAnalyticsOverviewCore({ + projectId, + startDate, + endDate, + interval: req.query.interval, + }) + ); } // --------------------------------------------------------------------------- @@ -149,11 +159,16 @@ export const zActiveUsersQuery = z.object({ }); export async function getActiveUsers( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await getRollingActiveUsersCore({ projectId, days: req.query.days })); + return reply.send( + await getRollingActiveUsersCore({ projectId, days: req.query.days }) + ); } // --------------------------------------------------------------------------- @@ -162,7 +177,7 @@ export async function getActiveUsers( export async function getRetentionSeries( req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await getWeeklyRetentionSeriesCore(projectId)); @@ -174,7 +189,7 @@ export async function getRetentionSeries( export async function getRetentionCohort( req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await getRetentionCohortCore(projectId)); @@ -185,8 +200,11 @@ export async function getRetentionCohort( // --------------------------------------------------------------------------- export async function getTopPages( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); @@ -202,12 +220,22 @@ export const zEntryExitQuery = zDateRange.extend({ }); export async function getEntryExitPages( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await getEntryExitPagesCore({ projectId, startDate, endDate, mode: req.query.mode })); + return reply.send( + await getEntryExitPagesCore({ + projectId, + startDate, + endDate, + mode: req.query.mode, + }) + ); } // --------------------------------------------------------------------------- @@ -216,19 +244,29 @@ export async function getEntryExitPages( export const zPagePerfQuery = zDateRange.extend({ search: z.string().optional(), - sortBy: z.enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']).optional(), + sortBy: z + .enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']) + .optional(), sortOrder: z.enum(['asc', 'desc']).optional(), limit: z.number().int().min(1).max(500).default(50), }); export async function getPagePerformance( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getPagePerformanceCore({ projectId, startDate, endDate, ...req.query }), + await getPagePerformanceCore({ + projectId, + startDate, + endDate, + ...req.query, + }) ); } @@ -247,8 +285,11 @@ export const zFunnelQuery = zDateRange.extend({ }); export async function getFunnel( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); @@ -260,7 +301,7 @@ export async function getFunnel( steps: req.query.steps, windowHours: req.query.windowHours, groupBy: req.query.groupBy, - }), + }) ); } @@ -268,7 +309,14 @@ export async function getFunnel( // Analytics — traffic (referrers / geo / devices) // --------------------------------------------------------------------------- -const referrerColumns = ['referrer_name', 'referrer_type', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'] as const; +const referrerColumns = [ + 'referrer_name', + 'referrer_type', + 'referrer', + 'utm_source', + 'utm_medium', + 'utm_campaign', +] as const; const geoColumns = ['country', 'region', 'city'] as const; const deviceColumns = ['device', 'browser', 'os'] as const; @@ -285,35 +333,59 @@ export const zDeviceQuery = zDateRange.extend({ }); export async function getTrafficReferrers( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn }), + await getTrafficBreakdownCore({ + projectId, + startDate, + endDate, + column: req.query.breakdown as TrafficColumn, + }) ); } export async function getTrafficGeo( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn }), + await getTrafficBreakdownCore({ + projectId, + startDate, + endDate, + column: req.query.breakdown as TrafficColumn, + }) ); } export async function getTrafficDevices( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn }), + await getTrafficBreakdownCore({ + projectId, + startDate, + endDate, + column: req.query.breakdown as TrafficColumn, + }) ); } @@ -335,13 +407,16 @@ export const zUserFlowQuery = zDateRange.extend({ }); export async function getUserFlow( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await getUserFlowCore({ projectId, startDate, endDate, ...req.query }), + await getUserFlowCore({ projectId, startDate, endDate, ...req.query }) ); } @@ -351,7 +426,7 @@ export async function getUserFlow( export async function getEngagement( req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await getEngagementCore(projectId)); @@ -380,8 +455,11 @@ export const zEventsQuery = zDateRange.extend({ }); export async function queryEvents( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await queryEventsCore({ projectId, ...req.query })); @@ -389,7 +467,7 @@ export async function queryEvents( export async function listEventNames( req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await listEventNamesCore(projectId)); @@ -400,11 +478,16 @@ export const zEventPropertiesQuery = z.object({ }); export async function listEventProperties( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await listEventPropertiesCore({ projectId, eventName: req.query.eventName })); + return reply.send( + await listEventPropertiesCore({ projectId, eventName: req.query.eventName }) + ); } export const zPropertyValuesQuery = z.object({ @@ -413,11 +496,16 @@ export const zPropertyValuesQuery = z.object({ }); export async function getEventPropertyValues( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await getEventPropertyValuesCore({ projectId, ...req.query })); + return reply.send( + await getEventPropertyValuesCore({ projectId, ...req.query }) + ); } // --------------------------------------------------------------------------- @@ -439,8 +527,11 @@ export const zProfilesQuery = z.object({ }); export async function findProfiles( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await findProfilesCore({ projectId, ...req.query })); @@ -451,15 +542,27 @@ export const zGetProfileQuery = z.object({ }); export async function getProfile( - req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string; profileId: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); - const result = await getProfileWithEvents(projectId, req.params.profileId, req.query.eventLimit); + const result = await getProfileWithEvents( + projectId, + req.params.profileId, + req.query.eventLimit + ); if (!result.profile) { - return reply.status(404).send({ error: 'Profile not found', profileId: req.params.profileId }); + return reply + .status(404) + .send({ error: 'Profile not found', profileId: req.params.profileId }); } - return reply.send({ profile: result.profile, recentEvents: result.recent_events }); + return reply.send({ + profile: result.profile, + recentEvents: result.recent_events, + }); } export const zProfileSessionsQuery = z.object({ @@ -467,20 +570,29 @@ export const zProfileSessionsQuery = z.object({ }); export async function getProfileSessions( - req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string; profileId: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await getProfileSessionsCore(projectId, req.params.profileId, req.query.limit)); + return reply.send( + await getProfileSessionsCore( + projectId, + req.params.profileId, + req.query.limit + ) + ); } export async function getProfileMetrics( req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send( - await getProfileMetricsCore({ projectId, profileId: req.params.profileId }), + await getProfileMetricsCore({ projectId, profileId: req.params.profileId }) ); } @@ -502,8 +614,11 @@ export const zSessionsQuery = zDateRange.extend({ }); export async function querySessions( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await querySessionsCore({ projectId, ...req.query })); @@ -515,7 +630,7 @@ export async function querySessions( export async function listGroupTypes( req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await listGroupTypesCore(projectId)); @@ -528,8 +643,11 @@ export const zGroupsQuery = z.object({ }); export async function findGroups( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send(await findGroupsCore({ projectId, ...req.query })); @@ -540,12 +658,19 @@ export const zGetGroupQuery = z.object({ }); export async function getGroup( - req: FastifyRequest<{ Params: { projectId?: string; groupId: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string; groupId: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); return reply.send( - await getGroupCore({ projectId, groupId: req.params.groupId, memberLimit: req.query.memberLimit }), + await getGroupCore({ + projectId, + groupId: req.params.groupId, + memberLimit: req.query.memberLimit, + }) ); } @@ -555,7 +680,7 @@ export async function getGroup( export async function listDashboards( req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const organizationId = getOrgId(req as RequestWithProjectParam); @@ -564,23 +689,31 @@ export async function listDashboards( export async function listReports( req: FastifyRequest<{ Params: { projectId?: string; dashboardId: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const organizationId = getOrgId(req as RequestWithProjectParam); return reply.send( - await listReportsCore({ projectId, dashboardId: req.params.dashboardId, organizationId }), + await listReportsCore({ + projectId, + dashboardId: req.params.dashboardId, + organizationId, + }) ); } export async function getReportData( req: FastifyRequest<{ Params: { projectId?: string; reportId: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const organizationId = getOrgId(req as RequestWithProjectParam); return reply.send( - await getReportDataCore({ projectId, reportId: req.params.reportId, organizationId }), + await getReportDataCore({ + projectId, + reportId: req.params.reportId, + organizationId, + }) ); } @@ -593,12 +726,22 @@ export const zGscOverviewQuery = zDateRange.extend({ }); export async function gscOverview( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: req.query.interval })); + return reply.send( + await gscGetOverviewCore({ + projectId, + startDate, + endDate, + interval: req.query.interval, + }) + ); } export const zGscLimitQuery = zDateRange.extend({ @@ -606,12 +749,22 @@ export const zGscLimitQuery = zDateRange.extend({ }); export async function gscPages( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: req.query.limit })); + return reply.send( + await gscGetTopPagesCore({ + projectId, + startDate, + endDate, + limit: req.query.limit, + }) + ); } export const zGscPageDetailsQuery = zDateRange.extend({ @@ -619,21 +772,41 @@ export const zGscPageDetailsQuery = zDateRange.extend({ }); export async function gscPageDetails( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: req.query.page })); + return reply.send( + await gscGetPageDetailsCore({ + projectId, + startDate, + endDate, + page: req.query.page, + }) + ); } export async function gscQueries( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: req.query.limit })); + return reply.send( + await gscGetTopQueriesCore({ + projectId, + startDate, + endDate, + limit: req.query.limit, + }) + ); } export const zGscQueryDetailsQuery = zDateRange.extend({ @@ -641,13 +814,21 @@ export const zGscQueryDetailsQuery = zDateRange.extend({ }); export async function gscQueryDetails( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: req.query.query }), + await gscGetQueryDetailsCore({ + projectId, + startDate, + endDate, + query: req.query.query, + }) ); } @@ -656,21 +837,34 @@ export const zGscOpportunitiesQuery = zDateRange.extend({ }); export async function gscQueryOpportunities( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); return reply.send( - await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: req.query.minImpressions }), + await gscGetQueryOpportunitiesCore({ + projectId, + startDate, + endDate, + minImpressions: req.query.minImpressions, + }) ); } export async function gscCannibalization( - req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, - reply: FastifyReply, + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply ) { const projectId = getProjectId(req as RequestWithProjectParam); const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await gscGetCannibalizationCore({ projectId, startDate, endDate })); + return reply.send( + await gscGetCannibalizationCore({ projectId, startDate, endDate }) + ); } diff --git a/packages/db/index.ts b/packages/db/index.ts index 2eb494b80..571233fce 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -11,10 +11,12 @@ export * from './src/services/chart.service'; export * from './src/services/clients.service'; export * from './src/services/conversion.service'; export * from './src/services/dashboard.service'; +export * from './src/services/date.service'; export * from './src/services/delete.service'; export * from './src/services/event.service'; export * from './src/services/funnel.service'; export * from './src/services/group.service'; +export * from './src/services/gsc.service'; export * from './src/services/id.service'; export * from './src/services/import.service'; export * from './src/services/insights'; diff --git a/packages/db/src/engine/index.ts b/packages/db/src/engine/index.ts index 0e5d72add..b6da4fd4d 100644 --- a/packages/db/src/engine/index.ts +++ b/packages/db/src/engine/index.ts @@ -7,10 +7,8 @@ import type { IReportInput, } from '@openpanel/validation'; import { chQuery } from '../clickhouse/client'; -import { - getAggregateChartSql, - getChartPrevStartEndDate, -} from '../services/chart.service'; +import { getAggregateChartSql } from '../services/chart.service'; +import { getChartPrevStartEndDate } from '../services/date.service'; import { getOrganizationSubscriptionChartEndDate, getSettingsForProject, diff --git a/packages/db/src/engine/normalize.ts b/packages/db/src/engine/normalize.ts index f77818ae1..94fffac70 100644 --- a/packages/db/src/engine/normalize.ts +++ b/packages/db/src/engine/normalize.ts @@ -5,7 +5,7 @@ import type { IReportInput, IReportInputWithDates, } from '@openpanel/validation'; -import { getChartStartEndDate } from '../services/chart.service'; +import { getChartStartEndDate } from '../services/date.service'; import { getSettingsForProject } from '../services/organization.service'; import type { SeriesDefinition } from './types'; diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 4083cee07..1f9291d5b 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -1,8 +1,7 @@ /** biome-ignore-all lint/style/useDefaultSwitchClause: switch cases are exhaustive by design */ -import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common'; +import { stripLeadingAndTrailingSlashes } from '@openpanel/common'; import type { IChartEventFilter, - IChartRange, IGetChartDataInput, IReportInput, } from '@openpanel/validation'; @@ -1106,239 +1105,3 @@ export function getEventFiltersWhereClause( return where; } - -export function getChartStartEndDate( - { - startDate, - endDate, - range, - }: Pick, - timezone: string -) { - if (startDate && endDate) { - return { startDate, endDate }; - } - - const ranges = getDatesFromRange(range, timezone); - if (!startDate && endDate) { - return { startDate: ranges.startDate, endDate }; - } - - return ranges; -} - -export function getDatesFromRange(range: IChartRange, timezone: string) { - if (range === '30min' || range === 'lastHour') { - const minutes = range === '30min' ? 30 : 60; - const startDate = DateTime.now() - .minus({ minute: minutes }) - .startOf('minute') - .setZone(timezone) - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('minute') - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'today') { - const startDate = DateTime.now() - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'yesterday') { - const startDate = DateTime.now() - .minus({ day: 1 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .minus({ day: 1 }) - .setZone(timezone) - .endOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - return { - startDate, - endDate, - }; - } - - if (range === '7d') { - const startDate = DateTime.now() - .minus({ day: 7 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === '6m') { - const startDate = DateTime.now() - .minus({ month: 6 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === '12m') { - const startDate = DateTime.now() - .minus({ month: 12 }) - .setZone(timezone) - .startOf('month') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('month') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'monthToDate') { - const startDate = DateTime.now() - .setZone(timezone) - .startOf('month') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'lastMonth') { - const month = DateTime.now() - .minus({ month: 1 }) - .setZone(timezone) - .startOf('month'); - - const startDate = month.toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = month - .endOf('month') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'yearToDate') { - const startDate = DateTime.now() - .setZone(timezone) - .startOf('year') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - if (range === 'lastYear') { - const year = DateTime.now().minus({ year: 1 }).setZone(timezone); - const startDate = year.startOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; - } - - // range === '30d' - const startDate = DateTime.now() - .minus({ day: 30 }) - .setZone(timezone) - .startOf('day') - .toFormat('yyyy-MM-dd HH:mm:ss'); - const endDate = DateTime.now() - .setZone(timezone) - .endOf('day') - .plus({ millisecond: 1 }) - .toFormat('yyyy-MM-dd HH:mm:ss'); - - return { - startDate, - endDate, - }; -} - -export function getChartPrevStartEndDate({ - startDate, - endDate, -}: { - startDate: string; - endDate: string; -}) { - let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff( - DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') - ); - - // this will make sure our start and end date's are correct - // otherwise if a day ends with 23:59:59.999 and starts with 00:00:00.000 - // the diff will be 23:59:59.999 and that will make the start date wrong - // so we add 1 millisecond to the diff - if ((diff.milliseconds / 1000) % 2 !== 0) { - diff = diff.plus({ millisecond: 1 }); - } - - return { - startDate: DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') - .minus({ millisecond: diff.milliseconds }) - .toFormat('yyyy-MM-dd HH:mm:ss'), - endDate: DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss') - .minus({ millisecond: diff.milliseconds }) - .toFormat('yyyy-MM-dd HH:mm:ss'), - }; -} diff --git a/packages/db/src/services/dashboard.service.ts b/packages/db/src/services/dashboard.service.ts index 1cc070c46..d4d392afe 100644 --- a/packages/db/src/services/dashboard.service.ts +++ b/packages/db/src/services/dashboard.service.ts @@ -38,3 +38,14 @@ export function getDashboardsByProjectId(projectId: string) { }, }); } + +export async function listDashboardsCore(input: { + projectId: string; + organizationId: string; +}) { + return db.dashboard.findMany({ + where: { projectId: input.projectId }, + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, projectId: true }, + }); +} diff --git a/packages/db/src/services/date.service.ts b/packages/db/src/services/date.service.ts new file mode 100644 index 000000000..d10a8580b --- /dev/null +++ b/packages/db/src/services/date.service.ts @@ -0,0 +1,245 @@ +import { DateTime } from '@openpanel/common'; +import type { IChartRange, IReportInput } from '@openpanel/validation'; + +export function resolveDateRange( + startDate?: string, + endDate?: string +): { startDate: string; endDate: string } { + const end = endDate ?? new Date().toISOString().slice(0, 10); + const start = + startDate ?? + new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10); + return { startDate: start, endDate: end }; +} + +export function getDatesFromRange(range: IChartRange, timezone: string) { + if (range === '30min' || range === 'lastHour') { + const minutes = range === '30min' ? 30 : 60; + const startDate = DateTime.now() + .minus({ minute: minutes }) + .startOf('minute') + .setZone(timezone) + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('minute') + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'today') { + const startDate = DateTime.now() + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'yesterday') { + const startDate = DateTime.now() + .minus({ day: 1 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .minus({ day: 1 }) + .setZone(timezone) + .endOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + return { + startDate, + endDate, + }; + } + + if (range === '7d') { + const startDate = DateTime.now() + .minus({ day: 7 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === '6m') { + const startDate = DateTime.now() + .minus({ month: 6 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === '12m') { + const startDate = DateTime.now() + .minus({ month: 12 }) + .setZone(timezone) + .startOf('month') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('month') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'monthToDate') { + const startDate = DateTime.now() + .setZone(timezone) + .startOf('month') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'lastMonth') { + const month = DateTime.now() + .minus({ month: 1 }) + .setZone(timezone) + .startOf('month'); + + const startDate = month.toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = month + .endOf('month') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'yearToDate') { + const startDate = DateTime.now() + .setZone(timezone) + .startOf('year') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + if (range === 'lastYear') { + const year = DateTime.now().minus({ year: 1 }).setZone(timezone); + const startDate = year.startOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = year.endOf('year').toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; + } + + // range === '30d' + const startDate = DateTime.now() + .minus({ day: 30 }) + .setZone(timezone) + .startOf('day') + .toFormat('yyyy-MM-dd HH:mm:ss'); + const endDate = DateTime.now() + .setZone(timezone) + .endOf('day') + .plus({ millisecond: 1 }) + .toFormat('yyyy-MM-dd HH:mm:ss'); + + return { + startDate, + endDate, + }; +} + +export function getChartStartEndDate( + { + startDate, + endDate, + range, + }: Pick, + timezone: string +) { + if (startDate && endDate) { + return { startDate, endDate }; + } + + const ranges = getDatesFromRange(range, timezone); + if (!startDate && endDate) { + return { startDate: ranges.startDate, endDate }; + } + + return ranges; +} + +export function getChartPrevStartEndDate({ + startDate, + endDate, +}: { + startDate: string; + endDate: string; +}) { + let diff = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss').diff( + DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') + ); + + if ((diff.milliseconds / 1000) % 2 !== 0) { + diff = diff.plus({ millisecond: 1 }); + } + + return { + startDate: DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss') + .minus({ millisecond: diff.milliseconds }) + .toFormat('yyyy-MM-dd HH:mm:ss'), + endDate: DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss') + .minus({ millisecond: diff.milliseconds }) + .toFormat('yyyy-MM-dd HH:mm:ss'), + }; +} diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index 50d96b1c6..daa353941 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -1138,3 +1138,155 @@ class EventService { } export const eventService = new EventService(ch); + +import { getCache } from '@openpanel/redis'; +import { resolveDateRange } from './date.service'; + +export async function getTopEventNames(projectId: string): Promise { + return getCache(`mcp:event-names:${projectId}`, 60 * 10, async () => { + const rows = await clix(ch) + .select(['name', 'count() as count']) + .from(TABLE_NAMES.event_names_mv) + .where('project_id', '=', projectId) + .groupBy(['name']) + .orderBy('count', 'DESC') + .limit(50) + .execute(); + + return rows.map((r) => r.name); + }); +} + +export const listEventNamesCore = (projectId: string): Promise => + getTopEventNames(projectId); + +export async function listEventPropertiesCore(input: { + projectId: string; + eventName?: string; +}): Promise<{ properties: Array<{ property_key: string; event_name: string }> }> { + const builder = clix(ch) + .select<{ property_key: string; event_name: string }>([ + 'distinct property_key', + 'name as event_name', + ]) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', input.projectId) + .orderBy('property_key', 'ASC') + .limit(500); + + if (input.eventName) { + builder.where('name', '=', input.eventName); + } + + const rows = await builder.execute(); + return { properties: rows }; +} + +export async function getEventPropertyValuesCore(input: { + projectId: string; + eventName: string; + propertyKey: string; +}): Promise<{ event: string; property: string; values: string[] }> { + const rows = await clix(ch) + .select<{ value: string }>(['property_value as value']) + .from(TABLE_NAMES.event_property_values_mv) + .where('project_id', '=', input.projectId) + .where('name', '=', input.eventName) + .where('property_key', '=', input.propertyKey) + .orderBy('created_at', 'DESC') + .limit(200) + .execute(); + + return { + event: input.eventName, + property: input.propertyKey, + values: rows.map((r) => r.value), + }; +} + +export interface QueryEventsInput { + projectId: string; + startDate?: string; + endDate?: string; + eventNames?: string[]; + path?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + os?: string; + referrer?: string; + referrerName?: string; + referrerType?: string; + profileId?: string; + properties?: Record; + limit?: number; +} + +export async function queryEventsCore( + input: QueryEventsInput, +): Promise { + const builder = clix(ch) + .select([]) + .from(TABLE_NAMES.events) + .where('project_id', '=', input.projectId); + + if (input.profileId) { + builder.where('profile_id', '=', input.profileId); + } + + if (input.eventNames?.length) { + builder.where('name', 'IN', input.eventNames); + } + + if (input.path) { + builder.where('path', '=', input.path); + } + + if (input.referrer) { + builder.where('referrer', '=', input.referrer); + } + + if (input.referrerName) { + builder.where('referrer_name', '=', input.referrerName); + } + + if (input.referrerType) { + builder.where('referrer_type', '=', input.referrerType); + } + + if (input.device) { + builder.where('device', '=', input.device); + } + + if (input.country) { + builder.where('country', '=', input.country); + } + + if (input.city) { + builder.where('city', '=', input.city); + } + + if (input.os) { + builder.where('os', '=', input.os); + } + + if (input.browser) { + builder.where('browser', '=', input.browser); + } + + if (input.properties) { + for (const [key, value] of Object.entries(input.properties)) { + builder.where(`properties['${key}']`, '=', value); + } + } + + const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); + + builder.where('created_at', 'BETWEEN', [ + clix.datetime(start), + clix.datetime(end), + ]); + + return builder.limit(input.limit ?? 20).execute(); +} diff --git a/packages/db/src/services/funnel.service.ts b/packages/db/src/services/funnel.service.ts index 17ad964cb..4d587aadb 100644 --- a/packages/db/src/services/funnel.service.ts +++ b/packages/db/src/services/funnel.service.ts @@ -412,3 +412,78 @@ export class FunnelService { } export const funnelService = new FunnelService(ch); + +import { getSettingsForProject } from './organization.service'; + +export async function getFunnelCore(input: { + projectId: string; + startDate: string; + endDate: string; + steps: string[]; + windowHours?: number; + groupBy?: 'session_id' | 'profile_id'; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + const eventSeries = input.steps.map((name, index) => ({ + id: String(index + 1), + type: 'event' as const, + name, + displayName: name, + segment: 'user' as const, + filters: [], + })); + + const result = await funnelService.getFunnel({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + series: eventSeries, + breakdowns: [], + chartType: 'funnel', + interval: 'day', + range: 'custom', + previous: false, + metric: 'sum', + options: { + type: 'funnel', + funnelWindow: input.windowHours ?? 24, + funnelGroup: input.groupBy ?? 'session_id', + }, + timezone, + }); + + const primarySeries = result[0]; + if (!primarySeries) { + return { + steps: [], + totalUsers: 0, + completedUsers: 0, + overallConversionRate: 0, + }; + } + + const steps = primarySeries.steps.map((step, index) => ({ + step: index + 1, + eventName: step.event.displayName || step.event.name, + users: step.count, + conversionRateFromStart: Math.round(step.percent * 100) / 100, + dropoffPercent: + step.dropoffPercent != null + ? Math.round(step.dropoffPercent * 100) / 100 + : null, + isHighestDropoff: step.isHighestDropoff, + })); + + const totalUsers = steps[0]?.users ?? 0; + const completedUsers = steps[steps.length - 1]?.users ?? 0; + + return { + steps, + totalUsers, + completedUsers, + overallConversionRate: + totalUsers > 0 + ? Math.round((completedUsers / totalUsers) * 10000) / 100 + : 0, + }; +} diff --git a/packages/db/src/services/group.service.ts b/packages/db/src/services/group.service.ts index 03cb3547f..baa79b8ff 100644 --- a/packages/db/src/services/group.service.ts +++ b/packages/db/src/services/group.service.ts @@ -350,3 +350,47 @@ export async function getGroupMemberProfiles({ .filter(Boolean) as IServiceProfile[]; return { data, count }; } + +export async function listGroupTypesCore(projectId: string) { + const types = await getGroupTypes(projectId); + return { types }; +} + +export async function findGroupsCore(input: { + projectId: string; + type?: string; + search?: string; + limit?: number; +}) { + return getGroupList({ + projectId: input.projectId, + type: input.type, + search: input.search, + take: input.limit ?? 20, + }); +} + +export async function getGroupCore(input: { + projectId: string; + groupId: string; + memberLimit?: number; +}) { + const [group, members] = await Promise.all([ + getGroupById(input.groupId, input.projectId), + getGroupMemberProfiles({ + projectId: input.projectId, + groupId: input.groupId, + take: input.memberLimit ?? 10, + }), + ]); + + if (!group) { + throw new Error(`Group not found: ${input.groupId}`); + } + + return { + group, + member_count: members.count, + members: members.data, + }; +} diff --git a/packages/db/src/services/gsc.service.ts b/packages/db/src/services/gsc.service.ts new file mode 100644 index 000000000..8314ef361 --- /dev/null +++ b/packages/db/src/services/gsc.service.ts @@ -0,0 +1,194 @@ +import { getGscCannibalization, getGscOverview, getGscPageDetails, getGscPages, getGscQueryDetails, getGscQueries } from '../gsc'; + +export interface GscQueryOpportunity { + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + opportunity_score: number; + reason: string; +} + +function computeOpportunities( + queries: Array<{ + query: string; + clicks: number; + impressions: number; + ctr: number; + position: number; + }>, +): GscQueryOpportunity[] { + const ctrBenchmarks: Record = { + '1': 0.28, + '2': 0.15, + '3': 0.11, + '4-6': 0.065, + '7-10': 0.035, + '11-20': 0.012, + }; + + function getBenchmark(position: number): number { + if (position <= 1) return ctrBenchmarks['1'] ?? 0.28; + if (position <= 2) return ctrBenchmarks['2'] ?? 0.15; + if (position <= 3) return ctrBenchmarks['3'] ?? 0.11; + if (position <= 6) return ctrBenchmarks['4-6'] ?? 0.065; + if (position <= 10) return ctrBenchmarks['7-10'] ?? 0.035; + return ctrBenchmarks['11-20'] ?? 0.012; + } + + return queries + .filter((q) => q.position >= 4 && q.position <= 20 && q.impressions >= 50) + .map((q) => { + const benchmark = getBenchmark(q.position); + const ctrGap = Math.max(0, benchmark - q.ctr); + const opportunity_score = + Math.round(q.impressions * (1 / q.position) * (1 + ctrGap) * 100) / + 100; + + let reason: string; + if (q.position <= 6) { + reason = `Position ${q.position.toFixed(1)} — one rank improvement could significantly boost clicks`; + } else if (q.ctr < benchmark * 0.5) { + reason = `CTR (${(q.ctr * 100).toFixed(1)}%) is well below expected ${(benchmark * 100).toFixed(1)}% — title/meta optimization may help`; + } else { + reason = `Position ${q.position.toFixed(1)} with ${q.impressions} impressions — push to page 1 for major gains`; + } + + return { + query: q.query, + clicks: q.clicks, + impressions: q.impressions, + ctr: Math.round(q.ctr * 10000) / 100, + position: Math.round(q.position * 10) / 10, + opportunity_score, + reason, + }; + }) + .sort((a, b) => b.opportunity_score - a.opportunity_score) + .slice(0, 50); +} + +export async function gscGetOverviewCore(input: { + projectId: string; + startDate: string; + endDate: string; + interval?: 'day' | 'week' | 'month'; +}) { + const data = await getGscOverview( + input.projectId, + input.startDate, + input.endDate, + input.interval ?? 'day', + ); + return { + data, + summary: { + total_clicks: data.reduce((s, r) => s + r.clicks, 0), + total_impressions: data.reduce((s, r) => s + r.impressions, 0), + avg_ctr: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.ctr, 0) / data.length) * 10000, + ) / 100 + : 0, + avg_position: + data.length > 0 + ? Math.round( + (data.reduce((s, r) => s + r.position, 0) / data.length) * 10, + ) / 10 + : 0, + }, + }; +} + +export async function gscGetTopPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + return getGscPages( + input.projectId, + input.startDate, + input.endDate, + input.limit ?? 100, + ); +} + +export async function gscGetPageDetailsCore(input: { + projectId: string; + startDate: string; + endDate: string; + page: string; +}) { + return getGscPageDetails( + input.projectId, + input.page, + input.startDate, + input.endDate, + ); +} + +export async function gscGetTopQueriesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + return getGscQueries( + input.projectId, + input.startDate, + input.endDate, + input.limit ?? 100, + ); +} + +export async function gscGetQueryOpportunitiesCore(input: { + projectId: string; + startDate: string; + endDate: string; + minImpressions?: number; +}) { + const queries = await getGscQueries( + input.projectId, + input.startDate, + input.endDate, + 5000, + ); + const filtered = queries.filter( + (q) => q.impressions >= (input.minImpressions ?? 50), + ); + const opportunities = computeOpportunities(filtered); + return { + opportunities, + total_analyzed: filtered.length, + min_impressions: input.minImpressions ?? 50, + }; +} + +export async function gscGetQueryDetailsCore(input: { + projectId: string; + startDate: string; + endDate: string; + query: string; +}) { + return getGscQueryDetails( + input.projectId, + input.query, + input.startDate, + input.endDate, + ); +} + +export async function gscGetCannibalizationCore(input: { + projectId: string; + startDate: string; + endDate: string; +}) { + return getGscCannibalization( + input.projectId, + input.startDate, + input.endDate, + ); +} diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index d62b83a3d..a0b30ab16 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -1441,3 +1441,67 @@ export class OverviewService { } export const overviewService = new OverviewService(ch); + +import { getSettingsForProject } from './organization.service'; + +export type TrafficColumn = + | 'referrer' + | 'referrer_name' + | 'referrer_type' + | 'utm_source' + | 'utm_medium' + | 'utm_campaign' + | 'country' + | 'region' + | 'city' + | 'device' + | 'browser' + | 'os'; + +export async function getTrafficBreakdownCore(input: { + projectId: string; + startDate: string; + endDate: string; + column: TrafficColumn; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return overviewService.getTopGeneric({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + column: input.column, + timezone, + }); +} + +export interface GetAnalyticsOverviewInput { + projectId: string; + startDate: string; + endDate: string; + interval?: 'hour' | 'day' | 'week' | 'month'; +} + +export async function getAnalyticsOverviewCore( + input: GetAnalyticsOverviewInput, +) { + const { timezone } = await getSettingsForProject(input.projectId); + const interval = input.interval ?? 'day'; + + const result = await overviewService.getMetrics({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + interval, + timezone, + }); + + return { + summary: result.metrics, + series: result.series, + interval, + startDate: input.startDate, + endDate: input.endDate, + }; +} diff --git a/packages/db/src/services/pages.service.ts b/packages/db/src/services/pages.service.ts index e3bf54318..66133122e 100644 --- a/packages/db/src/services/pages.service.ts +++ b/packages/db/src/services/pages.service.ts @@ -172,3 +172,83 @@ export class PagesService { } export const pagesService = new PagesService(ch); + +import { OverviewService } from './overview.service'; +import { getSettingsForProject } from './organization.service'; + +const _overviewServiceForPages = new OverviewService(ch); + +export async function getTopPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + limit?: number; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return _overviewServiceForPages.getTopPages({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + timezone, + }); +} + +export async function getEntryExitPagesCore(input: { + projectId: string; + startDate: string; + endDate: string; + mode: 'entry' | 'exit'; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + return _overviewServiceForPages.getTopEntryExit({ + projectId: input.projectId, + filters: [], + startDate: input.startDate, + endDate: input.endDate, + mode: input.mode, + timezone, + }); +} + +export async function getPagePerformanceCore(input: { + projectId: string; + startDate: string; + endDate: string; + search?: string; + sortBy?: 'sessions' | 'pageviews' | 'bounce_rate' | 'avg_duration'; + sortOrder?: 'asc' | 'desc'; + limit?: number; +}) { + const { timezone } = await getSettingsForProject(input.projectId); + const pages = await pagesService.getTopPages({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + timezone, + search: input.search, + limit: 1000, + }); + + const col = input.sortBy ?? 'sessions'; + const dir = input.sortOrder === 'asc' ? 1 : -1; + const sorted = [...pages].sort( + (a, b) => dir * ((a[col] ?? 0) < (b[col] ?? 0) ? -1 : 1), + ); + const results = sorted.slice(0, input.limit ?? 50); + + const annotated = results.map((p) => ({ + ...p, + seo_signals: { + high_bounce: p.bounce_rate > 70, + low_engagement: p.avg_duration < 1, + good_landing_page: p.bounce_rate < 40 && p.avg_duration > 2, + }, + })); + + return { + total_pages: pages.length, + shown: annotated.length, + pages: annotated, + }; +} diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index bdd677a73..e3b0d57f9 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -325,3 +325,171 @@ export function upsertProfile( return profileBuffer.add(profile, isFromEvent); } + +import { ch } from '../clickhouse/client'; +import { clix } from '../clickhouse/query-builder'; +import type { IClickhouseEvent } from './event.service'; +import type { IClickhouseSession } from './session.service'; + +function esc(value: string): string { + return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; +} + +const PROFILE_COLUMNS = + 'id, first_name, last_name, email, avatar, properties, project_id, is_external, created_at, groups'; + +export interface FindProfilesInput { + projectId: string; + name?: string; + email?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + inactiveDays?: number; + minSessions?: number; + performedEvent?: string; + sortBy?: 'created_at'; + sortOrder?: 'asc' | 'desc'; + limit?: number; +} + +export async function findProfilesCore( + input: FindProfilesInput, +): Promise { + const pid = esc(input.projectId); + const conditions: string[] = [`project_id = ${pid}`]; + + if (input.email) { + conditions.push(`email LIKE ${esc('%' + input.email + '%')}`); + } + if (input.name) { + const escaped = esc('%' + input.name + '%'); + conditions.push(`(first_name LIKE ${escaped} OR last_name LIKE ${escaped})`); + } + if (input.country) { + conditions.push(`properties['country'] = ${esc(input.country)}`); + } + if (input.city) { + conditions.push(`properties['city'] = ${esc(input.city)}`); + } + if (input.device) { + conditions.push(`properties['device'] = ${esc(input.device)}`); + } + if (input.browser) { + conditions.push(`properties['browser'] = ${esc(input.browser)}`); + } + + if (input.inactiveDays !== undefined) { + const days = Math.floor(input.inactiveDays); + conditions.push(`id NOT IN ( + SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} + WHERE project_id = ${pid} + AND profile_id != '' + AND created_at >= now() - INTERVAL ${days} DAY + )`); + } + + if (input.minSessions !== undefined) { + const min = Math.floor(input.minSessions); + conditions.push(`id IN ( + SELECT profile_id FROM ${TABLE_NAMES.sessions} + WHERE project_id = ${pid} + AND sign = 1 + AND profile_id != '' + GROUP BY profile_id + HAVING count() >= ${min} + )`); + } + + if (input.performedEvent) { + conditions.push(`id IN ( + SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} + WHERE project_id = ${pid} + AND name = ${esc(input.performedEvent)} + )`); + } + + const orderDir = input.sortOrder === 'asc' ? 'ASC' : 'DESC'; + const limit = Math.min(input.limit ?? 20, 100); + + const sql = ` + SELECT ${PROFILE_COLUMNS} + FROM ${TABLE_NAMES.profiles} + WHERE ${conditions.join(' AND ')} + ORDER BY created_at ${orderDir} + LIMIT ${limit} + `; + + return chQuery(sql); +} + +export async function getProfileWithEvents( + projectId: string, + profileId: string, + eventLimit = 10, +): Promise<{ + profile: IClickhouseProfile | null; + recent_events: IClickhouseEvent[]; +}> { + const [profiles, recent_events] = await Promise.all([ + chQuery(` + SELECT ${PROFILE_COLUMNS} + FROM ${TABLE_NAMES.profiles} + WHERE project_id = ${esc(projectId)} AND id = ${esc(profileId)} + LIMIT 1 + `), + clix(ch) + .select([]) + .from(TABLE_NAMES.events) + .where('project_id', '=', projectId) + .where('profile_id', '=', profileId) + .orderBy('created_at', 'DESC') + .limit(eventLimit) + .execute(), + ]); + + return { profile: profiles[0] ?? null, recent_events }; +} + +export async function getProfileSessionsCore( + projectId: string, + profileId: string, + limit = 20, +): Promise { + return clix(ch) + .select([]) + .from(TABLE_NAMES.sessions) + .where('project_id', '=', projectId) + .where('profile_id', '=', profileId) + .where('sign', '=', 1) + .orderBy('created_at', 'DESC') + .limit(limit) + .execute(); +} + +export async function getProfileMetricsCore(input: { + projectId: string; + profileId: string; +}) { + const raw = await getProfileMetrics(input.profileId, input.projectId); + if (!raw) { + throw new Error(`Profile not found or has no events: ${input.profileId}`); + } + return { + profileId: input.profileId, + firstSeen: raw.firstSeen, + lastSeen: raw.lastSeen, + sessions: raw.sessions, + screenViews: raw.screenViews, + totalEvents: raw.totalEvents, + conversionEvents: raw.conversionEvents, + uniqueDaysActive: raw.uniqueDaysActive, + avgSessionDurationMin: raw.durationAvg, + p90SessionDurationMin: raw.durationP90, + avgEventsPerSession: raw.avgEventsPerSession, + avgTimeBetweenSessionsSec: raw.avgTimeBetweenSessions, + bounceRate: raw.bounceRate, + revenue: raw.revenue, + }; +} diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index 60effcef7..38e828dba 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -109,3 +109,44 @@ export const getProjectEventsCount = async (projectId: string) => { ); return res[0]?.count; }; + +export async function listProjectsCore(input: { + clientType: 'root' | 'read'; + organizationId: string; + projectId: string | null; +}) { + if (input.clientType === 'root') { + const projects = await db.project.findMany({ + where: { organizationId: input.organizationId }, + orderBy: { eventsCount: 'desc' }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }); + return { clientType: 'root', projects }; + } + + const project = input.projectId + ? await db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + organizationId: true, + eventsCount: true, + domain: true, + types: true, + }, + }) + : null; + + return { + clientType: 'read', + projects: project ? [project] : [], + }; +} diff --git a/packages/db/src/services/reports.service.ts b/packages/db/src/services/reports.service.ts index ff205dc31..b96794b3e 100644 --- a/packages/db/src/services/reports.service.ts +++ b/packages/db/src/services/reports.service.ts @@ -123,3 +123,73 @@ export async function getReportById(id: string) { return transformReport(report); } + +import { AggregateChartEngine, ChartEngine } from '../engine'; +import { getChartStartEndDate } from './date.service'; +import { funnelService } from './funnel.service'; +import { getSettingsForProject } from './organization.service'; + +export async function listReportsCore(input: { + projectId: string; + dashboardId: string; + organizationId: string; +}) { + const reports = await getReportsByDashboardId(input.dashboardId); + return reports.map((r) => ({ + id: r.id, + name: r.name, + chartType: r.chartType, + range: r.range, + interval: r.interval, + metric: r.metric, + series: r.series.map((s) => + s.type === 'formula' + ? { type: 'formula', id: s.id, formula: s.formula } + : { type: 'event', id: s.id, name: s.name, displayName: s.displayName, segment: s.segment }, + ), + breakdowns: r.breakdowns, + })); +} + +export async function getReportDataCore(input: { + projectId: string; + reportId: string; + organizationId: string; +}) { + const report = await getReportById(input.reportId); + + if (!report) { + throw new Error(`Report not found: ${input.reportId}`); + } + + if (report.projectId !== input.projectId) { + throw new Error(`Report does not belong to this project: ${input.reportId}`); + } + + const { timezone } = await getSettingsForProject(input.projectId); + const { startDate, endDate } = getChartStartEndDate(report, timezone); + const chartInput = { ...report, startDate, endDate, timezone }; + + const meta = { + id: report.id, + name: report.name, + chartType: report.chartType, + range: report.range, + interval: report.interval, + startDate, + endDate, + }; + + if (report.chartType === 'funnel') { + const result = await funnelService.getFunnel(chartInput); + return { ...meta, data: result }; + } + + if (report.chartType === 'metric') { + const result = await AggregateChartEngine.execute(chartInput); + return { ...meta, data: result }; + } + + const result = await ChartEngine.execute(chartInput); + return { ...meta, data: result }; +} diff --git a/packages/db/src/services/retention.service.ts b/packages/db/src/services/retention.service.ts index d164b2c2e..b337ee8fe 100644 --- a/packages/db/src/services/retention.service.ts +++ b/packages/db/src/services/retention.service.ts @@ -156,3 +156,63 @@ export function getRetentionLastSeenSeries({ users: number; }>(sql); } + +export async function getRollingActiveUsersCore(input: { + projectId: string; + days: number; +}) { + const data = await getRollingActiveUsers(input); + return { + window_days: input.days, + label: + input.days === 1 + ? 'DAU' + : input.days === 7 + ? 'WAU' + : input.days === 30 + ? 'MAU' + : `${input.days}d active`, + series: data, + }; +} + +export async function getWeeklyRetentionSeriesCore(projectId: string) { + return getRetentionSeries({ projectId }); +} + +export async function getRetentionCohortCore(projectId: string) { + return getRetentionCohortTable({ projectId }); +} + +export async function getEngagementCore(projectId: string) { + const raw = await getRetentionLastSeenSeries({ projectId }); + + let active_0_7 = 0; + let active_8_14 = 0; + let active_15_30 = 0; + let active_31_60 = 0; + let churned_60_plus = 0; + + for (const row of raw) { + if (row.days <= 7) active_0_7 += row.users; + else if (row.days <= 14) active_8_14 += row.users; + else if (row.days <= 30) active_15_30 += row.users; + else if (row.days <= 60) active_31_60 += row.users; + else churned_60_plus += row.users; + } + + const total = + active_0_7 + active_8_14 + active_15_30 + active_31_60 + churned_60_plus; + + return { + summary: { + total_identified_users: total, + active_last_7_days: active_0_7, + active_8_to_14_days: active_8_14, + active_15_to_30_days: active_15_30, + inactive_31_to_60_days: active_31_60, + churned_60_plus_days: churned_60_plus, + }, + distribution: raw, + }; +} diff --git a/packages/db/src/services/sankey.service.ts b/packages/db/src/services/sankey.service.ts index 64417c446..af1844612 100644 --- a/packages/db/src/services/sankey.service.ts +++ b/packages/db/src/services/sankey.service.ts @@ -781,3 +781,56 @@ export class SankeyService { } export const sankeyService = new SankeyService(ch); + +import { getSettingsForProject } from './organization.service'; + +function toChartEvent(name: string) { + return { + id: name, + name, + displayName: name, + type: 'event' as const, + segment: 'event' as const, + filters: [], + }; +} + +export async function getUserFlowCore(input: { + projectId: string; + startDate: string; + endDate: string; + startEvent: string; + endEvent?: string; + mode: 'after' | 'before' | 'between'; + steps?: number; + exclude?: string[]; + include?: string[]; +}) { + if (input.mode === 'between' && !input.endEvent) { + throw new Error('endEvent is required when mode is "between"'); + } + + const { timezone } = await getSettingsForProject(input.projectId); + const result = await sankeyService.getSankey({ + projectId: input.projectId, + startDate: input.startDate, + endDate: input.endDate, + steps: input.steps ?? 5, + mode: input.mode, + startEvent: toChartEvent(input.startEvent), + endEvent: input.endEvent ? toChartEvent(input.endEvent) : undefined, + exclude: input.exclude ?? [], + include: input.include, + timezone, + }); + + return { + mode: input.mode, + startEvent: input.startEvent, + endEvent: input.endEvent, + node_count: result.nodes.length, + link_count: result.links.length, + nodes: result.nodes, + links: result.links, + }; +} diff --git a/packages/db/src/services/session.service.ts b/packages/db/src/services/session.service.ts index 636cf452d..05e0c5899 100644 --- a/packages/db/src/services/session.service.ts +++ b/packages/db/src/services/session.service.ts @@ -462,3 +462,76 @@ class SessionService { } export const sessionService = new SessionService(ch); + +import { resolveDateRange } from './date.service'; + +export interface QuerySessionsInput { + projectId: string; + startDate?: string; + endDate?: string; + country?: string; + city?: string; + device?: string; + browser?: string; + os?: string; + referrer?: string; + referrerName?: string; + referrerType?: string; + profileId?: string; + limit?: number; +} + +export async function querySessionsCore( + input: QuerySessionsInput, +): Promise { + const builder = clix(ch) + .select([]) + .from(TABLE_NAMES.sessions) + .where('project_id', '=', input.projectId) + .where('sign', '=', 1); + + if (input.profileId) { + builder.where('profile_id', '=', input.profileId); + } + + if (input.referrer) { + builder.where('referrer', '=', input.referrer); + } + + if (input.referrerName) { + builder.where('referrer_name', '=', input.referrerName); + } + + if (input.referrerType) { + builder.where('referrer_type', '=', input.referrerType); + } + + if (input.device) { + builder.where('device', '=', input.device); + } + + if (input.country) { + builder.where('country', '=', input.country); + } + + if (input.city) { + builder.where('city', '=', input.city); + } + + if (input.os) { + builder.where('os', '=', input.os); + } + + if (input.browser) { + builder.where('browser', '=', input.browser); + } + + const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); + + builder.where('created_at', 'BETWEEN', [ + clix.datetime(start), + clix.datetime(end), + ]); + + return builder.limit(input.limit ?? 20).execute(); +} diff --git a/packages/mcp/index.ts b/packages/mcp/index.ts index 1cb69ce3d..5f1afe12b 100644 --- a/packages/mcp/index.ts +++ b/packages/mcp/index.ts @@ -3,72 +3,3 @@ export { SessionManager } from './src/session-manager'; export { authenticateToken, McpAuthError } from './src/auth'; export { handleMcpGet, handleMcpPost } from './src/handler'; export type { McpAuthContext } from './src/auth'; - -// Core analytics functions — callable directly without MCP transport -export { resolveDateRange } from './src/tools/shared'; -export { listProjectsCore } from './src/tools/projects'; -export { - getAnalyticsOverviewCore, - type GetAnalyticsOverviewInput, -} from './src/tools/analytics/overview'; -export { - getFunnelCore, -} from './src/tools/analytics/funnel'; -export { - getTopPagesCore, - getEntryExitPagesCore, -} from './src/tools/analytics/pages'; -export { - getRollingActiveUsersCore, - getWeeklyRetentionSeriesCore, -} from './src/tools/analytics/active-users'; -export { getRetentionCohortCore } from './src/tools/analytics/retention'; -export { - getTrafficBreakdownCore, - type TrafficColumn, -} from './src/tools/analytics/traffic'; -export { getUserFlowCore } from './src/tools/analytics/user-flow'; -export { getEngagementCore } from './src/tools/analytics/engagement'; -export { getPagePerformanceCore } from './src/tools/analytics/page-performance'; -export { - queryEventsCore, - type QueryEventsInput, -} from './src/tools/analytics/events'; -export { - listEventNamesCore, -} from './src/tools/analytics/event-names'; -export { - listEventPropertiesCore, - getEventPropertyValuesCore, -} from './src/tools/analytics/property-values'; -export { - findProfilesCore, - getProfileWithEvents, - getProfileSessionsCore, - type FindProfilesInput, -} from './src/tools/analytics/profiles'; -export { - getProfileMetricsCore, -} from './src/tools/analytics/profile-metrics'; -export { - querySessionsCore, - type QuerySessionsInput, -} from './src/tools/analytics/sessions'; -export { - listGroupTypesCore, - findGroupsCore, - getGroupCore, -} from './src/tools/analytics/groups'; -export { - listDashboardsCore, - listReportsCore, - getReportDataCore, -} from './src/tools/analytics/reports'; -export { gscGetOverviewCore } from './src/tools/gsc/overview'; -export { gscGetTopPagesCore, gscGetPageDetailsCore } from './src/tools/gsc/pages'; -export { - gscGetTopQueriesCore, - gscGetQueryOpportunitiesCore, - gscGetQueryDetailsCore, -} from './src/tools/gsc/queries'; -export { gscGetCannibalizationCore } from './src/tools/gsc/cannibalization'; diff --git a/packages/mcp/src/tools/analytics/active-users.ts b/packages/mcp/src/tools/analytics/active-users.ts index 83f3cda9e..9b67dfcbe 100644 --- a/packages/mcp/src/tools/analytics/active-users.ts +++ b/packages/mcp/src/tools/analytics/active-users.ts @@ -1,7 +1,6 @@ -import { - getRollingActiveUsers, - getRetentionSeries, -} from '@openpanel/db'; +export { getRollingActiveUsersCore, getWeeklyRetentionSeriesCore } from '@openpanel/db'; + +import { getRollingActiveUsers, getRetentionSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -11,29 +10,6 @@ import { withErrorHandling, } from '../shared'; -export async function getRollingActiveUsersCore(input: { - projectId: string; - days: number; -}) { - const data = await getRollingActiveUsers(input); - return { - window_days: input.days, - label: - input.days === 1 - ? 'DAU' - : input.days === 7 - ? 'WAU' - : input.days === 30 - ? 'MAU' - : `${input.days}d active`, - series: data, - }; -} - -export async function getWeeklyRetentionSeriesCore(projectId: string) { - return getRetentionSeries({ projectId }); -} - export function registerActiveUserTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/engagement.ts b/packages/mcp/src/tools/analytics/engagement.ts index dd91b0f18..89997f246 100644 --- a/packages/mcp/src/tools/analytics/engagement.ts +++ b/packages/mcp/src/tools/analytics/engagement.ts @@ -1,41 +1,10 @@ +export { getEngagementCore } from '@openpanel/db'; + import { getRetentionLastSeenSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; -export async function getEngagementCore(projectId: string) { - const raw = await getRetentionLastSeenSeries({ projectId }); - - let active_0_7 = 0; - let active_8_14 = 0; - let active_15_30 = 0; - let active_31_60 = 0; - let churned_60_plus = 0; - - for (const row of raw) { - if (row.days <= 7) active_0_7 += row.users; - else if (row.days <= 14) active_8_14 += row.users; - else if (row.days <= 30) active_15_30 += row.users; - else if (row.days <= 60) active_31_60 += row.users; - else churned_60_plus += row.users; - } - - const total = - active_0_7 + active_8_14 + active_15_30 + active_31_60 + churned_60_plus; - - return { - summary: { - total_identified_users: total, - active_last_7_days: active_0_7, - active_8_to_14_days: active_8_14, - active_15_to_30_days: active_15_30, - inactive_31_to_60_days: active_31_60, - churned_60_plus_days: churned_60_plus, - }, - distribution: raw, - }; -} - export function registerEngagementTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/event-names.ts b/packages/mcp/src/tools/analytics/event-names.ts index d1813cea4..c9556282f 100644 --- a/packages/mcp/src/tools/analytics/event-names.ts +++ b/packages/mcp/src/tools/analytics/event-names.ts @@ -1,6 +1,6 @@ -import { TABLE_NAMES, ch, clix } from '@openpanel/db'; -import type { IClickhouseEvent } from '@openpanel/db'; -import { getCache } from '@openpanel/redis'; +export { getTopEventNames, listEventNamesCore } from '@openpanel/db'; + +import { getTopEventNames } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; import { @@ -9,24 +9,6 @@ import { withErrorHandling, } from '../shared'; -export async function getTopEventNames(projectId: string): Promise { - return getCache(`mcp:event-names:${projectId}`, 60 * 10, async () => { - const rows = await clix(ch) - .select(['name', 'count() as count']) - .from(TABLE_NAMES.event_names_mv) - .where('project_id', '=', projectId) - .groupBy(['name']) - .orderBy('count', 'DESC') - .limit(50) - .execute(); - - return rows.map((r) => r.name); - }); -} - -export const listEventNamesCore = (projectId: string): Promise => - getTopEventNames(projectId); - export function registerEventNameTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/events.ts b/packages/mcp/src/tools/analytics/events.ts index 6d8366bce..8fc231bad 100644 --- a/packages/mcp/src/tools/analytics/events.ts +++ b/packages/mcp/src/tools/analytics/events.ts @@ -1,5 +1,6 @@ -import { TABLE_NAMES, ch, clix } from '@openpanel/db'; -import type { IClickhouseEvent } from '@openpanel/db'; +export { queryEventsCore, type QueryEventsInput } from '@openpanel/db'; + +import { queryEventsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -11,93 +12,6 @@ import { zDateRange, } from '../shared'; -export interface QueryEventsInput { - projectId: string; - startDate?: string; - endDate?: string; - eventNames?: string[]; - path?: string; - country?: string; - city?: string; - device?: string; - browser?: string; - os?: string; - referrer?: string; - referrerName?: string; - referrerType?: string; - profileId?: string; - properties?: Record; - limit?: number; -} - -export async function queryEventsCore( - input: QueryEventsInput, -): Promise { - const builder = clix(ch) - .select([]) - .from(TABLE_NAMES.events) - .where('project_id', '=', input.projectId); - - if (input.profileId) { - builder.where('profile_id', '=', input.profileId); - } - - if (input.eventNames?.length) { - builder.where('name', 'IN', input.eventNames); - } - - if (input.path) { - builder.where('path', '=', input.path); - } - - if (input.referrer) { - builder.where('referrer', '=', input.referrer); - } - - if (input.referrerName) { - builder.where('referrer_name', '=', input.referrerName); - } - - if (input.referrerType) { - builder.where('referrer_type', '=', input.referrerType); - } - - if (input.device) { - builder.where('device', '=', input.device); - } - - if (input.country) { - builder.where('country', '=', input.country); - } - - if (input.city) { - builder.where('city', '=', input.city); - } - - if (input.os) { - builder.where('os', '=', input.os); - } - - if (input.browser) { - builder.where('browser', '=', input.browser); - } - - if (input.properties) { - for (const [key, value] of Object.entries(input.properties)) { - builder.where(`properties['${key}']`, '=', value); - } - } - - const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); - - builder.where('created_at', 'BETWEEN', [ - clix.datetime(start), - clix.datetime(end), - ]); - - return builder.limit(input.limit ?? 20).execute(); -} - export function registerEventTools(server: McpServer, context: McpAuthContext) { server.tool( 'query_events', diff --git a/packages/mcp/src/tools/analytics/funnel.ts b/packages/mcp/src/tools/analytics/funnel.ts index b46852a90..ff04467c3 100644 --- a/packages/mcp/src/tools/analytics/funnel.ts +++ b/packages/mcp/src/tools/analytics/funnel.ts @@ -1,4 +1,6 @@ -import { FunnelService, ch, getSettingsForProject } from '@openpanel/db'; +import { getFunnelCore } from '@openpanel/db'; +export { getFunnelCore }; + import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -10,82 +12,6 @@ import { zDateRange, } from '../shared'; -const funnelService = new FunnelService(ch); - -export async function getFunnelCore(input: { - projectId: string; - startDate: string; - endDate: string; - steps: string[]; - windowHours?: number; - groupBy?: 'session_id' | 'profile_id'; -}) { - const { timezone } = await getSettingsForProject(input.projectId); - const eventSeries = input.steps.map((name, index) => ({ - id: String(index + 1), - type: 'event' as const, - name, - displayName: name, - segment: 'user' as const, - filters: [], - })); - - const result = await funnelService.getFunnel({ - projectId: input.projectId, - startDate: input.startDate, - endDate: input.endDate, - series: eventSeries, - breakdowns: [], - chartType: 'funnel', - interval: 'day', - range: 'custom', - previous: false, - metric: 'sum', - options: { - type: 'funnel', - funnelWindow: input.windowHours ?? 24, - funnelGroup: input.groupBy ?? 'session_id', - }, - timezone, - }); - - // Take the first (unbreakdown) series and map steps to a readable format - const primarySeries = result[0]; - if (!primarySeries) { - return { - steps: [], - totalUsers: 0, - completedUsers: 0, - overallConversionRate: 0, - }; - } - - const steps = primarySeries.steps.map((step, index) => ({ - step: index + 1, - eventName: step.event.displayName || step.event.name, - users: step.count, - conversionRateFromStart: Math.round(step.percent * 100) / 100, - dropoffPercent: - step.dropoffPercent != null - ? Math.round(step.dropoffPercent * 100) / 100 - : null, - isHighestDropoff: step.isHighestDropoff, - })); - - const totalUsers = steps[0]?.users ?? 0; - const completedUsers = steps[steps.length - 1]?.users ?? 0; - - return { - steps, - totalUsers, - completedUsers, - overallConversionRate: - totalUsers > 0 - ? Math.round((completedUsers / totalUsers) * 10000) / 100 - : 0, - }; -} - export function registerFunnelTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/groups.ts b/packages/mcp/src/tools/analytics/groups.ts index 0c59caba4..513821129 100644 --- a/packages/mcp/src/tools/analytics/groups.ts +++ b/packages/mcp/src/tools/analytics/groups.ts @@ -1,58 +1,11 @@ -import { - getGroupById, - getGroupList, - getGroupMemberProfiles, - getGroupTypes, -} from '@openpanel/db'; +export { findGroupsCore, getGroupCore, listGroupTypesCore } from '@openpanel/db'; + +import { getGroupById, getGroupList, getGroupMemberProfiles, getGroupTypes } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; -export async function listGroupTypesCore(projectId: string) { - const types = await getGroupTypes(projectId); - return { types }; -} - -export async function findGroupsCore(input: { - projectId: string; - type?: string; - search?: string; - limit?: number; -}) { - return getGroupList({ - projectId: input.projectId, - type: input.type, - search: input.search, - take: input.limit ?? 20, - }); -} - -export async function getGroupCore(input: { - projectId: string; - groupId: string; - memberLimit?: number; -}) { - const [group, members] = await Promise.all([ - getGroupById(input.groupId, input.projectId), - getGroupMemberProfiles({ - projectId: input.projectId, - groupId: input.groupId, - take: input.memberLimit ?? 10, - }), - ]); - - if (!group) { - return { error: 'Group not found', groupId: input.groupId }; - } - - return { - group, - member_count: members.count, - members: members.data, - }; -} - export function registerGroupTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/overview.ts b/packages/mcp/src/tools/analytics/overview.ts index 9ce25c059..ab30cd73d 100644 --- a/packages/mcp/src/tools/analytics/overview.ts +++ b/packages/mcp/src/tools/analytics/overview.ts @@ -1,8 +1,6 @@ -import { - OverviewService, - ch, - getSettingsForProject, -} from '@openpanel/db'; +export { getAnalyticsOverviewCore, type GetAnalyticsOverviewInput } from '@openpanel/db'; + +import { getAnalyticsOverviewCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -14,39 +12,6 @@ import { zDateRange, } from '../shared'; -const overviewService = new OverviewService(ch); - -export interface GetAnalyticsOverviewInput { - projectId: string; - startDate: string; - endDate: string; - interval?: 'hour' | 'day' | 'week' | 'month'; -} - -export async function getAnalyticsOverviewCore( - input: GetAnalyticsOverviewInput, -) { - const { timezone } = await getSettingsForProject(input.projectId); - const interval = input.interval ?? 'day'; - - const result = await overviewService.getMetrics({ - projectId: input.projectId, - filters: [], - startDate: input.startDate, - endDate: input.endDate, - interval, - timezone, - }); - - return { - summary: result.metrics, - series: result.series, - interval, - startDate: input.startDate, - endDate: input.endDate, - }; -} - export function registerOverviewTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/page-performance.ts b/packages/mcp/src/tools/analytics/page-performance.ts index a6a14a006..f72b62346 100644 --- a/packages/mcp/src/tools/analytics/page-performance.ts +++ b/packages/mcp/src/tools/analytics/page-performance.ts @@ -1,3 +1,5 @@ +export { getPagePerformanceCore } from '@openpanel/db'; + import { PagesService, ch, getSettingsForProject } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -12,48 +14,6 @@ import { const pagesService = new PagesService(ch); -export async function getPagePerformanceCore(input: { - projectId: string; - startDate: string; - endDate: string; - search?: string; - sortBy?: 'sessions' | 'pageviews' | 'bounce_rate' | 'avg_duration'; - sortOrder?: 'asc' | 'desc'; - limit?: number; -}) { - const { timezone } = await getSettingsForProject(input.projectId); - const pages = await pagesService.getTopPages({ - projectId: input.projectId, - startDate: input.startDate, - endDate: input.endDate, - timezone, - search: input.search, - limit: 1000, - }); - - const col = input.sortBy ?? 'sessions'; - const dir = input.sortOrder === 'asc' ? 1 : -1; - const sorted = [...pages].sort( - (a, b) => dir * ((a[col] ?? 0) < (b[col] ?? 0) ? -1 : 1), - ); - const results = sorted.slice(0, input.limit ?? 50); - - const annotated = results.map((p) => ({ - ...p, - seo_signals: { - high_bounce: p.bounce_rate > 70, - low_engagement: p.avg_duration < 1, - good_landing_page: p.bounce_rate < 40 && p.avg_duration > 2, - }, - })); - - return { - total_pages: pages.length, - shown: annotated.length, - pages: annotated, - }; -} - export function registerPagePerformanceTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/pages.ts b/packages/mcp/src/tools/analytics/pages.ts index 799d44e95..e7e4dffd9 100644 --- a/packages/mcp/src/tools/analytics/pages.ts +++ b/packages/mcp/src/tools/analytics/pages.ts @@ -1,8 +1,6 @@ -import { - OverviewService, - ch, - getSettingsForProject, -} from '@openpanel/db'; +import { getEntryExitPagesCore, getTopPagesCore } from '@openpanel/db'; +export { getEntryExitPagesCore, getTopPagesCore }; + import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -14,41 +12,6 @@ import { zDateRange, } from '../shared'; -const overviewService = new OverviewService(ch); - -export async function getTopPagesCore(input: { - projectId: string; - startDate: string; - endDate: string; - limit?: number; -}) { - const { timezone } = await getSettingsForProject(input.projectId); - return overviewService.getTopPages({ - projectId: input.projectId, - filters: [], - startDate: input.startDate, - endDate: input.endDate, - timezone, - }); -} - -export async function getEntryExitPagesCore(input: { - projectId: string; - startDate: string; - endDate: string; - mode: 'entry' | 'exit'; -}) { - const { timezone } = await getSettingsForProject(input.projectId); - return overviewService.getTopEntryExit({ - projectId: input.projectId, - filters: [], - startDate: input.startDate, - endDate: input.endDate, - mode: input.mode, - timezone, - }); -} - export function registerPageTools(server: McpServer, context: McpAuthContext) { server.tool( 'get_top_pages', diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts index cae2a678c..c4bbb60b0 100644 --- a/packages/mcp/src/tools/analytics/profile-metrics.ts +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -1,3 +1,5 @@ +export { getProfileMetricsCore } from '@openpanel/db'; + import { getProfileMetrics } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -8,32 +10,6 @@ import { withErrorHandling, } from '../shared'; -export async function getProfileMetricsCore(input: { - projectId: string; - profileId: string; -}) { - const raw = await getProfileMetrics(input.profileId, input.projectId); - if (!raw) { - return { error: 'Profile not found or has no events', profileId: input.profileId }; - } - return { - profileId: input.profileId, - firstSeen: raw.firstSeen, - lastSeen: raw.lastSeen, - sessions: raw.sessions, - screenViews: raw.screenViews, - totalEvents: raw.totalEvents, - conversionEvents: raw.conversionEvents, - uniqueDaysActive: raw.uniqueDaysActive, - avgSessionDurationMin: raw.durationAvg, - p90SessionDurationMin: raw.durationP90, - avgEventsPerSession: raw.avgEventsPerSession, - avgTimeBetweenSessionsSec: raw.avgTimeBetweenSessions, - bounceRate: raw.bounceRate, - revenue: raw.revenue, - }; -} - export function registerProfileMetricTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/profiles.ts b/packages/mcp/src/tools/analytics/profiles.ts index 80fdf660b..a4c4867c4 100644 --- a/packages/mcp/src/tools/analytics/profiles.ts +++ b/packages/mcp/src/tools/analytics/profiles.ts @@ -1,9 +1,6 @@ -import { TABLE_NAMES, ch, chQuery, clix } from '@openpanel/db'; -import type { - IClickhouseEvent, - IClickhouseProfile, - IClickhouseSession, -} from '@openpanel/db'; +import { findProfilesCore, getProfileSessionsCore, getProfileWithEvents, type FindProfilesInput } from '@openpanel/db'; +export { findProfilesCore, getProfileSessionsCore, getProfileWithEvents, type FindProfilesInput }; + import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -14,148 +11,6 @@ import { withErrorHandling, } from '../shared'; -/** Safely escape a string value for use in a ClickHouse SQL literal. */ -function esc(value: string): string { - return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; -} - -const PROFILE_COLUMNS = - 'id, first_name, last_name, email, avatar, properties, project_id, is_external, created_at, groups'; - -export interface FindProfilesInput { - projectId: string; - /** Partial match against first_name OR last_name */ - name?: string; - email?: string; - country?: string; - city?: string; - device?: string; - browser?: string; - /** Profiles with no activity (events) in the last N days */ - inactiveDays?: number; - /** Profiles with at least N total sessions */ - minSessions?: number; - /** Only profiles that have performed this event at least once */ - performedEvent?: string; - sortBy?: 'created_at'; - sortOrder?: 'asc' | 'desc'; - limit?: number; -} - -export async function findProfilesCore( - input: FindProfilesInput, -): Promise { - const pid = esc(input.projectId); - const conditions: string[] = [`project_id = ${pid}`]; - - if (input.email) { - conditions.push(`email LIKE ${esc('%' + input.email + '%')}`); - } - if (input.name) { - const escaped = esc('%' + input.name + '%'); - conditions.push(`(first_name LIKE ${escaped} OR last_name LIKE ${escaped})`); - } - if (input.country) { - conditions.push(`properties['country'] = ${esc(input.country)}`); - } - if (input.city) { - conditions.push(`properties['city'] = ${esc(input.city)}`); - } - if (input.device) { - conditions.push(`properties['device'] = ${esc(input.device)}`); - } - if (input.browser) { - conditions.push(`properties['browser'] = ${esc(input.browser)}`); - } - - if (input.inactiveDays !== undefined) { - const days = Math.floor(input.inactiveDays); - conditions.push(`id NOT IN ( - SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} - WHERE project_id = ${pid} - AND profile_id != '' - AND created_at >= now() - INTERVAL ${days} DAY - )`); - } - - if (input.minSessions !== undefined) { - const min = Math.floor(input.minSessions); - conditions.push(`id IN ( - SELECT profile_id FROM ${TABLE_NAMES.sessions} - WHERE project_id = ${pid} - AND sign = 1 - AND profile_id != '' - GROUP BY profile_id - HAVING count() >= ${min} - )`); - } - - if (input.performedEvent) { - conditions.push(`id IN ( - SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} - WHERE project_id = ${pid} - AND name = ${esc(input.performedEvent)} - )`); - } - - const orderDir = input.sortOrder === 'asc' ? 'ASC' : 'DESC'; - const limit = Math.min(input.limit ?? 20, 100); - - const sql = ` - SELECT ${PROFILE_COLUMNS} - FROM ${TABLE_NAMES.profiles} - WHERE ${conditions.join(' AND ')} - ORDER BY created_at ${orderDir} - LIMIT ${limit} - `; - - return chQuery(sql); -} - -export async function getProfileWithEvents( - projectId: string, - profileId: string, - eventLimit = 10, -): Promise<{ - profile: IClickhouseProfile | null; - recent_events: IClickhouseEvent[]; -}> { - const [profiles, recent_events] = await Promise.all([ - chQuery(` - SELECT ${PROFILE_COLUMNS} - FROM ${TABLE_NAMES.profiles} - WHERE project_id = ${esc(projectId)} AND id = ${esc(profileId)} - LIMIT 1 - `), - clix(ch) - .select([]) - .from(TABLE_NAMES.events) - .where('project_id', '=', projectId) - .where('profile_id', '=', profileId) - .orderBy('created_at', 'DESC') - .limit(eventLimit) - .execute(), - ]); - - return { profile: profiles[0] ?? null, recent_events }; -} - -export async function getProfileSessionsCore( - projectId: string, - profileId: string, - limit = 20, -): Promise { - return clix(ch) - .select([]) - .from(TABLE_NAMES.sessions) - .where('project_id', '=', projectId) - .where('profile_id', '=', profileId) - .where('sign', '=', 1) - .orderBy('created_at', 'DESC') - .limit(limit) - .execute(); -} - export function registerProfileTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/property-values.ts b/packages/mcp/src/tools/analytics/property-values.ts index 9d9a9e23a..29fc6dddb 100644 --- a/packages/mcp/src/tools/analytics/property-values.ts +++ b/packages/mcp/src/tools/analytics/property-values.ts @@ -1,3 +1,5 @@ +export { listEventPropertiesCore, getEventPropertyValuesCore } from '@openpanel/db'; + import { TABLE_NAMES, ch, clix } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -8,50 +10,6 @@ import { withErrorHandling, } from '../shared'; -export async function listEventPropertiesCore(input: { - projectId: string; - eventName?: string; -}): Promise<{ properties: Array<{ property_key: string; event_name: string }> }> { - const builder = clix(ch) - .select<{ property_key: string; event_name: string }>([ - 'distinct property_key', - 'name as event_name', - ]) - .from(TABLE_NAMES.event_property_values_mv) - .where('project_id', '=', input.projectId) - .orderBy('property_key', 'ASC') - .limit(500); - - if (input.eventName) { - builder.where('name', '=', input.eventName); - } - - const rows = await builder.execute(); - return { properties: rows }; -} - -export async function getEventPropertyValuesCore(input: { - projectId: string; - eventName: string; - propertyKey: string; -}): Promise<{ event: string; property: string; values: string[] }> { - const rows = await clix(ch) - .select<{ value: string }>(['property_value as value']) - .from(TABLE_NAMES.event_property_values_mv) - .where('project_id', '=', input.projectId) - .where('name', '=', input.eventName) - .where('property_key', '=', input.propertyKey) - .orderBy('created_at', 'DESC') - .limit(200) - .execute(); - - return { - event: input.eventName, - property: input.propertyKey, - values: rows.map((r) => r.value), - }; -} - export function registerPropertyValueTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/reports.ts b/packages/mcp/src/tools/analytics/reports.ts index 92b612afe..4f772a26f 100644 --- a/packages/mcp/src/tools/analytics/reports.ts +++ b/packages/mcp/src/tools/analytics/reports.ts @@ -1,3 +1,6 @@ +import { getReportDataCore, listDashboardsCore, listReportsCore } from '@openpanel/db'; +export { getReportDataCore, listDashboardsCore, listReportsCore }; + import { AggregateChartEngine, ChartEngine, @@ -34,101 +37,6 @@ function dashboardUrl( return `${dashboardBaseUrl()}/${organizationId}/${projectId}/dashboards/${dashboardId}`; } -export async function listDashboardsCore(input: { - projectId: string; - organizationId: string; -}) { - const dashboards = await db.dashboard.findMany({ - where: { projectId: input.projectId }, - orderBy: { createdAt: 'desc' }, - select: { id: true, name: true, projectId: true }, - }); - return dashboards.map((d) => ({ - ...d, - dashboard_url: dashboardUrl(input.organizationId, input.projectId, d.id), - })); -} - -export async function listReportsCore(input: { - projectId: string; - dashboardId: string; - organizationId: string; -}) { - const reports = await getReportsByDashboardId(input.dashboardId); - return reports.map((r) => ({ - id: r.id, - name: r.name, - chartType: r.chartType, - range: r.range, - interval: r.interval, - metric: r.metric, - series: r.series.map((s) => - s.type === 'formula' - ? { type: 'formula', id: s.id, formula: s.formula } - : { - type: 'event', - id: s.id, - name: s.name, - displayName: s.displayName, - segment: s.segment, - }, - ), - breakdowns: r.breakdowns, - dashboard_url: reportUrl(input.organizationId, input.projectId, r.id), - })); -} - -export async function getReportDataCore(input: { - projectId: string; - reportId: string; - organizationId: string; -}) { - const report = await getReportById(input.reportId); - - if (!report) { - return { error: 'Report not found', reportId: input.reportId }; - } - - if (report.projectId !== input.projectId) { - return { - error: 'Report does not belong to this project', - reportId: input.reportId, - }; - } - - const { timezone } = await getSettingsForProject(input.projectId); - const { startDate, endDate } = getChartStartEndDate(report, timezone); - const chartInput = { ...report, startDate, endDate, timezone }; - - const meta = { - id: report.id, - name: report.name, - chartType: report.chartType, - range: report.range, - interval: report.interval, - startDate, - endDate, - dashboard_url: reportUrl( - input.organizationId, - input.projectId, - input.reportId, - ), - }; - - if (report.chartType === 'funnel') { - const result = await funnelService.getFunnel(chartInput); - return { ...meta, data: result }; - } - - if (report.chartType === 'metric') { - const result = await AggregateChartEngine.execute(chartInput); - return { ...meta, data: result }; - } - - const result = await ChartEngine.execute(chartInput); - return { ...meta, data: result }; -} - export function registerReportTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/retention.ts b/packages/mcp/src/tools/analytics/retention.ts index 2649097dd..0bdb4f8b0 100644 --- a/packages/mcp/src/tools/analytics/retention.ts +++ b/packages/mcp/src/tools/analytics/retention.ts @@ -1,3 +1,5 @@ +export { getRetentionCohortCore } from '@openpanel/db'; + import { getRetentionCohortTable } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; @@ -7,10 +9,6 @@ import { withErrorHandling, } from '../shared'; -export async function getRetentionCohortCore(projectId: string) { - return getRetentionCohortTable({ projectId }); -} - export function registerRetentionTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/sessions.ts b/packages/mcp/src/tools/analytics/sessions.ts index 17868c56d..0034be6c7 100644 --- a/packages/mcp/src/tools/analytics/sessions.ts +++ b/packages/mcp/src/tools/analytics/sessions.ts @@ -1,88 +1,17 @@ -import { TABLE_NAMES, ch, clix } from '@openpanel/db'; -import type { IClickhouseSession } from '@openpanel/db'; +export { querySessionsCore, type QuerySessionsInput } from '@openpanel/db'; + +import { querySessionsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { sessionUrl } from '../dashboard-links'; import { projectIdSchema, - resolveDateRange, resolveProjectId, withErrorHandling, zDateRange, } from '../shared'; -export interface QuerySessionsInput { - projectId: string; - startDate?: string; - endDate?: string; - country?: string; - city?: string; - device?: string; - browser?: string; - os?: string; - referrer?: string; - referrerName?: string; - referrerType?: string; - profileId?: string; - limit?: number; -} - -export async function querySessionsCore( - input: QuerySessionsInput, -): Promise { - const builder = clix(ch) - .select([]) - .from(TABLE_NAMES.sessions) - .where('project_id', '=', input.projectId) - .where('sign', '=', 1); - - if (input.profileId) { - builder.where('profile_id', '=', input.profileId); - } - - if (input.referrer) { - builder.where('referrer', '=', input.referrer); - } - - if (input.referrerName) { - builder.where('referrer_name', '=', input.referrerName); - } - - if (input.referrerType) { - builder.where('referrer_type', '=', input.referrerType); - } - - if (input.device) { - builder.where('device', '=', input.device); - } - - if (input.country) { - builder.where('country', '=', input.country); - } - - if (input.city) { - builder.where('city', '=', input.city); - } - - if (input.os) { - builder.where('os', '=', input.os); - } - - if (input.browser) { - builder.where('browser', '=', input.browser); - } - - const { startDate: start, endDate: end } = resolveDateRange(input.startDate, input.endDate); - - builder.where('created_at', 'BETWEEN', [ - clix.datetime(start), - clix.datetime(end), - ]); - - return builder.limit(input.limit ?? 20).execute(); -} - export function registerSessionTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/analytics/traffic.ts b/packages/mcp/src/tools/analytics/traffic.ts index 0763c5068..3d61d0058 100644 --- a/packages/mcp/src/tools/analytics/traffic.ts +++ b/packages/mcp/src/tools/analytics/traffic.ts @@ -1,8 +1,6 @@ -import { - OverviewService, - ch, - getSettingsForProject, -} from '@openpanel/db'; +export { getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; + +import { getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -14,48 +12,6 @@ import { zDateRange, } from '../shared'; -const overviewService = new OverviewService(ch); - -export type TrafficColumn = - | 'referrer' - | 'referrer_name' - | 'referrer_type' - | 'utm_source' - | 'utm_medium' - | 'utm_campaign' - | 'country' - | 'region' - | 'city' - | 'device' - | 'browser' - | 'os'; - -export async function getTrafficBreakdownCore(input: { - projectId: string; - startDate: string; - endDate: string; - column: TrafficColumn; -}) { - return getTopGeneric(input); -} - -async function getTopGeneric(input: { - projectId: string; - startDate: string; - endDate: string; - column: TrafficColumn; -}) { - const { timezone } = await getSettingsForProject(input.projectId); - return overviewService.getTopGeneric({ - projectId: input.projectId, - filters: [], - startDate: input.startDate, - endDate: input.endDate, - column: input.column, - timezone, - }); -} - export function registerTrafficTools( server: McpServer, context: McpAuthContext, @@ -78,7 +34,7 @@ export function registerTrafficTools( withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); - return getTopGeneric({ + return getTrafficBreakdownCore({ projectId, startDate, endDate, @@ -103,7 +59,7 @@ export function registerTrafficTools( withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); - return getTopGeneric({ + return getTrafficBreakdownCore({ projectId, startDate, endDate, @@ -130,7 +86,7 @@ export function registerTrafficTools( withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); - return getTopGeneric({ + return getTrafficBreakdownCore({ projectId, startDate, endDate, diff --git a/packages/mcp/src/tools/analytics/user-flow.ts b/packages/mcp/src/tools/analytics/user-flow.ts index 8b676ed32..83eff0bd5 100644 --- a/packages/mcp/src/tools/analytics/user-flow.ts +++ b/packages/mcp/src/tools/analytics/user-flow.ts @@ -1,4 +1,6 @@ -import { SankeyService, ch, getSettingsForProject } from '@openpanel/db'; +import { getUserFlowCore } from '@openpanel/db'; +export { getUserFlowCore }; + import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -10,59 +12,6 @@ import { zDateRange, } from '../shared'; -const sankeyService = new SankeyService(ch); - -function toChartEvent(name: string) { - return { - id: name, - name, - displayName: name, - type: 'event' as const, - segment: 'event' as const, - filters: [], - }; -} - -export async function getUserFlowCore(input: { - projectId: string; - startDate: string; - endDate: string; - startEvent: string; - endEvent?: string; - mode: 'after' | 'before' | 'between'; - steps?: number; - exclude?: string[]; - include?: string[]; -}) { - if (input.mode === 'between' && !input.endEvent) { - return { error: 'endEvent is required when mode is "between"' }; - } - - const { timezone } = await getSettingsForProject(input.projectId); - const result = await sankeyService.getSankey({ - projectId: input.projectId, - startDate: input.startDate, - endDate: input.endDate, - steps: input.steps ?? 5, - mode: input.mode, - startEvent: toChartEvent(input.startEvent), - endEvent: input.endEvent ? toChartEvent(input.endEvent) : undefined, - exclude: input.exclude ?? [], - include: input.include, - timezone, - }); - - return { - mode: input.mode, - startEvent: input.startEvent, - endEvent: input.endEvent, - node_count: result.nodes.length, - link_count: result.links.length, - nodes: result.nodes, - links: result.links, - }; -} - export function registerUserFlowTools( server: McpServer, context: McpAuthContext, @@ -107,34 +56,7 @@ export function registerUserFlowTools( withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); - const { timezone } = await getSettingsForProject(projectId); - - if (mode === 'between' && !endEvent) { - return { error: 'endEvent is required when mode is "between"' }; - } - - const result = await sankeyService.getSankey({ - projectId, - startDate, - endDate, - steps: steps ?? 5, - mode, - startEvent: toChartEvent(startEvent), - endEvent: endEvent ? toChartEvent(endEvent) : undefined, - exclude: exclude ?? [], - include, - timezone, - }); - - return { - mode, - startEvent, - endEvent, - node_count: result.nodes.length, - link_count: result.links.length, - nodes: result.nodes, - links: result.links, - }; + return getUserFlowCore({ projectId, startDate, endDate, startEvent, endEvent, mode, steps, exclude, include }); }), ); } diff --git a/packages/mcp/src/tools/gsc/cannibalization.ts b/packages/mcp/src/tools/gsc/cannibalization.ts index f0f5d3233..f692e16a8 100644 --- a/packages/mcp/src/tools/gsc/cannibalization.ts +++ b/packages/mcp/src/tools/gsc/cannibalization.ts @@ -1,5 +1,5 @@ -import { getGscCannibalization } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { getGscCannibalization } from '@openpanel/db'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, @@ -9,21 +9,9 @@ import { zDateRange, } from '../shared'; -export async function gscGetCannibalizationCore(input: { - projectId: string; - startDate: string; - endDate: string; -}) { - return getGscCannibalization( - input.projectId, - input.startDate, - input.endDate, - ); -} - export function registerGscCannibalizationTools( server: McpServer, - context: McpAuthContext, + context: McpAuthContext ) { server.tool( 'gsc_get_cannibalization', @@ -33,10 +21,10 @@ export function registerGscCannibalizationTools( ...zDateRange, }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => - withErrorHandling(async () => { + withErrorHandling(() => { const projectId = resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscCannibalization(projectId, startDate, endDate); - }), + }) ); } diff --git a/packages/mcp/src/tools/gsc/overview.ts b/packages/mcp/src/tools/gsc/overview.ts index e1f12caa6..1b10bcfc5 100644 --- a/packages/mcp/src/tools/gsc/overview.ts +++ b/packages/mcp/src/tools/gsc/overview.ts @@ -1,5 +1,7 @@ -import { getGscOverview } from '@openpanel/db'; +export { gscGetOverviewCore } from '@openpanel/db'; + import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { getGscOverview } from '@openpanel/db'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { @@ -10,42 +12,9 @@ import { zDateRange, } from '../shared'; -export async function gscGetOverviewCore(input: { - projectId: string; - startDate: string; - endDate: string; - interval?: 'day' | 'week' | 'month'; -}) { - const data = await getGscOverview( - input.projectId, - input.startDate, - input.endDate, - input.interval ?? 'day', - ); - return { - data, - summary: { - total_clicks: data.reduce((s, r) => s + r.clicks, 0), - total_impressions: data.reduce((s, r) => s + r.impressions, 0), - avg_ctr: - data.length > 0 - ? Math.round( - (data.reduce((s, r) => s + r.ctr, 0) / data.length) * 10000, - ) / 100 - : 0, - avg_position: - data.length > 0 - ? Math.round( - (data.reduce((s, r) => s + r.position, 0) / data.length) * 10, - ) / 10 - : 0, - }, - }; -} - export function registerGscOverviewTools( server: McpServer, - context: McpAuthContext, + context: McpAuthContext ) { server.tool( 'gsc_get_overview', @@ -59,7 +28,12 @@ export function registerGscOverviewTools( .optional() .describe('Time interval for aggregation (default: day)'), }, - async ({ projectId: inputProjectId, startDate: sd, endDate: ed, interval }) => + async ({ + projectId: inputProjectId, + startDate: sd, + endDate: ed, + interval, + }) => withErrorHandling(async () => { const projectId = resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); @@ -67,7 +41,7 @@ export function registerGscOverviewTools( projectId, startDate, endDate, - interval ?? 'day', + interval ?? 'day' ); return { data, @@ -77,19 +51,18 @@ export function registerGscOverviewTools( avg_ctr: data.length > 0 ? Math.round( - (data.reduce((s, r) => s + r.ctr, 0) / data.length) * - 10000, + (data.reduce((s, r) => s + r.ctr, 0) / data.length) * 10_000 ) / 100 : 0, avg_position: data.length > 0 ? Math.round( (data.reduce((s, r) => s + r.position, 0) / data.length) * - 10, + 10 ) / 10 : 0, }, }; - }), + }) ); } diff --git a/packages/mcp/src/tools/gsc/pages.ts b/packages/mcp/src/tools/gsc/pages.ts index cda2336d3..ff7465c18 100644 --- a/packages/mcp/src/tools/gsc/pages.ts +++ b/packages/mcp/src/tools/gsc/pages.ts @@ -1,3 +1,5 @@ +export { gscGetPageDetailsCore, gscGetTopPagesCore } from '@openpanel/db'; + import { getGscPageDetails, getGscPages } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -10,34 +12,6 @@ import { zDateRange, } from '../shared'; -export async function gscGetTopPagesCore(input: { - projectId: string; - startDate: string; - endDate: string; - limit?: number; -}) { - return getGscPages( - input.projectId, - input.startDate, - input.endDate, - input.limit ?? 100, - ); -} - -export async function gscGetPageDetailsCore(input: { - projectId: string; - startDate: string; - endDate: string; - page: string; -}) { - return getGscPageDetails( - input.projectId, - input.page, - input.startDate, - input.endDate, - ); -} - export function registerGscPageTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/gsc/queries.ts b/packages/mcp/src/tools/gsc/queries.ts index fd0aac077..63af098a9 100644 --- a/packages/mcp/src/tools/gsc/queries.ts +++ b/packages/mcp/src/tools/gsc/queries.ts @@ -1,4 +1,7 @@ +export { type GscQueryOpportunity, gscGetQueryDetailsCore, gscGetQueryOpportunitiesCore, gscGetTopQueriesCore } from '@openpanel/db'; + import { getGscQueryDetails, getGscQueries } from '@openpanel/db'; +import type { GscQueryOpportunity } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -10,24 +13,6 @@ import { zDateRange, } from '../shared'; -export interface GscQueryOpportunity { - query: string; - clicks: number; - impressions: number; - ctr: number; - position: number; - opportunity_score: number; - reason: string; -} - -/** - * Identify low-hanging-fruit queries: - * - Position between 4-20 (ranking but not on page 1 top 3) - * - Reasonable impression volume (signal of real search demand) - * - CTR below benchmark for that position (room to improve) - * - * Opportunity score = impressions * (1 / position) — higher is better - */ function computeOpportunities( queries: Array<{ query: string; @@ -37,7 +22,6 @@ function computeOpportunities( position: number; }>, ): GscQueryOpportunity[] { - // Expected CTR benchmarks by position bucket const ctrBenchmarks: Record = { '1': 0.28, '2': 0.15, @@ -88,57 +72,6 @@ function computeOpportunities( .slice(0, 50); } -export async function gscGetTopQueriesCore(input: { - projectId: string; - startDate: string; - endDate: string; - limit?: number; -}) { - return getGscQueries( - input.projectId, - input.startDate, - input.endDate, - input.limit ?? 100, - ); -} - -export async function gscGetQueryOpportunitiesCore(input: { - projectId: string; - startDate: string; - endDate: string; - minImpressions?: number; -}) { - const queries = await getGscQueries( - input.projectId, - input.startDate, - input.endDate, - 5000, - ); - const filtered = queries.filter( - (q) => q.impressions >= (input.minImpressions ?? 50), - ); - const opportunities = computeOpportunities(filtered); - return { - opportunities, - total_analyzed: filtered.length, - min_impressions: input.minImpressions ?? 50, - }; -} - -export async function gscGetQueryDetailsCore(input: { - projectId: string; - startDate: string; - endDate: string; - query: string; -}) { - return getGscQueryDetails( - input.projectId, - input.query, - input.startDate, - input.endDate, - ); -} - export function registerGscQueryTools( server: McpServer, context: McpAuthContext, diff --git a/packages/mcp/src/tools/projects.ts b/packages/mcp/src/tools/projects.ts index b034180c8..136312be8 100644 --- a/packages/mcp/src/tools/projects.ts +++ b/packages/mcp/src/tools/projects.ts @@ -1,49 +1,10 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +export { listProjectsCore } from '@openpanel/db'; + import { db } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../auth'; import { withErrorHandling } from './shared'; -export async function listProjectsCore(input: { - clientType: 'root' | 'read'; - organizationId: string; - projectId: string | null; -}) { - if (input.clientType === 'root') { - const projects = await db.project.findMany({ - where: { organizationId: input.organizationId }, - orderBy: { eventsCount: 'desc' }, - select: { - id: true, - name: true, - organizationId: true, - eventsCount: true, - domain: true, - types: true, - }, - }); - return { clientType: 'root', projects }; - } - - const project = input.projectId - ? await db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - organizationId: true, - eventsCount: true, - domain: true, - types: true, - }, - }) - : null; - - return { - clientType: 'read', - projects: project ? [project] : [], - }; -} - export function registerProjectTools( server: McpServer, context: McpAuthContext, From 3aabcf9697d8ea580d21bd95f2130339c3d80b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 15:05:43 +0200 Subject: [PATCH 09/18] cleanup mcp --- packages/mcp/src/tools/analytics/active-users.ts | 2 -- packages/mcp/src/tools/analytics/engagement.ts | 2 -- packages/mcp/src/tools/analytics/event-names.ts | 2 -- packages/mcp/src/tools/analytics/events.ts | 2 -- packages/mcp/src/tools/analytics/funnel.ts | 1 - packages/mcp/src/tools/analytics/groups.ts | 2 -- packages/mcp/src/tools/analytics/overview.ts | 2 -- packages/mcp/src/tools/analytics/page-performance.ts | 2 -- packages/mcp/src/tools/analytics/pages.ts | 1 - packages/mcp/src/tools/analytics/profile-metrics.ts | 2 -- packages/mcp/src/tools/analytics/profiles.test.ts | 2 +- packages/mcp/src/tools/analytics/profiles.ts | 3 +-- packages/mcp/src/tools/analytics/property-values.ts | 2 -- packages/mcp/src/tools/analytics/reports.ts | 3 --- packages/mcp/src/tools/analytics/retention.ts | 2 -- packages/mcp/src/tools/analytics/sessions.ts | 2 -- packages/mcp/src/tools/analytics/traffic.ts | 2 -- packages/mcp/src/tools/analytics/user-flow.ts | 1 - packages/mcp/src/tools/gsc/overview.ts | 2 -- packages/mcp/src/tools/gsc/pages.ts | 2 -- packages/mcp/src/tools/gsc/queries.ts | 2 -- packages/mcp/src/tools/projects.ts | 2 -- 22 files changed, 2 insertions(+), 41 deletions(-) diff --git a/packages/mcp/src/tools/analytics/active-users.ts b/packages/mcp/src/tools/analytics/active-users.ts index 9b67dfcbe..0b6e69455 100644 --- a/packages/mcp/src/tools/analytics/active-users.ts +++ b/packages/mcp/src/tools/analytics/active-users.ts @@ -1,5 +1,3 @@ -export { getRollingActiveUsersCore, getWeeklyRetentionSeriesCore } from '@openpanel/db'; - import { getRollingActiveUsers, getRetentionSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/engagement.ts b/packages/mcp/src/tools/analytics/engagement.ts index 89997f246..b9b9b0822 100644 --- a/packages/mcp/src/tools/analytics/engagement.ts +++ b/packages/mcp/src/tools/analytics/engagement.ts @@ -1,5 +1,3 @@ -export { getEngagementCore } from '@openpanel/db'; - import { getRetentionLastSeenSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; diff --git a/packages/mcp/src/tools/analytics/event-names.ts b/packages/mcp/src/tools/analytics/event-names.ts index c9556282f..9f0a17ed7 100644 --- a/packages/mcp/src/tools/analytics/event-names.ts +++ b/packages/mcp/src/tools/analytics/event-names.ts @@ -1,5 +1,3 @@ -export { getTopEventNames, listEventNamesCore } from '@openpanel/db'; - import { getTopEventNames } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; diff --git a/packages/mcp/src/tools/analytics/events.ts b/packages/mcp/src/tools/analytics/events.ts index 8fc231bad..fec83071f 100644 --- a/packages/mcp/src/tools/analytics/events.ts +++ b/packages/mcp/src/tools/analytics/events.ts @@ -1,5 +1,3 @@ -export { queryEventsCore, type QueryEventsInput } from '@openpanel/db'; - import { queryEventsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/funnel.ts b/packages/mcp/src/tools/analytics/funnel.ts index ff04467c3..8d0c1fda8 100644 --- a/packages/mcp/src/tools/analytics/funnel.ts +++ b/packages/mcp/src/tools/analytics/funnel.ts @@ -1,5 +1,4 @@ import { getFunnelCore } from '@openpanel/db'; -export { getFunnelCore }; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/groups.ts b/packages/mcp/src/tools/analytics/groups.ts index 513821129..dc05427f7 100644 --- a/packages/mcp/src/tools/analytics/groups.ts +++ b/packages/mcp/src/tools/analytics/groups.ts @@ -1,5 +1,3 @@ -export { findGroupsCore, getGroupCore, listGroupTypesCore } from '@openpanel/db'; - import { getGroupById, getGroupList, getGroupMemberProfiles, getGroupTypes } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/overview.ts b/packages/mcp/src/tools/analytics/overview.ts index ab30cd73d..ba4c06378 100644 --- a/packages/mcp/src/tools/analytics/overview.ts +++ b/packages/mcp/src/tools/analytics/overview.ts @@ -1,5 +1,3 @@ -export { getAnalyticsOverviewCore, type GetAnalyticsOverviewInput } from '@openpanel/db'; - import { getAnalyticsOverviewCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/page-performance.ts b/packages/mcp/src/tools/analytics/page-performance.ts index f72b62346..86e4db5bd 100644 --- a/packages/mcp/src/tools/analytics/page-performance.ts +++ b/packages/mcp/src/tools/analytics/page-performance.ts @@ -1,5 +1,3 @@ -export { getPagePerformanceCore } from '@openpanel/db'; - import { PagesService, ch, getSettingsForProject } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/pages.ts b/packages/mcp/src/tools/analytics/pages.ts index e7e4dffd9..1c86461fa 100644 --- a/packages/mcp/src/tools/analytics/pages.ts +++ b/packages/mcp/src/tools/analytics/pages.ts @@ -1,5 +1,4 @@ import { getEntryExitPagesCore, getTopPagesCore } from '@openpanel/db'; -export { getEntryExitPagesCore, getTopPagesCore }; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts index c4bbb60b0..c768a5290 100644 --- a/packages/mcp/src/tools/analytics/profile-metrics.ts +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -1,5 +1,3 @@ -export { getProfileMetricsCore } from '@openpanel/db'; - import { getProfileMetrics } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/profiles.test.ts b/packages/mcp/src/tools/analytics/profiles.test.ts index ce9ac74c8..fb766278b 100644 --- a/packages/mcp/src/tools/analytics/profiles.test.ts +++ b/packages/mcp/src/tools/analytics/profiles.test.ts @@ -24,7 +24,7 @@ vi.mock('@openpanel/db', () => ({ }), })); -import { findProfilesCore } from './profiles'; +import { findProfilesCore } from '@openpanel/db'; function capturedSql(): string { return mockChQuery.mock.calls[0]?.[0] as string; diff --git a/packages/mcp/src/tools/analytics/profiles.ts b/packages/mcp/src/tools/analytics/profiles.ts index a4c4867c4..331dbaf79 100644 --- a/packages/mcp/src/tools/analytics/profiles.ts +++ b/packages/mcp/src/tools/analytics/profiles.ts @@ -1,5 +1,4 @@ -import { findProfilesCore, getProfileSessionsCore, getProfileWithEvents, type FindProfilesInput } from '@openpanel/db'; -export { findProfilesCore, getProfileSessionsCore, getProfileWithEvents, type FindProfilesInput }; +import { findProfilesCore, getProfileSessionsCore, getProfileWithEvents } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/property-values.ts b/packages/mcp/src/tools/analytics/property-values.ts index 29fc6dddb..23b03b769 100644 --- a/packages/mcp/src/tools/analytics/property-values.ts +++ b/packages/mcp/src/tools/analytics/property-values.ts @@ -1,5 +1,3 @@ -export { listEventPropertiesCore, getEventPropertyValuesCore } from '@openpanel/db'; - import { TABLE_NAMES, ch, clix } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/reports.ts b/packages/mcp/src/tools/analytics/reports.ts index 4f772a26f..4f85f4792 100644 --- a/packages/mcp/src/tools/analytics/reports.ts +++ b/packages/mcp/src/tools/analytics/reports.ts @@ -1,6 +1,3 @@ -import { getReportDataCore, listDashboardsCore, listReportsCore } from '@openpanel/db'; -export { getReportDataCore, listDashboardsCore, listReportsCore }; - import { AggregateChartEngine, ChartEngine, diff --git a/packages/mcp/src/tools/analytics/retention.ts b/packages/mcp/src/tools/analytics/retention.ts index 0bdb4f8b0..6e9b27543 100644 --- a/packages/mcp/src/tools/analytics/retention.ts +++ b/packages/mcp/src/tools/analytics/retention.ts @@ -1,5 +1,3 @@ -export { getRetentionCohortCore } from '@openpanel/db'; - import { getRetentionCohortTable } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; diff --git a/packages/mcp/src/tools/analytics/sessions.ts b/packages/mcp/src/tools/analytics/sessions.ts index 0034be6c7..7e9ee2e2b 100644 --- a/packages/mcp/src/tools/analytics/sessions.ts +++ b/packages/mcp/src/tools/analytics/sessions.ts @@ -1,5 +1,3 @@ -export { querySessionsCore, type QuerySessionsInput } from '@openpanel/db'; - import { querySessionsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/traffic.ts b/packages/mcp/src/tools/analytics/traffic.ts index 3d61d0058..db8a8ee24 100644 --- a/packages/mcp/src/tools/analytics/traffic.ts +++ b/packages/mcp/src/tools/analytics/traffic.ts @@ -1,5 +1,3 @@ -export { getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; - import { getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/analytics/user-flow.ts b/packages/mcp/src/tools/analytics/user-flow.ts index 83eff0bd5..32cb76764 100644 --- a/packages/mcp/src/tools/analytics/user-flow.ts +++ b/packages/mcp/src/tools/analytics/user-flow.ts @@ -1,5 +1,4 @@ import { getUserFlowCore } from '@openpanel/db'; -export { getUserFlowCore }; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/gsc/overview.ts b/packages/mcp/src/tools/gsc/overview.ts index 1b10bcfc5..3fd52568e 100644 --- a/packages/mcp/src/tools/gsc/overview.ts +++ b/packages/mcp/src/tools/gsc/overview.ts @@ -1,5 +1,3 @@ -export { gscGetOverviewCore } from '@openpanel/db'; - import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getGscOverview } from '@openpanel/db'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/gsc/pages.ts b/packages/mcp/src/tools/gsc/pages.ts index ff7465c18..d2f3124c2 100644 --- a/packages/mcp/src/tools/gsc/pages.ts +++ b/packages/mcp/src/tools/gsc/pages.ts @@ -1,5 +1,3 @@ -export { gscGetPageDetailsCore, gscGetTopPagesCore } from '@openpanel/db'; - import { getGscPageDetails, getGscPages } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; diff --git a/packages/mcp/src/tools/gsc/queries.ts b/packages/mcp/src/tools/gsc/queries.ts index 63af098a9..5f3cd7df8 100644 --- a/packages/mcp/src/tools/gsc/queries.ts +++ b/packages/mcp/src/tools/gsc/queries.ts @@ -1,5 +1,3 @@ -export { type GscQueryOpportunity, gscGetQueryDetailsCore, gscGetQueryOpportunitiesCore, gscGetTopQueriesCore } from '@openpanel/db'; - import { getGscQueryDetails, getGscQueries } from '@openpanel/db'; import type { GscQueryOpportunity } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; diff --git a/packages/mcp/src/tools/projects.ts b/packages/mcp/src/tools/projects.ts index 136312be8..c21110865 100644 --- a/packages/mcp/src/tools/projects.ts +++ b/packages/mcp/src/tools/projects.ts @@ -1,5 +1,3 @@ -export { listProjectsCore } from '@openpanel/db'; - import { db } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../auth'; From eed35307307e7af9dae73a6ef26bece5d9b3b279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 21:55:21 +0200 Subject: [PATCH 10/18] improve tests --- .github/workflows/docker-build.yml | 26 +- .../src/controllers/insights.controller.ts | 18 +- apps/api/src/controllers/query.controller.ts | 14 - apps/api/src/integration/setup.ts | 9 - apps/api/src/routes/query.router.test.ts | 186 ++++++------- apps/api/src/test-setup.ts | 6 - apps/api/vitest.config.ts | 7 +- biome.json | 3 +- packages/mcp/src/integration/setup.ts | 38 --- packages/mcp/src/integration/tools.test.ts | 147 ++++++---- packages/mcp/src/test-setup.ts | 11 - .../mcp/src/tools/analytics/profiles.test.ts | 67 +++-- packages/mcp/vitest.config.ts | 9 +- .../clickhouse-schema.sql | 0 test/{clickhouse-fixtures.ts => fixtures.ts} | 263 +++++++++++++++--- test/global-setup.ts | 32 +++ test/test-setup.ts | 27 ++ vitest.config.ts | 7 + vitest.shared.ts | 7 +- 19 files changed, 556 insertions(+), 321 deletions(-) delete mode 100644 apps/api/src/integration/setup.ts delete mode 100644 apps/api/src/test-setup.ts delete mode 100644 packages/mcp/src/integration/setup.ts delete mode 100644 packages/mcp/src/test-setup.ts rename {packages/mcp/src/integration => test}/clickhouse-schema.sql (100%) rename test/{clickhouse-fixtures.ts => fixtures.ts} (52%) create mode 100644 test/global-setup.ts create mode 100644 test/test-setup.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 1af6c7477..57786a3cf 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -36,6 +36,19 @@ jobs: lint-and-test: runs-on: ubuntu-latest services: + postgres: + image: postgres:16-alpine + ports: + - 5432:5432 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 20 redis: image: redis:7-alpine ports: @@ -89,8 +102,17 @@ jobs: - name: Codegen run: pnpm codegen - - name: Run MCP tests - run: pnpm --filter @openpanel/mcp test:run + - name: Migrate database + run: pnpm migrate:deploy + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + + - name: Run tests + run: pnpm test + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + CLICKHOUSE_URL: http://localhost:8123/openpanel + REDIS_URL: redis://localhost:6379 # - name: Run Biome # run: pnpm lint diff --git a/apps/api/src/controllers/insights.controller.ts b/apps/api/src/controllers/insights.controller.ts index 0f276418d..36a15556c 100644 --- a/apps/api/src/controllers/insights.controller.ts +++ b/apps/api/src/controllers/insights.controller.ts @@ -21,7 +21,7 @@ export async function getMetrics( Params: { projectId: string }; Querystring: z.infer; }>, - reply: FastifyReply, + reply: FastifyReply ) { const { timezone } = await getSettingsForProject(request.params.projectId); const { startDate, endDate } = getChartStartEndDate(request.query, timezone); @@ -33,13 +33,13 @@ export async function getMetrics( endDate, interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day', timezone, - }), + }) ); } export async function getLiveVisitors( request: FastifyRequest<{ Params: { projectId: string } }>, - reply: FastifyReply, + reply: FastifyReply ) { reply.send({ visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId), @@ -59,8 +59,7 @@ export async function getPages( request: FastifyRequest<{ Params: { projectId: string }; Querystring: z.infer; - }>, - reply: FastifyReply, + }> ) { const { timezone } = await getSettingsForProject(request.params.projectId); const { startDate, endDate } = getChartStartEndDate(request.query, timezone); @@ -113,10 +112,13 @@ export function getOverviewGeneric(column: OverviewColumn) { Params: { projectId: string }; Querystring: z.infer; }>, - reply: FastifyReply, + reply: FastifyReply ) => { const { timezone } = await getSettingsForProject(request.params.projectId); - const { startDate, endDate } = getChartStartEndDate(request.query, timezone); + const { startDate, endDate } = getChartStartEndDate( + request.query, + timezone + ); reply.send( await overviewService.getTopGeneric({ column, @@ -125,7 +127,7 @@ export function getOverviewGeneric(column: OverviewColumn) { startDate, endDate, timezone, - }), + }) ); }; } diff --git a/apps/api/src/controllers/query.controller.ts b/apps/api/src/controllers/query.controller.ts index ac3395e02..468e7cef4 100644 --- a/apps/api/src/controllers/query.controller.ts +++ b/apps/api/src/controllers/query.controller.ts @@ -33,7 +33,6 @@ import { listEventNamesCore, listEventPropertiesCore, listGroupTypesCore, - listProjectsCore, listReportsCore, queryEventsCore, querySessionsCore, @@ -110,19 +109,6 @@ function getClientType(req: RequestWithProjectParam): 'root' | 'read' { // --------------------------------------------------------------------------- // Projects -// --------------------------------------------------------------------------- - -export async function listProjects(req: FastifyRequest, reply: FastifyReply) { - const client = req.client!; - return reply.send( - await listProjectsCore({ - clientType: getClientType(req as RequestWithProjectParam), - organizationId: client.organizationId, - projectId: client.projectId ?? null, - }) - ); -} - // --------------------------------------------------------------------------- // Analytics — overview // --------------------------------------------------------------------------- diff --git a/apps/api/src/integration/setup.ts b/apps/api/src/integration/setup.ts deleted file mode 100644 index e10778782..000000000 --- a/apps/api/src/integration/setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { setupFixtures, teardownFixtures } from '../../../../test/clickhouse-fixtures'; - -export { FIXTURE } from '../../../../test/clickhouse-fixtures'; - -export const TEST_PROJECT_ID = 'api-e2e-test'; -export const TEST_ORG_ID = 'api-e2e-org'; - -export const setup = () => setupFixtures(TEST_PROJECT_ID); -export const teardown = () => teardownFixtures(TEST_PROJECT_ID); diff --git a/apps/api/src/routes/query.router.test.ts b/apps/api/src/routes/query.router.test.ts index 026f3fa53..c91a8f087 100644 --- a/apps/api/src/routes/query.router.test.ts +++ b/apps/api/src/routes/query.router.test.ts @@ -1,8 +1,8 @@ /** - * Integration tests for the /query/* REST routes. + * Integration tests for the /insights/* REST routes. * * Auth is mocked (getClientByIdCached, verifyPassword, getCache). - * ClickHouse is real — uses the local Docker instance (pnpm dock:up). + * ClickHouse and Postgres are real — uses the local Docker instance (pnpm dock:up). * * Fixture data (see apps/api/src/tests/setup.ts): * Alice — 3 events: session_start, page_view(/home), session_end — 2 days ago — Chrome / US @@ -14,61 +14,36 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; // ─── Module mocks (hoisted before imports) ──────────────────────────────────── +// Mock only getClientByIdCached so auth can be controlled per-test. +// importOriginal is fine here because real Postgres is available. vi.mock('@openpanel/db', async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - // Auth: getClientByIdCached is controlled per-test via mockResolvedValue - getClientByIdCached: vi.fn(), - // Settings: always return UTC so overview / retention tests are stable - getSettingsForProject: vi.fn().mockResolvedValue({ timezone: 'UTC' }), - // Prisma client used by listProjectsCore — return a minimal project stub - db: { - ...actual.db, - project: { - findMany: vi.fn().mockResolvedValue([ - { - id: 'api-e2e-test', - name: 'E2E Test Project', - organizationId: 'api-e2e-org', - eventsCount: 8, - domain: null, - types: [], - }, - ]), - findUnique: vi.fn().mockResolvedValue({ - id: 'api-e2e-test', - name: 'E2E Test Project', - organizationId: 'api-e2e-org', - eventsCount: 8, - domain: null, - types: [], - }), - }, - }, - }; + return { ...actual, getClientByIdCached: vi.fn() }; }); -// Password verification is always truthy in tests +// Password verification is always truthy in tests. vi.mock('@openpanel/common/server', async (importOriginal) => { - const actual = await importOriginal(); + const actual = + await importOriginal(); return { ...actual, verifyPassword: vi.fn().mockResolvedValue(true) }; }); // Bypass Redis caching — no real ioredis connections in tests. // getRedisCache must return a truthy object so that @trpc-limiter/redis's // RateLimiterRedis constructor doesn't throw "storeClient is not set". -// The fake client methods are never called in our tests (we only test /query/*). vi.mock('@openpanel/redis', async (importOriginal) => { const actual = await importOriginal(); - // Minimal fake that satisfies RateLimiterRedis's truthy-client check const fakeRedisClient = new Proxy( {}, - { get: (_t, p) => (p === 'status' ? 'ready' : vi.fn().mockResolvedValue(null)) }, + { + get: (_t, p) => + p === 'status' ? 'ready' : vi.fn().mockResolvedValue(null), + } ); return { ...actual, - getCache: async (_key: string, _ttl: number, fn: () => Promise) => fn(), + getCache: async (_key: string, _ttl: number, fn: () => Promise) => + fn(), getRedisCache: vi.fn().mockReturnValue(fakeRedisClient), }; }); @@ -78,7 +53,7 @@ vi.mock('@openpanel/redis', async (importOriginal) => { import { ClientType, getClientByIdCached } from '@openpanel/db'; import type { FastifyInstance } from 'fastify'; import { buildApp } from '../app'; -import { FIXTURE, TEST_ORG_ID, TEST_PROJECT_ID, setup, teardown } from '../integration/setup'; +import { FIXTURE, TEST_ORG_ID, TEST_PROJECT_ID } from '../../../../test/global-setup'; // ─── Test client constants ──────────────────────────────────────────────────── @@ -112,22 +87,17 @@ let app: FastifyInstance; beforeAll(async () => { vi.mocked(getClientByIdCached).mockResolvedValue(READ_CLIENT as any); - app = await buildApp({ testing: true }); await app.ready(); - - // Insert ClickHouse fixture data - await setup(); }, 30_000); afterAll(async () => { - await teardown(); await app.close(); }, 10_000); // ─── Helpers ────────────────────────────────────────────────────────────────── -async function get(path: string, headers: Record = AUTH) { +function get(path: string, headers: Record = AUTH) { return app.inject({ method: 'GET', url: path, headers }); } @@ -135,12 +105,12 @@ async function get(path: string, headers: Record = AUTH) { describe('auth', () => { it('returns 401 when no client-id header is present', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events/names`, {}); + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`, {}); expect(res.statusCode).toBe(401); }); it('returns 401 when client-id is not a valid UUID', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events/names`, { + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`, { 'openpanel-client-id': 'not-a-uuid', 'openpanel-client-secret': CLIENT_SECRET, }); @@ -148,35 +118,23 @@ describe('auth', () => { }); it('returns 200 with valid credentials', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events/names`); + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`); expect(res.statusCode).toBe(200); }); }); -// ─── Projects ───────────────────────────────────────────────────────────────── - -describe('GET /query/projects', () => { - it('returns project list for read client', async () => { - const res = await get('/query/projects'); - expect(res.statusCode).toBe(200); - const body = res.json(); - expect(body).toHaveProperty('projects'); - expect(Array.isArray(body.projects)).toBe(true); - }); -}); - // ─── Events ─────────────────────────────────────────────────────────────────── -describe('GET /query/:projectId/events/names', () => { +describe('GET /insights/:projectId/events/names', () => { it('returns event_names array', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events/names`); + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); it('includes events from fixture data', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events/names`); + const res = await get(`/insights/${TEST_PROJECT_ID}/events/names`); const body = res.json(); expect(body).toContain('session_start'); expect(body).toContain('page_view'); @@ -184,47 +142,49 @@ describe('GET /query/:projectId/events/names', () => { }); }); -describe('GET /query/:projectId/events', () => { +describe('GET /insights/:projectId/events', () => { it('returns events array', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events`); + const res = await get(`/insights/${TEST_PROJECT_ID}/events`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); it('respects limit parameter', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events?limit=2`); + const res = await get(`/insights/${TEST_PROJECT_ID}/events?limit=2`); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.length).toBeLessThanOrEqual(2); }); it('filters by eventNames', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events?eventNames=purchase`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/events?eventNames=purchase` + ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.every((e: any) => e.name === 'purchase')).toBe(true); }); it('returns 400 when limit is out of range', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events?limit=9999`); + const res = await get(`/insights/${TEST_PROJECT_ID}/events?limit=9999`); expect(res.statusCode).toBe(400); }); }); -describe('GET /query/:projectId/events/properties', () => { +describe('GET /insights/:projectId/events/properties', () => { it('returns properties array', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events/properties`); + const res = await get(`/insights/${TEST_PROJECT_ID}/events/properties`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body.properties)).toBe(true); }); }); -describe('GET /query/:projectId/events/property-values', () => { +describe('GET /insights/:projectId/events/property_values', () => { it('returns values for a known property', async () => { const res = await get( - `/query/${TEST_PROJECT_ID}/events/property-values?eventName=page_view&propertyKey=path`, + `/insights/${TEST_PROJECT_ID}/events/property_values?eventName=page_view&propertyKey=path` ); expect(res.statusCode).toBe(200); const body = res.json(); @@ -232,23 +192,25 @@ describe('GET /query/:projectId/events/property-values', () => { }); it('returns 400 when required params are missing', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/events/property-values?eventName=page_view`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/events/property_values?eventName=page_view` + ); expect(res.statusCode).toBe(400); }); }); // ─── Profiles ───────────────────────────────────────────────────────────────── -describe('GET /query/:projectId/profiles', () => { +describe('GET /insights/:projectId/profiles', () => { it('returns profiles array', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/profiles`); + const res = await get(`/insights/${TEST_PROJECT_ID}/profiles`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); it('includes fixture profiles', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/profiles`); + const res = await get(`/insights/${TEST_PROJECT_ID}/profiles`); const body = res.json(); const emails = body.map((p: any) => p.email); expect(emails).toContain('alice@example.com'); @@ -256,7 +218,9 @@ describe('GET /query/:projectId/profiles', () => { }); it('filters by browser via query params', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/profiles?browser=Firefox`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles?browser=Firefox` + ); expect(res.statusCode).toBe(200); const body = res.json(); // Charlie uses Firefox; Alice uses Chrome — only Charlie should appear @@ -266,14 +230,18 @@ describe('GET /query/:projectId/profiles', () => { }); }); -describe('GET /query/:projectId/profiles/:profileId', () => { +describe('GET /insights/:projectId/profiles/:profileId', () => { it('returns 404 for unknown profile', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/profiles/does-not-exist`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles/does-not-exist` + ); expect(res.statusCode).toBe(404); }); it('returns profile data for known profile', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.alice}`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.alice}` + ); expect(res.statusCode).toBe(200); const body = res.json(); expect(body).toHaveProperty('profile'); @@ -281,9 +249,11 @@ describe('GET /query/:projectId/profiles/:profileId', () => { }); }); -describe('GET /query/:projectId/profiles/:profileId/sessions', () => { +describe('GET /insights/:projectId/profiles/:profileId/sessions', () => { it('returns sessions for charlie', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.charlie}/sessions`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/profiles/${FIXTURE.profiles.charlie}/sessions` + ); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); @@ -293,16 +263,16 @@ describe('GET /query/:projectId/profiles/:profileId/sessions', () => { // ─── Sessions ───────────────────────────────────────────────────────────────── -describe('GET /query/:projectId/sessions', () => { +describe('GET /insights/:projectId/sessions', () => { it('returns sessions array', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/sessions`); + const res = await get(`/insights/${TEST_PROJECT_ID}/sessions`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); it('fixture has at least 3 sessions (alice-1, charlie-1, charlie-2)', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/sessions?limit=100`); + const res = await get(`/insights/${TEST_PROJECT_ID}/sessions?limit=100`); const body = res.json(); expect(body.length).toBeGreaterThanOrEqual(3); }); @@ -310,9 +280,9 @@ describe('GET /query/:projectId/sessions', () => { // ─── Analytics overview ─────────────────────────────────────────────────────── -describe('GET /query/:projectId/overview', () => { +describe('GET /insights/:projectId/overview', () => { it('returns analytics overview', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/overview`); + const res = await get(`/insights/${TEST_PROJECT_ID}/overview`); expect(res.statusCode).toBe(200); const body = res.json(); // Overview returns an object with at least some metrics @@ -320,22 +290,24 @@ describe('GET /query/:projectId/overview', () => { }); it('accepts interval param', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/overview?interval=day`); + const res = await get(`/insights/${TEST_PROJECT_ID}/overview?interval=day`); expect(res.statusCode).toBe(200); }); it('returns 400 for invalid interval', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/overview?interval=invalid`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/overview?interval=invalid` + ); expect(res.statusCode).toBe(400); }); }); // ─── Funnel ─────────────────────────────────────────────────────────────────── -describe('GET /query/:projectId/funnel', () => { +describe('GET /insights/:projectId/funnel', () => { it('returns funnel data for valid steps', async () => { const res = await get( - `/query/${TEST_PROJECT_ID}/funnel?steps=session_start&steps=session_end`, + `/insights/${TEST_PROJECT_ID}/funnel?steps=session_start&steps=session_end` ); expect(res.statusCode).toBe(200); const body = res.json(); @@ -343,62 +315,66 @@ describe('GET /query/:projectId/funnel', () => { }); it('returns 400 when fewer than 2 steps are provided', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/funnel?steps[]=session_start`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/funnel?steps[]=session_start` + ); expect(res.statusCode).toBe(400); }); it('returns 400 when steps param is missing entirely', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/funnel`); + const res = await get(`/insights/${TEST_PROJECT_ID}/funnel`); expect(res.statusCode).toBe(400); }); }); // ─── Pages ──────────────────────────────────────────────────────────────────── -describe('GET /query/:projectId/pages/top', () => { +describe('GET /insights/:projectId/pages/top', () => { it('returns top pages', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/pages/top`); + const res = await get(`/insights/${TEST_PROJECT_ID}/pages/top`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); }); -describe('GET /query/:projectId/pages/entry-exit', () => { +describe('GET /insights/:projectId/pages/entry_exit', () => { it('defaults to entry mode', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/pages/entry-exit`); + const res = await get(`/insights/${TEST_PROJECT_ID}/pages/entry_exit`); expect(res.statusCode).toBe(200); }); it('accepts mode=exit', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/pages/entry-exit?mode=exit`); + const res = await get( + `/insights/${TEST_PROJECT_ID}/pages/entry_exit?mode=exit` + ); expect(res.statusCode).toBe(200); }); }); // ─── Traffic ────────────────────────────────────────────────────────────────── -describe('GET /query/:projectId/traffic/referrers', () => { +describe('GET /insights/:projectId/traffic/referrers', () => { it('returns referrer breakdown', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/traffic/referrers`); + const res = await get(`/insights/${TEST_PROJECT_ID}/traffic/referrers`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); }); -describe('GET /query/:projectId/traffic/geo', () => { +describe('GET /insights/:projectId/traffic/geo', () => { it('returns geo breakdown', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/traffic/geo`); + const res = await get(`/insights/${TEST_PROJECT_ID}/traffic/geo`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); }); }); -describe('GET /query/:projectId/traffic/devices', () => { +describe('GET /insights/:projectId/traffic/devices', () => { it('returns device breakdown', async () => { - const res = await get(`/query/${TEST_PROJECT_ID}/traffic/devices`); + const res = await get(`/insights/${TEST_PROJECT_ID}/traffic/devices`); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body)).toBe(true); diff --git a/apps/api/src/test-setup.ts b/apps/api/src/test-setup.ts deleted file mode 100644 index a4327de6a..000000000 --- a/apps/api/src/test-setup.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { afterAll } from 'vitest'; -import { originalCh } from '@openpanel/db'; - -// Close the ClickHouse connection pool after all tests finish to allow the -// Vitest process to exit cleanly. -afterAll(() => originalCh.close()); diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index e4ff7745d..f87a2039f 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -1,8 +1,3 @@ -import { mergeConfig } from 'vitest/config'; import { getSharedVitestConfig } from '../../vitest.shared'; -export default mergeConfig(getSharedVitestConfig({ __dirname }), { - test: { - setupFiles: ['./src/test-setup.ts'], - }, -}); +export default getSharedVitestConfig({ __dirname }); diff --git a/biome.json b/biome.json index d1a47dec9..4db27e00a 100644 --- a/biome.json +++ b/biome.json @@ -67,7 +67,8 @@ "noDelete": "off", "noAccumulatingSpread": "off", "noBarrelFile": "off", - "noNamespaceImport": "off" + "noNamespaceImport": "off", + "useTopLevelRegex": "off" }, "suspicious": { "noExplicitAny": "off", diff --git a/packages/mcp/src/integration/setup.ts b/packages/mcp/src/integration/setup.ts deleted file mode 100644 index adf43d115..000000000 --- a/packages/mcp/src/integration/setup.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createClient } from '@openpanel/db'; -import { setupFixtures, teardownFixtures } from '../../../../test/clickhouse-fixtures'; - -export { FIXTURE } from '../../../../test/clickhouse-fixtures'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export const TEST_PROJECT_ID = 'mcp-integration-test'; - -async function ensureSchema() { - const client = createClient({ - url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123', - }); - - const sql = readFileSync(join(__dirname, 'clickhouse-schema.sql'), 'utf8'); - const statements = sql - .split('\n') - .filter((line) => !line.trimStart().startsWith('--')) - .join('\n') - .split(';') - .map((s) => s.trim()) - .filter((s) => s.length > 0); - - await Promise.all(statements.map((statement) => client.command({ query: statement }))); - await client.close(); -} - -export async function setup() { - await ensureSchema(); - await setupFixtures(TEST_PROJECT_ID); -} - -export async function teardown() { - await teardownFixtures(TEST_PROJECT_ID); -} diff --git a/packages/mcp/src/integration/tools.test.ts b/packages/mcp/src/integration/tools.test.ts index 69432b74f..7af6927e0 100644 --- a/packages/mcp/src/integration/tools.test.ts +++ b/packages/mcp/src/integration/tools.test.ts @@ -14,26 +14,19 @@ * that function — all ClickHouse queries still run for real. */ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; - -vi.mock('@openpanel/db', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getSettingsForProject: vi.fn().mockResolvedValue({ timezone: 'UTC' }), - }; -}); +import { describe, expect, it, vi } from 'vitest'; // Bypass Redis caching — prevents ioredis TCP connections that hang the process vi.mock('@openpanel/redis', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getCache: async (_key: string, _ttl: number, fn: () => Promise) => fn(), + getCache: async (_key: string, _ttl: number, fn: () => Promise) => + fn(), }; }); -import { FIXTURE, setup, teardown } from './setup'; +import { FIXTURE, TEST_PROJECT_ID } from '../../../../test/global-setup'; import { registerActiveUserTools } from '../tools/analytics/active-users'; import { registerEngagementTools } from '../tools/analytics/engagement'; import { registerEventNameTools } from '../tools/analytics/event-names'; @@ -50,7 +43,6 @@ import { registerRetentionTools } from '../tools/analytics/retention'; import { registerSessionTools } from '../tools/analytics/sessions'; import { registerTrafficTools } from '../tools/analytics/traffic'; import { registerUserFlowTools } from '../tools/analytics/user-flow'; -import { TEST_PROJECT_ID } from './setup'; const CTX = { projectId: TEST_PROJECT_ID, @@ -58,21 +50,28 @@ const CTX = { clientType: 'read' as const, }; -// Run ClickHouse fixture setup only when this file is executed (not for unit tests) -beforeAll(() => setup(), 30_000); -afterAll(() => teardown(), 10_000); - function makeServer() { const handlers = new Map Promise>(); return { - tool: (name: string, _desc: string, _schema: unknown, fn: (input: unknown) => Promise) => { + tool: ( + name: string, + _desc: string, + _schema: unknown, + fn: (input: unknown) => Promise + ) => { handlers.set(name, fn); }, invoke: async (name: string, input: unknown) => { const handler = handlers.get(name); - if (!handler) throw new Error(`Tool not registered: ${name}`); - const result = await handler(input) as any; - return JSON.parse(result.content[0].text); + if (!handler) { + throw new Error(`Tool not registered: ${name}`); + } + const result = (await handler(input)) as any; + const text = result.content[0].text as string; + if (result.isError) { + return { error: text.replace(/^Error:\s*/, '') }; + } + return JSON.parse(text); }, }; } @@ -83,7 +82,9 @@ describe('list_event_names', () => { it('returns { event_names: string[] }', async () => { const server = makeServer(); registerEventNameTools(server as any, CTX); - const res = await server.invoke('list_event_names', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('list_event_names', { + projectId: TEST_PROJECT_ID, + }); expect(Array.isArray(res.event_names)).toBe(true); }); }); @@ -92,7 +93,9 @@ describe('list_event_properties', () => { it('returns { properties: array }', async () => { const server = makeServer(); registerPropertyValueTools(server as any, CTX); - const res = await server.invoke('list_event_properties', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('list_event_properties', { + projectId: TEST_PROJECT_ID, + }); expect(Array.isArray(res.properties)).toBe(true); }); }); @@ -151,7 +154,9 @@ describe('query_events', () => { profileId: FIXTURE.profiles.alice, }); expect(res.length).toBe(3); - expect(res.every((e: any) => e.profile_id === FIXTURE.profiles.alice)).toBe(true); + expect(res.every((e: any) => e.profile_id === FIXTURE.profiles.alice)).toBe( + true + ); }); it('filters by browser', async () => { @@ -169,7 +174,6 @@ describe('query_events', () => { // Note: read-context resolveProjectId ignores the input projectId and always // uses CTX.projectId — so there is no way to query another project's data. - }); describe('query_sessions', () => { @@ -194,7 +198,9 @@ describe('query_sessions', () => { profileId: FIXTURE.profiles.charlie, }); expect(res.length).toBe(2); - expect(res.every((s: any) => s.profile_id === FIXTURE.profiles.charlie)).toBe(true); + expect( + res.every((s: any) => s.profile_id === FIXTURE.profiles.charlie) + ).toBe(true); }); it('filters by browser', async () => { @@ -217,14 +223,19 @@ describe('find_profiles', () => { it('returns all 3 fixture profiles', async () => { const server = makeServer(); registerProfileTools(server as any, CTX); - const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + }); expect(res.length).toBe(3); }); it('filters by email partial match', async () => { const server = makeServer(); registerProfileTools(server as any, CTX); - const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, email: 'alice@' }); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + email: 'alice@', + }); expect(res.length).toBe(1); expect(res[0].email).toBe('alice@example.com'); }); @@ -232,11 +243,17 @@ describe('find_profiles', () => { it('filters by name — matches first_name and last_name', async () => { const server = makeServer(); registerProfileTools(server as any, CTX); - const byFirst = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, name: 'Charlie' }); + const byFirst = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + name: 'Charlie', + }); expect(byFirst.length).toBe(1); expect(byFirst[0].first_name).toBe('Charlie'); - const byLast = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, name: 'Smith' }); + const byLast = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + name: 'Smith', + }); expect(byLast.length).toBe(1); expect(byLast[0].last_name).toBe('Smith'); }); @@ -244,7 +261,10 @@ describe('find_profiles', () => { it('filters by country property', async () => { const server = makeServer(); registerProfileTools(server as any, CTX); - const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, country: 'SE' }); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + country: 'SE', + }); expect(res.length).toBe(1); expect(res[0].email).toBe('bob@example.com'); }); @@ -252,7 +272,10 @@ describe('find_profiles', () => { it('inactiveDays=7 excludes alice (active 2 days ago) but includes bob (no events)', async () => { const server = makeServer(); registerProfileTools(server as any, CTX); - const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, inactiveDays: 7 }); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + inactiveDays: 7, + }); const emails = res.map((p: any) => p.email); expect(emails).not.toContain('alice@example.com'); expect(emails).not.toContain('charlie@example.com'); @@ -262,7 +285,10 @@ describe('find_profiles', () => { it('minSessions=2 returns only charlie (has 2 sessions)', async () => { const server = makeServer(); registerProfileTools(server as any, CTX); - const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, minSessions: 2 }); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + minSessions: 2, + }); expect(res.length).toBe(1); expect(res[0].first_name).toBe('Charlie'); }); @@ -270,14 +296,16 @@ describe('find_profiles', () => { it('performedEvent=purchase returns only charlie', async () => { const server = makeServer(); registerProfileTools(server as any, CTX); - const res = await server.invoke('find_profiles', { projectId: TEST_PROJECT_ID, performedEvent: 'purchase' }); + const res = await server.invoke('find_profiles', { + projectId: TEST_PROJECT_ID, + performedEvent: 'purchase', + }); expect(res.length).toBe(1); expect(res[0].first_name).toBe('Charlie'); }); // Note: read-context resolveProjectId ignores the input projectId and always // uses CTX.projectId — so there is no way to query another project's data. - }); describe('get_profile', () => { @@ -304,7 +332,9 @@ describe('get_profile_sessions', () => { profileId: FIXTURE.profiles.charlie, }); expect(res.sessions.length).toBe(2); - expect(res.sessions.every((s: any) => s.profile_id === FIXTURE.profiles.charlie)).toBe(true); + expect( + res.sessions.every((s: any) => s.profile_id === FIXTURE.profiles.charlie) + ).toBe(true); }); }); @@ -319,11 +349,11 @@ describe('get_profile_metrics', () => { // No error — bug was getProfileMetrics returns single object, not array expect(res.error).toBeUndefined(); expect(res.profileId).toBe(FIXTURE.profiles.charlie); - expect(res.sessions).toBe(1); // 1 session_start event - expect(res.screenViews).toBe(1); // 1 screen_view event - expect(res.totalEvents).toBe(5); // session_start + screen_view + page_view + purchase + session_end - expect(res.conversionEvents).toBe(2); // page_view + purchase (excludes session_start/screen_view/session_end) - expect(res.uniqueDaysActive).toBe(1); // all on the same day + expect(res.sessions).toBe(1); // 1 session_start event + expect(res.screenViews).toBe(1); // 1 screen_view event + expect(res.totalEvents).toBe(5); // session_start + screen_view + page_view + purchase + session_end + expect(res.conversionEvents).toBe(2); // page_view + purchase (excludes session_start/screen_view/session_end) + expect(res.uniqueDaysActive).toBe(1); // all on the same day expect(res.firstSeen).not.toBeNull(); expect(res.lastSeen).not.toBeNull(); }); @@ -337,7 +367,7 @@ describe('get_profile_metrics', () => { }); expect(res.error).toBeUndefined(); expect(res.sessions).toBe(1); - expect(res.totalEvents).toBe(3); // session_start + page_view + session_end + expect(res.totalEvents).toBe(3); // session_start + page_view + session_end expect(res.conversionEvents).toBe(1); // page_view only expect(res.screenViews).toBe(0); }); @@ -349,7 +379,9 @@ describe('list_group_types', () => { it('returns { types: [] } (no groups in fixtures)', async () => { const server = makeServer(); registerGroupTools(server as any, CTX); - const res = await server.invoke('list_group_types', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('list_group_types', { + projectId: TEST_PROJECT_ID, + }); expect(Array.isArray(res.types)).toBe(true); expect(res.types).toHaveLength(0); }); @@ -359,7 +391,9 @@ describe('find_groups', () => { it('returns empty array (no groups in fixtures)', async () => { const server = makeServer(); registerGroupTools(server as any, CTX); - const res = await server.invoke('find_groups', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('find_groups', { + projectId: TEST_PROJECT_ID, + }); expect(Array.isArray(res)).toBe(true); }); }); @@ -581,7 +615,10 @@ describe('get_rolling_active_users', () => { it('returns DAU series (may be empty — dau_mv not auto-populated)', async () => { const server = makeServer(); registerActiveUserTools(server as any, CTX); - const res = await server.invoke('get_rolling_active_users', { projectId: TEST_PROJECT_ID, days: 1 }); + const res = await server.invoke('get_rolling_active_users', { + projectId: TEST_PROJECT_ID, + days: 1, + }); expect(res.label).toBe('DAU'); expect(res.window_days).toBe(1); expect(Array.isArray(res.series)).toBe(true); @@ -590,9 +627,15 @@ describe('get_rolling_active_users', () => { it('uses correct label for WAU and MAU', async () => { const server = makeServer(); registerActiveUserTools(server as any, CTX); - const wau = await server.invoke('get_rolling_active_users', { projectId: TEST_PROJECT_ID, days: 7 }); + const wau = await server.invoke('get_rolling_active_users', { + projectId: TEST_PROJECT_ID, + days: 7, + }); expect(wau.label).toBe('WAU'); - const mau = await server.invoke('get_rolling_active_users', { projectId: TEST_PROJECT_ID, days: 30 }); + const mau = await server.invoke('get_rolling_active_users', { + projectId: TEST_PROJECT_ID, + days: 30, + }); expect(mau.label).toBe('MAU'); }); }); @@ -601,7 +644,9 @@ describe('get_weekly_retention_series', () => { it('returns array of { date, active_users, retained_users, retention } rows', async () => { const server = makeServer(); registerActiveUserTools(server as any, CTX); - const res = await server.invoke('get_weekly_retention_series', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('get_weekly_retention_series', { + projectId: TEST_PROJECT_ID, + }); expect(Array.isArray(res)).toBe(true); if (res.length > 0) { expect(res[0]).toHaveProperty('date'); @@ -616,7 +661,9 @@ describe('get_retention_cohort', () => { it('returns array of cohort rows with period_0..period_9', async () => { const server = makeServer(); registerRetentionTools(server as any, CTX); - const res = await server.invoke('get_retention_cohort', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('get_retention_cohort', { + projectId: TEST_PROJECT_ID, + }); expect(Array.isArray(res)).toBe(true); if (res.length > 0) { expect(res[0]).toHaveProperty('first_seen'); @@ -629,7 +676,9 @@ describe('get_user_last_seen_distribution', () => { it('returns alice and charlie in active_last_7_days bucket', async () => { const server = makeServer(); registerEngagementTools(server as any, CTX); - const res = await server.invoke('get_user_last_seen_distribution', { projectId: TEST_PROJECT_ID }); + const res = await server.invoke('get_user_last_seen_distribution', { + projectId: TEST_PROJECT_ID, + }); // Alice: last event 2 days ago → 0-7 bucket // Charlie: last event 5 days ago → 0-7 bucket // Bob: no events → not counted diff --git a/packages/mcp/src/test-setup.ts b/packages/mcp/src/test-setup.ts deleted file mode 100644 index be1c74424..000000000 --- a/packages/mcp/src/test-setup.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Runs after every test file in this package (via setupFiles in vitest.config.ts). - * - * Closes the ClickHouse client's keep-alive connection pool so Vitest's worker - * thread can exit cleanly. Without this the process hangs waiting for idle - * sockets to time out (~10 s). - */ -import { afterAll } from 'vitest'; -import { originalCh } from '@openpanel/db'; - -afterAll(() => originalCh.close()); diff --git a/packages/mcp/src/tools/analytics/profiles.test.ts b/packages/mcp/src/tools/analytics/profiles.test.ts index fb766278b..a59336e9e 100644 --- a/packages/mcp/src/tools/analytics/profiles.test.ts +++ b/packages/mcp/src/tools/analytics/profiles.test.ts @@ -2,29 +2,50 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockChQuery = vi.hoisted(() => vi.fn().mockResolvedValue([])); -vi.mock('@openpanel/db', () => ({ - TABLE_NAMES: { - profiles: 'profiles', - events: 'events', - sessions: 'sessions', - }, - ch: {}, - chQuery: mockChQuery, - // clix is used by getProfileSessionsCore and getProfileWithEvents — mock a chainable builder - clix: vi.fn(() => { - const builder: Record = {}; - const chain = () => builder; - builder.select = chain; - builder.from = chain; - builder.where = chain; - builder.orderBy = chain; - builder.limit = chain; - builder.execute = vi.fn().mockResolvedValue([]); - return builder; - }), -})); - -import { findProfilesCore } from '@openpanel/db'; +// Mock the ClickHouse client WITHOUT importOriginal — using importOriginal +// causes the real module to be bound into profile.service.ts before the mock +// factory result is visible, bypassing the chQuery replacement. +vi.mock('../../../../db/src/clickhouse/client', () => { + return { + chQuery: mockChQuery, + chQueryWithMeta: vi.fn().mockResolvedValue({ data: [], rows: 0 }), + ch: {}, + originalCh: {}, + createClient: () => ({}), + withRetry: vi.fn().mockImplementation((fn: () => unknown) => fn()), + isClickhouseClustered: () => false, + getReplicatedTableName: (name: string) => name, + CLICKHOUSE_OPTIONS: {}, + TABLE_NAMES: { + events: 'events', + profiles: 'profiles', + alias: 'profile_aliases', + self_hosting: 'self_hosting', + events_bots: 'events_bots', + dau_mv: 'dau_mv', + event_names_mv: 'distinct_event_names_mv', + event_property_values_mv: 'event_property_values_mv', + cohort_events_mv: 'cohort_events_mv', + sessions: 'sessions', + events_imports: 'events_imports', + session_replay_chunks: 'session_replay_chunks', + gsc_daily: 'gsc_daily', + gsc_pages_daily: 'gsc_pages_daily', + gsc_queries_daily: 'gsc_queries_daily', + groups: 'groups', + }, + formatClickhouseDate: (date: Date | string, skipTime = false) => { + if (skipTime) return new Date(date).toISOString().split('T')[0]; + return new Date(date).toISOString().replace('T', ' ').replace(/(\.\d{3})?Z+$/, ''); + }, + toDate: (str: string) => str, + convertClickhouseDateToJs: (date: string) => new Date(`${date.replace(' ', 'T')}Z`), + isClickhouseDefaultMinDate: (date: string) => date.startsWith('1970-01-01') || date.startsWith('1969-12-31'), + toNullIfDefaultMinDate: () => null, + }; +}); + +import { findProfilesCore } from '../../../../db/src/services/profile.service'; function capturedSql(): string { return mockChQuery.mock.calls[0]?.[0] as string; diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts index dbd8a0797..f87a2039f 100644 --- a/packages/mcp/vitest.config.ts +++ b/packages/mcp/vitest.config.ts @@ -1,10 +1,3 @@ -import { mergeConfig } from 'vitest/config'; import { getSharedVitestConfig } from '../../vitest.shared'; -export default mergeConfig(getSharedVitestConfig({ __dirname }), { - test: { - // Closes the ClickHouse keep-alive connection pool after every test file - // so the worker thread can exit without hanging. - setupFiles: ['./src/test-setup.ts'], - }, -}); +export default getSharedVitestConfig({ __dirname }); diff --git a/packages/mcp/src/integration/clickhouse-schema.sql b/test/clickhouse-schema.sql similarity index 100% rename from packages/mcp/src/integration/clickhouse-schema.sql rename to test/clickhouse-schema.sql diff --git a/test/clickhouse-fixtures.ts b/test/fixtures.ts similarity index 52% rename from test/clickhouse-fixtures.ts rename to test/fixtures.ts index d4985904a..c67afaed3 100644 --- a/test/clickhouse-fixtures.ts +++ b/test/fixtures.ts @@ -22,7 +22,20 @@ * different project IDs (ClickHouse's MergeTree ordering includes project_id). */ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createClient } from '../packages/db/src/clickhouse/client'; +import { PrismaClient } from '../packages/db/src/generated/prisma/client'; + +// Lazily create a Prisma client so DATABASE_URL is read at call time, +// not at module-import time (globalSetup runs before env is configured). +function getDb() { + const url = + process.env.DATABASE_URL ?? + 'postgresql://postgres:postgres@localhost:5432/postgres?schema=public'; + return new PrismaClient({ datasources: { db: { url } } }); +} // --------------------------------------------------------------------------- // Well-known fixture IDs — import these in tests instead of hard-coding strings @@ -42,15 +55,15 @@ export const FIXTURE = { events: { alice: { sessionStart: '00000000-0000-0000-0000-000000000001', - pageView: '00000000-0000-0000-0000-000000000002', - sessionEnd: '00000000-0000-0000-0000-000000000003', + pageView: '00000000-0000-0000-0000-000000000002', + sessionEnd: '00000000-0000-0000-0000-000000000003', }, charlie: { sessionStart: '00000000-0000-0000-0000-000000000004', - screenView: '00000000-0000-0000-0000-000000000005', - pageView: '00000000-0000-0000-0000-000000000006', - purchase: '00000000-0000-0000-0000-000000000007', - sessionEnd: '00000000-0000-0000-0000-000000000008', + screenView: '00000000-0000-0000-0000-000000000005', + pageView: '00000000-0000-0000-0000-000000000006', + purchase: '00000000-0000-0000-0000-000000000007', + sessionEnd: '00000000-0000-0000-0000-000000000008', }, }, } as const; @@ -67,10 +80,13 @@ function getClient() { } function timeAgo(now: Date, days: number, minutesOffset = 0) { - return new Date(now.getTime() - days * 86_400_000 - minutesOffset * 60_000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ''); + return ( + new Date(now.getTime() - days * 86_400_000 - minutesOffset * 60_000) + .toISOString() + .replace('T', ' ') + // biome-ignore lint/performance/useTopLevelRegex: test setup + .replace(/\.\d+Z$/, '') + ); } function buildEvent( @@ -82,7 +98,7 @@ function buildEvent( sessionId: string, daysBack: number, minutesOffset = 0, - overrides: Record = {}, + overrides: Record = {} ) { return { id, @@ -123,7 +139,7 @@ function buildSession( id: string, profileId: string, daysBack: number, - overrides: Record = {}, + overrides: Record = {} ) { return { id, @@ -220,15 +236,94 @@ async function insertFixtures(client: ChClient, projectId: string) { await client.insert({ table: 'openpanel.events', values: [ - buildEvent(now, projectId, FIXTURE.events.alice.sessionStart, 'session_start', FIXTURE.profiles.alice, FIXTURE.sessions.alice1, 2, 4), - buildEvent(now, projectId, FIXTURE.events.alice.pageView, 'page_view', FIXTURE.profiles.alice, FIXTURE.sessions.alice1, 2, 2, { path: '/home', browser: 'Chrome' }), - buildEvent(now, projectId, FIXTURE.events.alice.sessionEnd, 'session_end', FIXTURE.profiles.alice, FIXTURE.sessions.alice1, 2, 0, { duration: 120000 }), + buildEvent( + now, + projectId, + FIXTURE.events.alice.sessionStart, + 'session_start', + FIXTURE.profiles.alice, + FIXTURE.sessions.alice1, + 2, + 4 + ), + buildEvent( + now, + projectId, + FIXTURE.events.alice.pageView, + 'page_view', + FIXTURE.profiles.alice, + FIXTURE.sessions.alice1, + 2, + 2, + { path: '/home', browser: 'Chrome' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.alice.sessionEnd, + 'session_end', + FIXTURE.profiles.alice, + FIXTURE.sessions.alice1, + 2, + 0, + { duration: 120_000 } + ), - buildEvent(now, projectId, FIXTURE.events.charlie.sessionStart, 'session_start', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 20, { browser: 'Firefox' }), - buildEvent(now, projectId, FIXTURE.events.charlie.screenView, 'screen_view', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 15, { path: '/shop', browser: 'Firefox' }), - buildEvent(now, projectId, FIXTURE.events.charlie.pageView, 'page_view', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 10, { path: '/shop', browser: 'Firefox' }), - buildEvent(now, projectId, FIXTURE.events.charlie.purchase, 'purchase', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 5, { path: '/checkout', revenue: 9900, browser: 'Firefox' }), - buildEvent(now, projectId, FIXTURE.events.charlie.sessionEnd, 'session_end', FIXTURE.profiles.charlie, FIXTURE.sessions.charlie1, 5, 0, { duration: 300000, browser: 'Firefox' }), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.sessionStart, + 'session_start', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 20, + { browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.screenView, + 'screen_view', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 15, + { path: '/shop', browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.pageView, + 'page_view', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 10, + { path: '/shop', browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.purchase, + 'purchase', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 5, + { path: '/checkout', revenue: 9900, browser: 'Firefox' } + ), + buildEvent( + now, + projectId, + FIXTURE.events.charlie.sessionEnd, + 'session_end', + FIXTURE.profiles.charlie, + FIXTURE.sessions.charlie1, + 5, + 0, + { duration: 300_000, browser: 'Firefox' } + ), ], format: 'JSONEachRow', }); @@ -236,23 +331,43 @@ async function insertFixtures(client: ChClient, projectId: string) { await client.insert({ table: 'openpanel.sessions', values: [ - buildSession(now, projectId, FIXTURE.sessions.alice1, FIXTURE.profiles.alice, 2), - buildSession(now, projectId, FIXTURE.sessions.charlie1, FIXTURE.profiles.charlie, 5, { - browser: 'Firefox', - entry_path: '/shop', - exit_path: '/checkout', - revenue: 9900, - duration: 300, - screen_view_count: 2, - event_count: 5, - }), - buildSession(now, projectId, FIXTURE.sessions.charlie2, FIXTURE.profiles.charlie, 10, { - browser: 'Firefox', - is_bounce: true, - entry_path: '/shop', - exit_path: '/shop', - duration: 15, - }), + buildSession( + now, + projectId, + FIXTURE.sessions.alice1, + FIXTURE.profiles.alice, + 2 + ), + buildSession( + now, + projectId, + FIXTURE.sessions.charlie1, + FIXTURE.profiles.charlie, + 5, + { + browser: 'Firefox', + entry_path: '/shop', + exit_path: '/checkout', + revenue: 9900, + duration: 300, + screen_view_count: 2, + event_count: 5, + } + ), + buildSession( + now, + projectId, + FIXTURE.sessions.charlie2, + FIXTURE.profiles.charlie, + 10, + { + browser: 'Firefox', + is_bounce: true, + entry_path: '/shop', + exit_path: '/shop', + duration: 15, + } + ), ], format: 'JSONEachRow', }); @@ -260,9 +375,15 @@ async function insertFixtures(client: ChClient, projectId: string) { async function deleteFixtures(client: ChClient, projectId: string) { await Promise.all([ - client.command({ query: `DELETE FROM openpanel.profiles WHERE project_id = '${projectId}'` }), - client.command({ query: `DELETE FROM openpanel.events WHERE project_id = '${projectId}'` }), - client.command({ query: `DELETE FROM openpanel.sessions WHERE project_id = '${projectId}'` }), + client.command({ + query: `DELETE FROM openpanel.profiles WHERE project_id = '${projectId}'`, + }), + client.command({ + query: `DELETE FROM openpanel.events WHERE project_id = '${projectId}'`, + }), + client.command({ + query: `DELETE FROM openpanel.sessions WHERE project_id = '${projectId}'`, + }), ]); } @@ -270,6 +391,68 @@ async function deleteFixtures(client: ChClient, projectId: string) { // Public API // --------------------------------------------------------------------------- +export async function setupPostgresFixtures( + projectId: string, + orgId: string +): Promise { + const db = getDb(); + try { + await db.organization.upsert({ + where: { id: orgId }, + create: { id: orgId, name: 'Test Org', timezone: 'UTC' }, + update: { timezone: 'UTC' }, + }); + await db.project.upsert({ + where: { id: projectId }, + create: { id: projectId, name: 'Test Project', organizationId: orgId }, + update: {}, + }); + } finally { + await db.$disconnect(); + } +} + +export async function teardownPostgresFixtures( + projectId: string, + orgId: string +): Promise { + const db = getDb(); + try { + await db.project.deleteMany({ where: { id: projectId } }); + await db.organization.deleteMany({ where: { id: orgId } }); + } finally { + await db.$disconnect(); + } +} + +// --------------------------------------------------------------------------- +// ClickHouse schema bootstrap +// --------------------------------------------------------------------------- + +const __fixturesDir = dirname(fileURLToPath(import.meta.url)); + +export async function ensureSchema(): Promise { + const client = createClient({ + url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123', + }); + const sql = readFileSync( + join(__fixturesDir, 'clickhouse-schema.sql'), + 'utf8' + ); + const statements = sql + .split('\n') + .filter((line) => !line.trimStart().startsWith('--')) + .join('\n') + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + await Promise.all( + statements.map((statement) => client.command({ query: statement })) + ); + await client.close(); +} + + export async function setupFixtures(projectId: string): Promise { const client = getClient(); await deleteFixtures(client, projectId); diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 000000000..b75146b9d --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,32 @@ +import { + ensureSchema, + setupFixtures, + setupPostgresFixtures, + teardownFixtures, + teardownPostgresFixtures, +} from './fixtures'; + +export { FIXTURE } from './fixtures'; +export const TEST_PROJECT_ID = 'integration-test'; +export const TEST_ORG_ID = 'integration-org'; + +// globalSetup runs in the parent process before vitest workers start, +// so vitest's `env` config is not applied — set defaults explicitly. +function setEnvDefaults() { + process.env.DATABASE_URL ??= + 'postgresql://postgres:postgres@localhost:5432/postgres?schema=public'; + process.env.CLICKHOUSE_URL ??= 'http://localhost:8123/openpanel'; +} + +export async function setup() { + setEnvDefaults(); + await ensureSchema(); + await setupPostgresFixtures(TEST_PROJECT_ID, TEST_ORG_ID); + await setupFixtures(TEST_PROJECT_ID); +} + +export async function teardown() { + setEnvDefaults(); + await teardownFixtures(TEST_PROJECT_ID); + await teardownPostgresFixtures(TEST_PROJECT_ID, TEST_ORG_ID); +} diff --git a/test/test-setup.ts b/test/test-setup.ts new file mode 100644 index 000000000..056557f80 --- /dev/null +++ b/test/test-setup.ts @@ -0,0 +1,27 @@ +/** + * Shared afterAll cleanup registered via setupFiles in vitest.shared.ts. + * + * Closes the ClickHouse keep-alive pool and disconnects Prisma after every + * test file so worker threads can exit cleanly. + * + * Uses dynamic imports + try-catch so this is safe in packages that mock + * these modules or don't use real connections. + */ +import { afterAll } from 'vitest'; + +afterAll(async () => { + await Promise.allSettled([ + (async () => { + const { originalCh } = await import( + '../packages/db/src/clickhouse/client' + ); + if (typeof originalCh?.close === 'function') { + await originalCh.close(); + } + })(), + (async () => { + const { db } = await import('../packages/db/src/prisma-client'); + await db.$disconnect(); + })(), + ]); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..bd99c6f76 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globalSetup: ['./test/global-setup.ts'], + }, +}); diff --git a/vitest.shared.ts b/vitest.shared.ts index 881e20f63..c44c4dc6a 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -1,6 +1,10 @@ import * as path from 'node:path'; import { defineConfig } from 'vitest/config'; +// Absolute path to the root test-setup — used as setupFiles so every package +// gets connection-pool cleanup without needing a per-package file. +const rootTestSetup = (dirname: string) => path.resolve(dirname, '../../test/test-setup.ts'); + export const getSharedVitestConfig = ({ __dirname: dirname, }: { @@ -13,9 +17,10 @@ export const getSharedVitestConfig = ({ }, }, test: { + setupFiles: [rootTestSetup(dirname)], env: { // Always point at local Docker — never production, regardless of .env - DATABASE_URL: 'postgresql://u:p@127.0.0.1:5432/db', + DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/postgres?schema=public', CLICKHOUSE_URL: 'http://localhost:8123/openpanel', REDIS_URL: 'redis://localhost:6379', SELF_HOSTED: 'true', From 1f6c142e6f11a77db44ac151d89039b93d4c1b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Tue, 7 Apr 2026 23:33:32 +0200 Subject: [PATCH 11/18] fixes --- .../src/controllers/insights.controller.ts | 633 ++++++++++++- apps/api/src/controllers/query.controller.ts | 856 ------------------ ...router.test.ts => insights.router.test.ts} | 0 apps/api/src/routes/insights.router.ts | 88 +- apps/api/src/routes/manage.router.ts | 20 +- apps/api/src/routes/mcp.router.ts | 12 +- packages/db/src/services/project.service.ts | 45 +- packages/logger/index.ts | 10 + packages/mcp/src/auth.ts | 18 - packages/mcp/src/session-manager.ts | 4 +- .../mcp/src/tools/analytics/active-users.ts | 8 +- .../src/tools/analytics/engagement.test.ts | 1 + .../mcp/src/tools/analytics/engagement.ts | 6 +- .../mcp/src/tools/analytics/event-names.ts | 6 +- packages/mcp/src/tools/analytics/events.ts | 6 +- packages/mcp/src/tools/analytics/funnel.ts | 6 +- packages/mcp/src/tools/analytics/groups.ts | 10 +- packages/mcp/src/tools/analytics/overview.ts | 6 +- .../tools/analytics/page-performance.test.ts | 1 + .../src/tools/analytics/page-performance.ts | 6 +- packages/mcp/src/tools/analytics/pages.ts | 8 +- .../src/tools/analytics/profile-metrics.ts | 6 +- packages/mcp/src/tools/analytics/profiles.ts | 10 +- .../src/tools/analytics/property-values.ts | 8 +- packages/mcp/src/tools/analytics/reports.ts | 10 +- packages/mcp/src/tools/analytics/retention.ts | 6 +- packages/mcp/src/tools/analytics/sessions.ts | 6 +- packages/mcp/src/tools/analytics/traffic.ts | 10 +- packages/mcp/src/tools/analytics/user-flow.ts | 6 +- packages/mcp/src/tools/dashboard-links.ts | 5 +- packages/mcp/src/tools/gsc/cannibalization.ts | 8 +- packages/mcp/src/tools/gsc/overview.ts | 6 +- packages/mcp/src/tools/gsc/pages.ts | 8 +- packages/mcp/src/tools/gsc/queries.ts | 10 +- packages/mcp/src/tools/index.ts | 1 + packages/mcp/src/tools/shared.test.ts | 36 +- packages/mcp/src/tools/shared.ts | 17 - 37 files changed, 832 insertions(+), 1071 deletions(-) delete mode 100644 apps/api/src/controllers/query.controller.ts rename apps/api/src/routes/{query.router.test.ts => insights.router.test.ts} (100%) diff --git a/apps/api/src/controllers/insights.controller.ts b/apps/api/src/controllers/insights.controller.ts index 36a15556c..6ad02a44c 100644 --- a/apps/api/src/controllers/insights.controller.ts +++ b/apps/api/src/controllers/insights.controller.ts @@ -1,14 +1,121 @@ import { getDefaultIntervalByDates } from '@openpanel/constants'; +import type { IServiceClientWithProject } from '@openpanel/db'; import { eventBuffer, + findGroupsCore, + findProfilesCore, + getAnalyticsOverviewCore, getChartStartEndDate, + getEngagementCore, + getEntryExitPagesCore, + getEventPropertyValuesCore, + getFunnelCore, + getGroupCore, + getPagePerformanceCore, + getProfileMetricsCore, + getProfileSessionsCore, + getProfileWithEvents, + getReportDataCore, + getRetentionCohortCore, + getRollingActiveUsersCore, getSettingsForProject, + getTopPagesCore, + getTrafficBreakdownCore, + getUserFlowCore, + getWeeklyRetentionSeriesCore, + gscGetCannibalizationCore, + gscGetOverviewCore, + gscGetPageDetailsCore, + gscGetQueryDetailsCore, + gscGetQueryOpportunitiesCore, + gscGetTopPagesCore, + gscGetTopQueriesCore, + listDashboardsCore, + listEventNamesCore, + listEventPropertiesCore, + listGroupTypesCore, + listReportsCore, overviewService, + queryEventsCore, + querySessionsCore, + resolveDateRange, + resolveClientProjectId, + type TrafficColumn, } from '@openpanel/db'; import { zChartEventFilter, zRange } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +export const zDateRange = z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + range: zRange.optional(), +}); + +type DateRangeInput = z.infer; + +type RequestWithProjectParam = FastifyRequest<{ + Params: { projectId?: string }; +}>; + +function getProjectId(req: RequestWithProjectParam): Promise { + const client = req.client!; + return resolveClientProjectId({ + clientType: client.type === 'root' ? 'root' : 'read', + clientProjectId: client.projectId ?? null, + organizationId: client.organizationId, + inputProjectId: req.params.projectId, + }); +} + +function getOrgId(req: RequestWithProjectParam): string { + return req.client!.organizationId; +} + +async function resolveDates( + projectId: string, + data: DateRangeInput +): Promise<{ startDate: string; endDate: string }> { + if (!data.range || data.startDate) { + return resolveDateRange(data.startDate, data.endDate); + } + const { timezone } = await getSettingsForProject(projectId); + return getChartStartEndDate( + { startDate: data.startDate, endDate: data.endDate, range: data.range! }, + timezone + ); +} + +// --------------------------------------------------------------------------- +// Analytics — overview +// --------------------------------------------------------------------------- + +export const zOverviewQuery = zDateRange.extend({ + interval: z.enum(['hour', 'day', 'week', 'month']).optional(), +}); + +export async function getOverview( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send( + await getAnalyticsOverviewCore({ projectId, startDate, endDate, interval: req.query.interval }) + ); +} + +// --------------------------------------------------------------------------- +// Analytics — metrics (legacy) +// --------------------------------------------------------------------------- + export const zGetMetricsQuery = z.object({ startDate: z.string().nullish(), endDate: z.string().nullish(), @@ -17,18 +124,19 @@ export const zGetMetricsQuery = z.object({ }); export async function getMetrics( - request: FastifyRequest<{ + req: FastifyRequest<{ Params: { projectId: string }; Querystring: z.infer; }>, reply: FastifyReply ) { - const { timezone } = await getSettingsForProject(request.params.projectId); - const { startDate, endDate } = getChartStartEndDate(request.query, timezone); + const projectId = await getProjectId(req as RequestWithProjectParam); + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(req.query, timezone); reply.send( await overviewService.getMetrics({ - projectId: request.params.projectId, - filters: request.query.filters, + projectId, + filters: req.query.filters, startDate, endDate, interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day', @@ -37,13 +145,71 @@ export async function getMetrics( ); } +// --------------------------------------------------------------------------- +// Analytics — live visitors +// --------------------------------------------------------------------------- + export async function getLiveVisitors( - request: FastifyRequest<{ Params: { projectId: string } }>, + req: FastifyRequest<{ Params: { projectId: string } }>, reply: FastifyReply ) { - reply.send({ - visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId), - }); + const projectId = await getProjectId(req as RequestWithProjectParam); + reply.send({ visitors: await eventBuffer.getActiveVisitorCount(projectId) }); +} + +// --------------------------------------------------------------------------- +// Analytics — active users +// --------------------------------------------------------------------------- + +export const zActiveUsersQuery = z.object({ + days: z.number().int().min(1).max(90).default(7), +}); + +export async function getActiveUsers( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getRollingActiveUsersCore({ projectId, days: req.query.days })); +} + +// --------------------------------------------------------------------------- +// Analytics — retention +// --------------------------------------------------------------------------- + +export async function getRetentionSeries( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getWeeklyRetentionSeriesCore(projectId)); +} + +export async function getRetentionCohort( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getRetentionCohortCore(projectId)); +} + +// --------------------------------------------------------------------------- +// Analytics — pages +// --------------------------------------------------------------------------- + +export async function getTopPages( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTopPagesCore({ projectId, startDate, endDate })); } export const zGetTopPagesQuery = z.object({ @@ -56,22 +222,56 @@ export const zGetTopPagesQuery = z.object({ }); export async function getPages( - request: FastifyRequest<{ + req: FastifyRequest<{ Params: { projectId: string }; Querystring: z.infer; }> ) { - const { timezone } = await getSettingsForProject(request.params.projectId); - const { startDate, endDate } = getChartStartEndDate(request.query, timezone); - return overviewService.getTopPages({ - projectId: request.params.projectId, - filters: request.query.filters, - startDate, - endDate, - timezone, - }); + const projectId = await getProjectId(req as RequestWithProjectParam); + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(req.query, timezone); + return overviewService.getTopPages({ projectId, filters: req.query.filters, startDate, endDate, timezone }); } +export const zEntryExitQuery = zDateRange.extend({ + mode: z.enum(['entry', 'exit']).default('entry'), +}); + +export async function getEntryExitPages( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getEntryExitPagesCore({ projectId, startDate, endDate, mode: req.query.mode })); +} + +export const zPagePerfQuery = zDateRange.extend({ + search: z.string().optional(), + sortBy: z.enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']).optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), + limit: z.number().int().min(1).max(500).default(50), +}); + +export async function getPagePerformance( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getPagePerformanceCore({ projectId, startDate, endDate, ...req.query })); +} + +// --------------------------------------------------------------------------- +// Analytics — overview generic (legacy) +// --------------------------------------------------------------------------- + export const overviewColumns = [ 'referrer', 'referrer_name', @@ -95,8 +295,6 @@ export const overviewColumns = [ export type OverviewColumn = (typeof overviewColumns)[number]; -// Querystring schema for the dynamic overview generic routes. -// `column` is injected from the route factory, not from the querystring. export const zOverviewGenericQuerystring = z.object({ filters: z.array(zChartEventFilter).default([]), startDate: z.string().nullish(), @@ -108,22 +306,20 @@ export const zOverviewGenericQuerystring = z.object({ export function getOverviewGeneric(column: OverviewColumn) { return async ( - request: FastifyRequest<{ + req: FastifyRequest<{ Params: { projectId: string }; Querystring: z.infer; }>, reply: FastifyReply ) => { - const { timezone } = await getSettingsForProject(request.params.projectId); - const { startDate, endDate } = getChartStartEndDate( - request.query, - timezone - ); + const projectId = await getProjectId(req as RequestWithProjectParam); + const { timezone } = await getSettingsForProject(projectId); + const { startDate, endDate } = getChartStartEndDate(req.query, timezone); reply.send( await overviewService.getTopGeneric({ column, - projectId: request.params.projectId, - filters: request.query.filters, + projectId, + filters: req.query.filters, startDate, endDate, timezone, @@ -131,3 +327,382 @@ export function getOverviewGeneric(column: OverviewColumn) { ); }; } + +// --------------------------------------------------------------------------- +// Analytics — funnel +// --------------------------------------------------------------------------- + +export const zFunnelQuery = zDateRange.extend({ + steps: z + .union([z.array(z.string()), z.string().transform((s) => [s])]) + .refine((a) => a.length >= 2 && a.length <= 10, { + message: 'steps must have between 2 and 10 items', + }), + windowHours: z.number().int().min(1).max(720).default(24), + groupBy: z.enum(['session_id', 'profile_id']).default('session_id'), +}); + +export async function getFunnel( + req: FastifyRequest<{ + Params: { projectId?: string }; + Querystring: z.infer; + }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send( + await getFunnelCore({ projectId, startDate, endDate, steps: req.query.steps, windowHours: req.query.windowHours, groupBy: req.query.groupBy }) + ); +} + +// --------------------------------------------------------------------------- +// Analytics — traffic +// --------------------------------------------------------------------------- + +const referrerColumns = ['referrer_name', 'referrer_type', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'] as const; +const geoColumns = ['country', 'region', 'city'] as const; +const deviceColumns = ['device', 'browser', 'os'] as const; + +export const zReferrerQuery = zDateRange.extend({ breakdown: z.enum(referrerColumns).default('referrer_name') }); +export const zGeoQuery = zDateRange.extend({ breakdown: z.enum(geoColumns).default('country') }); +export const zDeviceQuery = zDateRange.extend({ breakdown: z.enum(deviceColumns).default('device') }); + +export async function getTrafficReferrers( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn })); +} + +export async function getTrafficGeo( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn })); +} + +export async function getTrafficDevices( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getTrafficBreakdownCore({ projectId, startDate, endDate, column: req.query.breakdown as TrafficColumn })); +} + +// --------------------------------------------------------------------------- +// Analytics — user flow & engagement +// --------------------------------------------------------------------------- + +export const zUserFlowQuery = zDateRange.extend({ + startEvent: z.string(), + endEvent: z.string().optional(), + mode: z.enum(['after', 'before', 'between']).default('after'), + steps: z.number().int().min(2).max(10).default(5), + exclude: z.union([z.array(z.string()), z.string().transform((s) => [s])]).optional(), + include: z.union([z.array(z.string()), z.string().transform((s) => [s])]).optional(), +}); + +export async function getUserFlow( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await getUserFlowCore({ projectId, startDate, endDate, ...req.query })); +} + +export async function getEngagement( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getEngagementCore(projectId)); +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +export const zEventsQuery = zDateRange.extend({ + eventNames: z.union([z.array(z.string()), z.string().transform((s) => [s])]).optional(), + path: z.string().optional(), + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + referrer: z.string().optional(), + referrerName: z.string().optional(), + referrerType: z.string().optional(), + profileId: z.string().optional(), + properties: z.record(z.string(), z.string()).optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function queryEvents( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await queryEventsCore({ projectId, ...req.query })); +} + +export async function listEventNames( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listEventNamesCore(projectId)); +} + +export const zEventPropertiesQuery = z.object({ eventName: z.string().optional() }); + +export async function listEventProperties( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listEventPropertiesCore({ projectId, eventName: req.query.eventName })); +} + +export const zPropertyValuesQuery = z.object({ eventName: z.string(), propertyKey: z.string() }); + +export async function getEventPropertyValues( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getEventPropertyValuesCore({ projectId, ...req.query })); +} + +// --------------------------------------------------------------------------- +// Profiles +// --------------------------------------------------------------------------- + +export const zProfilesQuery = z.object({ + name: z.string().optional(), + email: z.string().optional(), + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + inactiveDays: z.number().int().min(1).optional(), + minSessions: z.number().int().min(1).optional(), + performedEvent: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function findProfiles( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await findProfilesCore({ projectId, ...req.query })); +} + +export const zGetProfileQuery = z.object({ eventLimit: z.number().int().min(1).max(100).default(20) }); + +export async function getProfile( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const result = await getProfileWithEvents(projectId, req.params.profileId, req.query.eventLimit); + if (!result.profile) { + return reply.status(404).send({ error: 'Profile not found', profileId: req.params.profileId }); + } + return reply.send({ profile: result.profile, recentEvents: result.recent_events }); +} + +export const zProfileSessionsQuery = z.object({ limit: z.number().int().min(1).max(100).default(20) }); + +export async function getProfileSessions( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getProfileSessionsCore(projectId, req.params.profileId, req.query.limit)); +} + +export async function getProfileMetrics( + req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getProfileMetricsCore({ projectId, profileId: req.params.profileId })); +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +export const zSessionsQuery = zDateRange.extend({ + country: z.string().optional(), + city: z.string().optional(), + device: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + referrer: z.string().optional(), + referrerName: z.string().optional(), + referrerType: z.string().optional(), + profileId: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function querySessions( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await querySessionsCore({ projectId, ...req.query })); +} + +// --------------------------------------------------------------------------- +// Groups +// --------------------------------------------------------------------------- + +export async function listGroupTypes( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listGroupTypesCore(projectId)); +} + +export const zGroupsQuery = z.object({ + type: z.string().optional(), + search: z.string().optional(), + limit: z.number().int().min(1).max(100).default(20), +}); + +export async function findGroups( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await findGroupsCore({ projectId, ...req.query })); +} + +export const zGetGroupQuery = z.object({ memberLimit: z.number().int().min(1).max(50).default(10) }); + +export async function getGroup( + req: FastifyRequest<{ Params: { projectId?: string; groupId: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getGroupCore({ projectId, groupId: req.params.groupId, memberLimit: req.query.memberLimit })); +} + +// --------------------------------------------------------------------------- +// Dashboards & reports +// --------------------------------------------------------------------------- + +export async function listDashboards( + req: FastifyRequest<{ Params: { projectId?: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listDashboardsCore({ projectId, organizationId: getOrgId(req as RequestWithProjectParam) })); +} + +export async function listReports( + req: FastifyRequest<{ Params: { projectId?: string; dashboardId: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await listReportsCore({ projectId, dashboardId: req.params.dashboardId, organizationId: getOrgId(req as RequestWithProjectParam) })); +} + +export async function getReportData( + req: FastifyRequest<{ Params: { projectId?: string; reportId: string } }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + return reply.send(await getReportDataCore({ projectId, reportId: req.params.reportId, organizationId: getOrgId(req as RequestWithProjectParam) })); +} + +// --------------------------------------------------------------------------- +// Google Search Console +// --------------------------------------------------------------------------- + +export const zGscOverviewQuery = zDateRange.extend({ + interval: z.enum(['day', 'week', 'month']).default('day'), +}); + +export async function gscOverview( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetOverviewCore({ projectId, startDate, endDate, interval: req.query.interval })); +} + +export const zGscLimitQuery = zDateRange.extend({ limit: z.number().int().min(1).max(1000).default(100) }); + +export async function gscPages( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetTopPagesCore({ projectId, startDate, endDate, limit: req.query.limit })); +} + +export const zGscPageDetailsQuery = zDateRange.extend({ page: z.string().url() }); + +export async function gscPageDetails( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetPageDetailsCore({ projectId, startDate, endDate, page: req.query.page })); +} + +export async function gscQueries( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetTopQueriesCore({ projectId, startDate, endDate, limit: req.query.limit })); +} + +export const zGscQueryDetailsQuery = zDateRange.extend({ query: z.string() }); + +export async function gscQueryDetails( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetQueryDetailsCore({ projectId, startDate, endDate, query: req.query.query })); +} + +export const zGscOpportunitiesQuery = zDateRange.extend({ minImpressions: z.number().int().min(1).default(50) }); + +export async function gscQueryOpportunities( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetQueryOpportunitiesCore({ projectId, startDate, endDate, minImpressions: req.query.minImpressions })); +} + +export async function gscCannibalization( + req: FastifyRequest<{ Params: { projectId?: string }; Querystring: z.infer }>, + reply: FastifyReply +) { + const projectId = await getProjectId(req as RequestWithProjectParam); + const { startDate, endDate } = await resolveDates(projectId, req.query); + return reply.send(await gscGetCannibalizationCore({ projectId, startDate, endDate })); +} diff --git a/apps/api/src/controllers/query.controller.ts b/apps/api/src/controllers/query.controller.ts deleted file mode 100644 index 468e7cef4..000000000 --- a/apps/api/src/controllers/query.controller.ts +++ /dev/null @@ -1,856 +0,0 @@ -import type { IServiceClientWithProject } from '@openpanel/db'; -import { - ClientType, - findGroupsCore, - findProfilesCore, - getAnalyticsOverviewCore, - getChartStartEndDate, - getEngagementCore, - getEntryExitPagesCore, - getEventPropertyValuesCore, - getFunnelCore, - getGroupCore, - getPagePerformanceCore, - getProfileMetricsCore, - getProfileSessionsCore, - getProfileWithEvents, - getReportDataCore, - getRetentionCohortCore, - getRollingActiveUsersCore, - getSettingsForProject, - getTopPagesCore, - getTrafficBreakdownCore, - getUserFlowCore, - getWeeklyRetentionSeriesCore, - gscGetCannibalizationCore, - gscGetOverviewCore, - gscGetPageDetailsCore, - gscGetQueryDetailsCore, - gscGetQueryOpportunitiesCore, - gscGetTopPagesCore, - gscGetTopQueriesCore, - listDashboardsCore, - listEventNamesCore, - listEventPropertiesCore, - listGroupTypesCore, - listReportsCore, - queryEventsCore, - querySessionsCore, - resolveDateRange, - type TrafficColumn, -} from '@openpanel/db'; -import { zRange } from '@openpanel/validation'; -import type { FastifyReply, FastifyRequest } from 'fastify'; -import { z } from 'zod'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function resolveQueryProjectId( - client: IServiceClientWithProject, - urlProjectId: string | undefined -): string { - if (client.type === ClientType.root) { - if (!urlProjectId) { - throw new Error('projectId URL parameter is required for root clients'); - } - return urlProjectId; - } - if (!client.projectId) { - throw new Error('Client is not associated with a project'); - } - return client.projectId; -} - -export const zDateRange = z.object({ - startDate: z.string().optional(), - endDate: z.string().optional(), - // Convenience shorthand matching the insights API (e.g. ?range=7d, ?range=30d). - // When provided without explicit startDate, it expands to a timezone-aware range. - // Explicit startDate/endDate always take precedence. - range: zRange.optional(), -}); - -type DateRangeInput = z.infer; - -async function resolveDates( - projectId: string, - data: DateRangeInput -): Promise<{ startDate: string; endDate: string }> { - // Explicit dates always win — range is only a shorthand when no startDate is given - if (!data.range || data.startDate) { - return resolveDateRange(data.startDate, data.endDate); - } - const { timezone } = await getSettingsForProject(projectId); - // data.range is guaranteed non-nullish here (checked above); cast to satisfy - // getChartStartEndDate which expects a non-optional range with a default value. - return getChartStartEndDate( - { startDate: data.startDate, endDate: data.endDate, range: data.range! }, - timezone - ); -} - -type RequestWithProjectParam = FastifyRequest<{ - Params: { projectId?: string }; -}>; - -function getProjectId(req: RequestWithProjectParam): string { - return resolveQueryProjectId(req.client!, req.params.projectId); -} - -function getOrgId(req: RequestWithProjectParam): string { - return req.client!.organizationId; -} - -function getClientType(req: RequestWithProjectParam): 'root' | 'read' { - return req.client!.type === ClientType.root ? 'root' : 'read'; -} - -// --------------------------------------------------------------------------- -// Projects -// --------------------------------------------------------------------------- -// Analytics — overview -// --------------------------------------------------------------------------- - -export const zOverviewQuery = zDateRange.extend({ - interval: z.enum(['hour', 'day', 'week', 'month']).optional(), -}); - -export async function getOverview( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getAnalyticsOverviewCore({ - projectId, - startDate, - endDate, - interval: req.query.interval, - }) - ); -} - -// --------------------------------------------------------------------------- -// Analytics — active users -// --------------------------------------------------------------------------- - -export const zActiveUsersQuery = z.object({ - days: z.number().int().min(1).max(90).default(7), -}); - -export async function getActiveUsers( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await getRollingActiveUsersCore({ projectId, days: req.query.days }) - ); -} - -// --------------------------------------------------------------------------- -// Analytics — retention series -// --------------------------------------------------------------------------- - -export async function getRetentionSeries( - req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await getWeeklyRetentionSeriesCore(projectId)); -} - -// --------------------------------------------------------------------------- -// Analytics — retention cohort -// --------------------------------------------------------------------------- - -export async function getRetentionCohort( - req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await getRetentionCohortCore(projectId)); -} - -// --------------------------------------------------------------------------- -// Analytics — pages (top) -// --------------------------------------------------------------------------- - -export async function getTopPages( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send(await getTopPagesCore({ projectId, startDate, endDate })); -} - -// --------------------------------------------------------------------------- -// Analytics — pages (entry/exit) -// --------------------------------------------------------------------------- - -export const zEntryExitQuery = zDateRange.extend({ - mode: z.enum(['entry', 'exit']).default('entry'), -}); - -export async function getEntryExitPages( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getEntryExitPagesCore({ - projectId, - startDate, - endDate, - mode: req.query.mode, - }) - ); -} - -// --------------------------------------------------------------------------- -// Analytics — page performance -// --------------------------------------------------------------------------- - -export const zPagePerfQuery = zDateRange.extend({ - search: z.string().optional(), - sortBy: z - .enum(['sessions', 'pageviews', 'bounce_rate', 'avg_duration']) - .optional(), - sortOrder: z.enum(['asc', 'desc']).optional(), - limit: z.number().int().min(1).max(500).default(50), -}); - -export async function getPagePerformance( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getPagePerformanceCore({ - projectId, - startDate, - endDate, - ...req.query, - }) - ); -} - -// --------------------------------------------------------------------------- -// Analytics — funnel -// --------------------------------------------------------------------------- - -export const zFunnelQuery = zDateRange.extend({ - steps: z - .union([z.array(z.string()), z.string().transform((s) => [s])]) - .refine((a) => a.length >= 2 && a.length <= 10, { - message: 'steps must have between 2 and 10 items', - }), - windowHours: z.number().int().min(1).max(720).default(24), - groupBy: z.enum(['session_id', 'profile_id']).default('session_id'), -}); - -export async function getFunnel( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getFunnelCore({ - projectId, - startDate, - endDate, - steps: req.query.steps, - windowHours: req.query.windowHours, - groupBy: req.query.groupBy, - }) - ); -} - -// --------------------------------------------------------------------------- -// Analytics — traffic (referrers / geo / devices) -// --------------------------------------------------------------------------- - -const referrerColumns = [ - 'referrer_name', - 'referrer_type', - 'referrer', - 'utm_source', - 'utm_medium', - 'utm_campaign', -] as const; -const geoColumns = ['country', 'region', 'city'] as const; -const deviceColumns = ['device', 'browser', 'os'] as const; - -export const zReferrerQuery = zDateRange.extend({ - breakdown: z.enum(referrerColumns).default('referrer_name'), -}); - -export const zGeoQuery = zDateRange.extend({ - breakdown: z.enum(geoColumns).default('country'), -}); - -export const zDeviceQuery = zDateRange.extend({ - breakdown: z.enum(deviceColumns).default('device'), -}); - -export async function getTrafficReferrers( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getTrafficBreakdownCore({ - projectId, - startDate, - endDate, - column: req.query.breakdown as TrafficColumn, - }) - ); -} - -export async function getTrafficGeo( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getTrafficBreakdownCore({ - projectId, - startDate, - endDate, - column: req.query.breakdown as TrafficColumn, - }) - ); -} - -export async function getTrafficDevices( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getTrafficBreakdownCore({ - projectId, - startDate, - endDate, - column: req.query.breakdown as TrafficColumn, - }) - ); -} - -// --------------------------------------------------------------------------- -// Analytics — user flow -// --------------------------------------------------------------------------- - -export const zUserFlowQuery = zDateRange.extend({ - startEvent: z.string(), - endEvent: z.string().optional(), - mode: z.enum(['after', 'before', 'between']).default('after'), - steps: z.number().int().min(2).max(10).default(5), - exclude: z - .union([z.array(z.string()), z.string().transform((s) => [s])]) - .optional(), - include: z - .union([z.array(z.string()), z.string().transform((s) => [s])]) - .optional(), -}); - -export async function getUserFlow( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await getUserFlowCore({ projectId, startDate, endDate, ...req.query }) - ); -} - -// --------------------------------------------------------------------------- -// Analytics — engagement -// --------------------------------------------------------------------------- - -export async function getEngagement( - req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await getEngagementCore(projectId)); -} - -// --------------------------------------------------------------------------- -// Events -// --------------------------------------------------------------------------- - -export const zEventsQuery = zDateRange.extend({ - eventNames: z - .union([z.array(z.string()), z.string().transform((s) => [s])]) - .optional(), - path: z.string().optional(), - country: z.string().optional(), - city: z.string().optional(), - device: z.string().optional(), - browser: z.string().optional(), - os: z.string().optional(), - referrer: z.string().optional(), - referrerName: z.string().optional(), - referrerType: z.string().optional(), - profileId: z.string().optional(), - properties: z.record(z.string(), z.string()).optional(), - limit: z.number().int().min(1).max(100).default(20), -}); - -export async function queryEvents( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await queryEventsCore({ projectId, ...req.query })); -} - -export async function listEventNames( - req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await listEventNamesCore(projectId)); -} - -export const zEventPropertiesQuery = z.object({ - eventName: z.string().optional(), -}); - -export async function listEventProperties( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await listEventPropertiesCore({ projectId, eventName: req.query.eventName }) - ); -} - -export const zPropertyValuesQuery = z.object({ - eventName: z.string(), - propertyKey: z.string(), -}); - -export async function getEventPropertyValues( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await getEventPropertyValuesCore({ projectId, ...req.query }) - ); -} - -// --------------------------------------------------------------------------- -// Profiles -// --------------------------------------------------------------------------- - -export const zProfilesQuery = z.object({ - name: z.string().optional(), - email: z.string().optional(), - country: z.string().optional(), - city: z.string().optional(), - device: z.string().optional(), - browser: z.string().optional(), - inactiveDays: z.number().int().min(1).optional(), - minSessions: z.number().int().min(1).optional(), - performedEvent: z.string().optional(), - sortOrder: z.enum(['asc', 'desc']).default('desc'), - limit: z.number().int().min(1).max(100).default(20), -}); - -export async function findProfiles( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await findProfilesCore({ projectId, ...req.query })); -} - -export const zGetProfileQuery = z.object({ - eventLimit: z.number().int().min(1).max(100).default(20), -}); - -export async function getProfile( - req: FastifyRequest<{ - Params: { projectId?: string; profileId: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const result = await getProfileWithEvents( - projectId, - req.params.profileId, - req.query.eventLimit - ); - if (!result.profile) { - return reply - .status(404) - .send({ error: 'Profile not found', profileId: req.params.profileId }); - } - return reply.send({ - profile: result.profile, - recentEvents: result.recent_events, - }); -} - -export const zProfileSessionsQuery = z.object({ - limit: z.number().int().min(1).max(100).default(20), -}); - -export async function getProfileSessions( - req: FastifyRequest<{ - Params: { projectId?: string; profileId: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await getProfileSessionsCore( - projectId, - req.params.profileId, - req.query.limit - ) - ); -} - -export async function getProfileMetrics( - req: FastifyRequest<{ Params: { projectId?: string; profileId: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await getProfileMetricsCore({ projectId, profileId: req.params.profileId }) - ); -} - -// --------------------------------------------------------------------------- -// Sessions -// --------------------------------------------------------------------------- - -export const zSessionsQuery = zDateRange.extend({ - country: z.string().optional(), - city: z.string().optional(), - device: z.string().optional(), - browser: z.string().optional(), - os: z.string().optional(), - referrer: z.string().optional(), - referrerName: z.string().optional(), - referrerType: z.string().optional(), - profileId: z.string().optional(), - limit: z.number().int().min(1).max(100).default(20), -}); - -export async function querySessions( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await querySessionsCore({ projectId, ...req.query })); -} - -// --------------------------------------------------------------------------- -// Groups -// --------------------------------------------------------------------------- - -export async function listGroupTypes( - req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await listGroupTypesCore(projectId)); -} - -export const zGroupsQuery = z.object({ - type: z.string().optional(), - search: z.string().optional(), - limit: z.number().int().min(1).max(100).default(20), -}); - -export async function findGroups( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send(await findGroupsCore({ projectId, ...req.query })); -} - -export const zGetGroupQuery = z.object({ - memberLimit: z.number().int().min(1).max(50).default(10), -}); - -export async function getGroup( - req: FastifyRequest<{ - Params: { projectId?: string; groupId: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - return reply.send( - await getGroupCore({ - projectId, - groupId: req.params.groupId, - memberLimit: req.query.memberLimit, - }) - ); -} - -// --------------------------------------------------------------------------- -// Dashboards & reports -// --------------------------------------------------------------------------- - -export async function listDashboards( - req: FastifyRequest<{ Params: { projectId?: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const organizationId = getOrgId(req as RequestWithProjectParam); - return reply.send(await listDashboardsCore({ projectId, organizationId })); -} - -export async function listReports( - req: FastifyRequest<{ Params: { projectId?: string; dashboardId: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const organizationId = getOrgId(req as RequestWithProjectParam); - return reply.send( - await listReportsCore({ - projectId, - dashboardId: req.params.dashboardId, - organizationId, - }) - ); -} - -export async function getReportData( - req: FastifyRequest<{ Params: { projectId?: string; reportId: string } }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const organizationId = getOrgId(req as RequestWithProjectParam); - return reply.send( - await getReportDataCore({ - projectId, - reportId: req.params.reportId, - organizationId, - }) - ); -} - -// --------------------------------------------------------------------------- -// Google Search Console -// --------------------------------------------------------------------------- - -export const zGscOverviewQuery = zDateRange.extend({ - interval: z.enum(['day', 'week', 'month']).default('day'), -}); - -export async function gscOverview( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await gscGetOverviewCore({ - projectId, - startDate, - endDate, - interval: req.query.interval, - }) - ); -} - -export const zGscLimitQuery = zDateRange.extend({ - limit: z.number().int().min(1).max(1000).default(100), -}); - -export async function gscPages( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await gscGetTopPagesCore({ - projectId, - startDate, - endDate, - limit: req.query.limit, - }) - ); -} - -export const zGscPageDetailsQuery = zDateRange.extend({ - page: z.string().url(), -}); - -export async function gscPageDetails( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await gscGetPageDetailsCore({ - projectId, - startDate, - endDate, - page: req.query.page, - }) - ); -} - -export async function gscQueries( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await gscGetTopQueriesCore({ - projectId, - startDate, - endDate, - limit: req.query.limit, - }) - ); -} - -export const zGscQueryDetailsQuery = zDateRange.extend({ - query: z.string(), -}); - -export async function gscQueryDetails( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await gscGetQueryDetailsCore({ - projectId, - startDate, - endDate, - query: req.query.query, - }) - ); -} - -export const zGscOpportunitiesQuery = zDateRange.extend({ - minImpressions: z.number().int().min(1).default(50), -}); - -export async function gscQueryOpportunities( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await gscGetQueryOpportunitiesCore({ - projectId, - startDate, - endDate, - minImpressions: req.query.minImpressions, - }) - ); -} - -export async function gscCannibalization( - req: FastifyRequest<{ - Params: { projectId?: string }; - Querystring: z.infer; - }>, - reply: FastifyReply -) { - const projectId = getProjectId(req as RequestWithProjectParam); - const { startDate, endDate } = await resolveDates(projectId, req.query); - return reply.send( - await gscGetCannibalizationCore({ projectId, startDate, endDate }) - ); -} diff --git a/apps/api/src/routes/query.router.test.ts b/apps/api/src/routes/insights.router.test.ts similarity index 100% rename from apps/api/src/routes/query.router.test.ts rename to apps/api/src/routes/insights.router.test.ts diff --git a/apps/api/src/routes/insights.router.ts b/apps/api/src/routes/insights.router.ts index 0e14b6e05..4c506aa90 100644 --- a/apps/api/src/routes/insights.router.ts +++ b/apps/api/src/routes/insights.router.ts @@ -2,15 +2,9 @@ import { Prisma } from '@openpanel/db'; import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; import { z } from 'zod'; -import * as insights from '@/controllers/insights.controller'; +import * as c from '@/controllers/insights.controller'; import { overviewColumns, - zGetMetricsQuery, - zGetTopPagesQuery, - zOverviewGenericQuerystring, -} from '@/controllers/insights.controller'; -import * as query from '@/controllers/query.controller'; -import { zActiveUsersQuery, zDateRange, zDeviceQuery, @@ -20,13 +14,16 @@ import { zFunnelQuery, zGeoQuery, zGetGroupQuery, + zGetMetricsQuery, zGetProfileQuery, + zGetTopPagesQuery, zGroupsQuery, zGscLimitQuery, zGscOpportunitiesQuery, zGscOverviewQuery, zGscPageDetailsQuery, zGscQueryDetailsQuery, + zOverviewGenericQuerystring, zOverviewQuery, zPagePerfQuery, zProfilesQuery, @@ -35,7 +32,7 @@ import { zReferrerQuery, zSessionsQuery, zUserFlowQuery, -} from '@/controllers/query.controller'; +} from '@/controllers/insights.controller'; import { validateExportRequest } from '@/utils/auth'; import { parseQueryString } from '@/utils/parse-zod-query-string'; import { activateRateLimiter } from '@/utils/rate-limiter'; @@ -63,6 +60,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { } return reply.status(401).send({ error: 'Unauthorized', message: 'Unexpected error' }); } + }); // Run parseQueryString before Fastify schema validation so coercion @@ -84,7 +82,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zOverviewQuery, }, - handler: query.getOverview, + handler: c.getOverview, }); fastify.route({ @@ -96,7 +94,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zActiveUsersQuery, }, - handler: query.getActiveUsers, + handler: c.getActiveUsers, }); fastify.route({ @@ -107,7 +105,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'Get weekly retention series data.', params: projectIdParam, }, - handler: query.getRetentionSeries, + handler: c.getRetentionSeries, }); fastify.route({ @@ -118,7 +116,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'Get retention cohort data.', params: projectIdParam, }, - handler: query.getRetentionCohort, + handler: c.getRetentionCohort, }); // --------------------------------------------------------------------------- @@ -134,7 +132,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zDateRange, }, - handler: query.getTopPages, + handler: c.getTopPages, }); fastify.route({ @@ -146,7 +144,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zEntryExitQuery, }, - handler: query.getEntryExitPages, + handler: c.getEntryExitPages, }); fastify.route({ @@ -158,7 +156,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zPagePerfQuery, }, - handler: query.getPagePerformance, + handler: c.getPagePerformance, }); // --------------------------------------------------------------------------- @@ -174,7 +172,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGetMetricsQuery, }, - handler: insights.getMetrics, + handler: c.getMetrics, }); fastify.route({ @@ -185,7 +183,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'Get the current number of live (active) visitors.', params: projectIdParam, }, - handler: insights.getLiveVisitors, + handler: c.getLiveVisitors, }); fastify.route({ @@ -197,7 +195,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGetTopPagesQuery, }, - handler: insights.getPages, + handler: c.getPages, }); for (const column of overviewColumns) { @@ -210,7 +208,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zOverviewGenericQuerystring, }, - handler: insights.getOverviewGeneric(column), + handler: c.getOverviewGeneric(column), }); } @@ -227,7 +225,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zFunnelQuery, }, - handler: query.getFunnel, + handler: c.getFunnel, }); // --------------------------------------------------------------------------- @@ -243,7 +241,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zReferrerQuery, }, - handler: query.getTrafficReferrers, + handler: c.getTrafficReferrers, }); fastify.route({ @@ -255,7 +253,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGeoQuery, }, - handler: query.getTrafficGeo, + handler: c.getTrafficGeo, }); fastify.route({ @@ -267,7 +265,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zDeviceQuery, }, - handler: query.getTrafficDevices, + handler: c.getTrafficDevices, }); // --------------------------------------------------------------------------- @@ -283,7 +281,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zUserFlowQuery, }, - handler: query.getUserFlow, + handler: c.getUserFlow, }); fastify.route({ @@ -294,7 +292,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'Get engagement metrics for the project.', params: projectIdParam, }, - handler: query.getEngagement, + handler: c.getEngagement, }); // --------------------------------------------------------------------------- @@ -310,7 +308,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zEventsQuery, }, - handler: query.queryEvents, + handler: c.queryEvents, }); fastify.route({ @@ -321,7 +319,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'List all distinct event names tracked in the project.', params: projectIdParam, }, - handler: query.listEventNames, + handler: c.listEventNames, }); fastify.route({ @@ -333,7 +331,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zEventPropertiesQuery, }, - handler: query.listEventProperties, + handler: c.listEventProperties, }); fastify.route({ @@ -345,7 +343,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zPropertyValuesQuery, }, - handler: query.getEventPropertyValues, + handler: c.getEventPropertyValues, }); // --------------------------------------------------------------------------- @@ -361,7 +359,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zProfilesQuery, }, - handler: query.findProfiles, + handler: c.findProfiles, }); fastify.route({ @@ -373,7 +371,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: profileParam, querystring: zGetProfileQuery, }, - handler: query.getProfile, + handler: c.getProfile, }); fastify.route({ @@ -385,7 +383,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: profileParam, querystring: zProfileSessionsQuery, }, - handler: query.getProfileSessions, + handler: c.getProfileSessions, }); fastify.route({ @@ -396,7 +394,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'Get aggregated metrics for a specific user profile.', params: profileParam, }, - handler: query.getProfileMetrics, + handler: c.getProfileMetrics, }); // --------------------------------------------------------------------------- @@ -412,7 +410,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zSessionsQuery, }, - handler: query.querySessions, + handler: c.querySessions, }); // --------------------------------------------------------------------------- @@ -427,7 +425,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'List all group types defined in the project.', params: projectIdParam, }, - handler: query.listGroupTypes, + handler: c.listGroupTypes, }); fastify.route({ @@ -439,7 +437,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGroupsQuery, }, - handler: query.findGroups, + handler: c.findGroups, }); fastify.route({ @@ -451,7 +449,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: groupParam, querystring: zGetGroupQuery, }, - handler: query.getGroup, + handler: c.getGroup, }); // --------------------------------------------------------------------------- @@ -466,7 +464,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { description: 'Get the data for a saved report.', params: reportParam, }, - handler: query.getReportData, + handler: c.getReportData, }); // --------------------------------------------------------------------------- @@ -482,7 +480,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGscOverviewQuery, }, - handler: query.gscOverview, + handler: c.gscOverview, }); fastify.route({ @@ -494,7 +492,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGscLimitQuery, }, - handler: query.gscPages, + handler: c.gscPages, }); fastify.route({ @@ -506,7 +504,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGscPageDetailsQuery, }, - handler: query.gscPageDetails, + handler: c.gscPageDetails, }); fastify.route({ @@ -518,7 +516,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGscLimitQuery, }, - handler: query.gscQueries, + handler: c.gscQueries, }); fastify.route({ @@ -530,7 +528,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGscQueryDetailsQuery, }, - handler: query.gscQueryDetails, + handler: c.gscQueryDetails, }); fastify.route({ @@ -542,7 +540,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zGscOpportunitiesQuery, }, - handler: query.gscQueryOpportunities, + handler: c.gscQueryOpportunities, }); fastify.route({ @@ -554,7 +552,7 @@ const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { params: projectIdParam, querystring: zDateRange, }, - handler: query.gscCannibalization, + handler: c.gscCannibalization, }); }; diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts index 895dae51d..43e72167c 100644 --- a/apps/api/src/routes/manage.router.ts +++ b/apps/api/src/routes/manage.router.ts @@ -1,9 +1,9 @@ -import { Prisma } from '@openpanel/db'; +import { Prisma, resolveClientProjectId } from '@openpanel/db'; import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZodOpenApi } from 'fastify-zod-openapi'; import { z } from 'zod'; import * as controller from '@/controllers/manage.controller'; -import { listDashboards, listReports } from '@/controllers/query.controller'; +import { listDashboards, listReports } from '@/controllers/insights.controller'; import { zCreateClient, zCreateProject, @@ -46,6 +46,22 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { .status(401) .send({ error: 'Unauthorized', message: 'Unexpected error' }); } + + // Validate :projectId URL param belongs to this client's organization. + const client = req.client!; + const params = req.params as { projectId?: string }; + if (params.projectId) { + try { + await resolveClientProjectId({ + clientType: 'root', + clientProjectId: null, + organizationId: client.organizationId, + inputProjectId: params.projectId, + }); + } catch { + return reply.status(403).send({ error: 'Forbidden', message: 'Project does not belong to your organization' }); + } + } }); // Projects routes diff --git a/apps/api/src/routes/mcp.router.ts b/apps/api/src/routes/mcp.router.ts index b35186f78..e7f23219d 100644 --- a/apps/api/src/routes/mcp.router.ts +++ b/apps/api/src/routes/mcp.router.ts @@ -1,8 +1,4 @@ -import { - SessionManager, - handleMcpGet, - handleMcpPost, -} from '@openpanel/mcp'; +import { handleMcpGet, handleMcpPost, SessionManager } from '@openpanel/mcp'; import type { FastifyPluginAsync } from 'fastify'; /** @@ -21,7 +17,7 @@ const mcpRouter: FastifyPluginAsync = async (fastify) => { * First request: authenticate via ?token= query param or Authorization: Bearer. * Subsequent requests: route by Mcp-Session-Id header. */ - fastify.post('/', async (req, reply) => { + await fastify.post('/', async (req, reply) => { // Hand off full response control to the MCP transport reply.hijack(); await handleMcpPost( @@ -39,7 +35,7 @@ const mcpRouter: FastifyPluginAsync = async (fastify) => { * Establishes an SSE stream for server-to-client notifications. * Requires Mcp-Session-Id header from a previously initialized session. */ - fastify.get('/', async (req, reply) => { + await fastify.get('/', async (req, reply) => { reply.hijack(); await handleMcpGet(mcpSessionManager, req.raw, reply.raw); }); @@ -49,7 +45,7 @@ const mcpRouter: FastifyPluginAsync = async (fastify) => { * * Explicitly close an MCP session and free its resources. */ - fastify.delete('/', async (req, reply) => { + await fastify.delete('/', async (req, reply) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId) { return reply diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index 38e828dba..f550c1b0b 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -1,7 +1,7 @@ import { cacheable } from '@openpanel/redis'; import sqlstring from 'sqlstring'; import { chQuery, TABLE_NAMES } from '../clickhouse/client'; -import type { Prisma, Project } from '../prisma-client'; +import { ClientType, type Prisma, type Project } from '../prisma-client'; import { db } from '../prisma-client'; export type IServiceProject = Project; @@ -110,6 +110,49 @@ export const getProjectEventsCount = async (projectId: string) => { return res[0]?.count; }; +/** + * Resolve and validate a projectId for an API client. + * + * - Read clients: returns the fixed projectId from the client (ignores any supplied value). + * - Root clients: validates that the supplied projectId belongs to the client's organization. + * + * Throws if the project is not found or does not belong to the organization. + * Use this as the single source of truth for projectId resolution across the API and MCP. + */ +export async function resolveClientProjectId({ + clientType, + clientProjectId, + organizationId, + inputProjectId, +}: { + clientType: 'read' | 'root'; + clientProjectId: string | null; + organizationId: string; + inputProjectId: string | undefined; +}): Promise { + if (clientType !== 'root') { + if (!clientProjectId) { + throw new Error('Client is not associated with a project'); + } + return clientProjectId; + } + + if (!inputProjectId) { + throw new Error('projectId is required when using a root (organization-level) client'); + } + + const project = await db.project.findFirst({ + where: { id: inputProjectId, organizationId }, + select: { id: true }, + }); + + if (!project) { + throw new Error('Project not found or does not belong to your organization'); + } + + return inputProjectId; +} + export async function listProjectsCore(input: { clientType: 'root' | 'read'; organizationId: string; diff --git a/packages/logger/index.ts b/packages/logger/index.ts index 938e76656..8c68a2382 100644 --- a/packages/logger/index.ts +++ b/packages/logger/index.ts @@ -64,6 +64,14 @@ export function createLogger({ name }: { name: string }): ILogger { 'apiKey', ]; + const sensitiveUrlParamPattern = new RegExp( + `([?&])(${sensitiveKeys.join('|')})=([^&]*)`, + 'gi', + ); + + const redactUrl = (value: string): string => + value.replace(sensitiveUrlParamPattern, '$1$2=[REDACTED]'); + const redactSensitiveInfo = winston.format((info) => { const redactObject = (obj: any): any => { if (!obj || typeof obj !== 'object') { @@ -74,6 +82,8 @@ export function createLogger({ name }: { name: string }): ILogger { const lowerKey = key.toLowerCase(); if (sensitiveKeys.some((k) => lowerKey.includes(k))) { acc[key] = '[REDACTED]'; + } else if (typeof obj[key] === 'string') { + acc[key] = redactUrl(obj[key]); } else if (typeof obj[key] === 'object') { if (obj[key] instanceof Date) { acc[key] = obj[key].toISOString(); diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 54fa68402..28ac7b46f 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -120,21 +120,3 @@ export function extractToken( return undefined; } -/** - * Resolve the effective projectId for a tool call. - * For read clients the projectId is fixed; for root clients it must be supplied. - */ -export function resolveProjectId( - context: McpAuthContext, - inputProjectId: string | undefined, -): string { - if (context.projectId !== null) { - return context.projectId; - } - if (!inputProjectId) { - throw new Error( - 'projectId is required when using a root (organization-level) client', - ); - } - return inputProjectId; -} diff --git a/packages/mcp/src/session-manager.ts b/packages/mcp/src/session-manager.ts index 8f5f92729..535a7c635 100644 --- a/packages/mcp/src/session-manager.ts +++ b/packages/mcp/src/session-manager.ts @@ -29,7 +29,7 @@ interface McpLocalSession { * when that instance goes down the client reconnects and gets a fresh session. */ export class SessionManager { - private local = new Map(); + private readonly local = new Map(); generateId(): string { return randomUUID(); @@ -47,7 +47,7 @@ export class SessionManager { }); } - async getContext(id: string): Promise { + getContext(id: string): Promise { return getRedisCache().getJson(redisKey(id)); } diff --git a/packages/mcp/src/tools/analytics/active-users.ts b/packages/mcp/src/tools/analytics/active-users.ts index 0b6e69455..b1feb5afc 100644 --- a/packages/mcp/src/tools/analytics/active-users.ts +++ b/packages/mcp/src/tools/analytics/active-users.ts @@ -1,10 +1,10 @@ -import { getRollingActiveUsers, getRetentionSeries } from '@openpanel/db'; +import { resolveClientProjectId, getRollingActiveUsers, getRetentionSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, } from '../shared'; @@ -26,7 +26,7 @@ export function registerActiveUserTools( }, async ({ projectId: inputProjectId, days }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const data = await getRollingActiveUsers({ projectId, days }); return { window_days: days, @@ -44,7 +44,7 @@ export function registerActiveUserTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); return getRetentionSeries({ projectId }); }), ); diff --git a/packages/mcp/src/tools/analytics/engagement.test.ts b/packages/mcp/src/tools/analytics/engagement.test.ts index 9d7d9548a..3912e7c43 100644 --- a/packages/mcp/src/tools/analytics/engagement.test.ts +++ b/packages/mcp/src/tools/analytics/engagement.test.ts @@ -4,6 +4,7 @@ const mockGetRetentionLastSeenSeries = vi.hoisted(() => vi.fn()); vi.mock('@openpanel/db', () => ({ getRetentionLastSeenSeries: mockGetRetentionLastSeenSeries, + resolveClientProjectId: vi.fn(({ clientProjectId }: { clientProjectId: string }) => Promise.resolve(clientProjectId)), })); // Import after mock is set up diff --git a/packages/mcp/src/tools/analytics/engagement.ts b/packages/mcp/src/tools/analytics/engagement.ts index b9b9b0822..080f118ea 100644 --- a/packages/mcp/src/tools/analytics/engagement.ts +++ b/packages/mcp/src/tools/analytics/engagement.ts @@ -1,7 +1,7 @@ -import { getRetentionLastSeenSeries } from '@openpanel/db'; +import { resolveClientProjectId, getRetentionLastSeenSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; -import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; +import { projectIdSchema, withErrorHandling } from '../shared'; export function registerEngagementTools( server: McpServer, @@ -15,7 +15,7 @@ export function registerEngagementTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const raw = await getRetentionLastSeenSeries({ projectId }); // Bucket into meaningful segments for easier reading diff --git a/packages/mcp/src/tools/analytics/event-names.ts b/packages/mcp/src/tools/analytics/event-names.ts index 9f0a17ed7..43163760d 100644 --- a/packages/mcp/src/tools/analytics/event-names.ts +++ b/packages/mcp/src/tools/analytics/event-names.ts @@ -1,9 +1,9 @@ -import { getTopEventNames } from '@openpanel/db'; +import { resolveClientProjectId, getTopEventNames } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, } from '../shared'; @@ -19,7 +19,7 @@ export function registerEventNameTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const names = await getTopEventNames(projectId); return { event_names: names }; }), diff --git a/packages/mcp/src/tools/analytics/events.ts b/packages/mcp/src/tools/analytics/events.ts index fec83071f..f0c3e0222 100644 --- a/packages/mcp/src/tools/analytics/events.ts +++ b/packages/mcp/src/tools/analytics/events.ts @@ -1,11 +1,11 @@ -import { queryEventsCore } from '@openpanel/db'; +import { resolveClientProjectId, queryEventsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -65,7 +65,7 @@ export function registerEventTools(server: McpServer, context: McpAuthContext) { }, async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); return queryEventsCore({ projectId, ...input }); }), ); diff --git a/packages/mcp/src/tools/analytics/funnel.ts b/packages/mcp/src/tools/analytics/funnel.ts index 8d0c1fda8..3f7955c10 100644 --- a/packages/mcp/src/tools/analytics/funnel.ts +++ b/packages/mcp/src/tools/analytics/funnel.ts @@ -1,4 +1,4 @@ -import { getFunnelCore } from '@openpanel/db'; +import { resolveClientProjectId, getFunnelCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -6,7 +6,7 @@ import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -47,7 +47,7 @@ export function registerFunnelTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, steps, windowHours, groupBy }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getFunnelCore({ projectId, diff --git a/packages/mcp/src/tools/analytics/groups.ts b/packages/mcp/src/tools/analytics/groups.ts index dc05427f7..9d9291f76 100644 --- a/packages/mcp/src/tools/analytics/groups.ts +++ b/packages/mcp/src/tools/analytics/groups.ts @@ -1,8 +1,8 @@ -import { getGroupById, getGroupList, getGroupMemberProfiles, getGroupTypes } from '@openpanel/db'; +import { resolveClientProjectId, getGroupById, getGroupList, getGroupMemberProfiles, getGroupTypes } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; -import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; +import { projectIdSchema, withErrorHandling } from '../shared'; export function registerGroupTools( server: McpServer, @@ -16,7 +16,7 @@ export function registerGroupTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const types = await getGroupTypes(projectId); return { types }; }), @@ -46,7 +46,7 @@ export function registerGroupTools( }, async ({ projectId: inputProjectId, type, search, limit }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); return getGroupList({ projectId, type, search, take: limit ?? 20 }); }), ); @@ -68,7 +68,7 @@ export function registerGroupTools( }, async ({ projectId: inputProjectId, groupId, memberLimit }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const [group, members] = await Promise.all([ getGroupById(groupId, projectId), getGroupMemberProfiles({ diff --git a/packages/mcp/src/tools/analytics/overview.ts b/packages/mcp/src/tools/analytics/overview.ts index ba4c06378..e4a12a58a 100644 --- a/packages/mcp/src/tools/analytics/overview.ts +++ b/packages/mcp/src/tools/analytics/overview.ts @@ -1,11 +1,11 @@ -import { getAnalyticsOverviewCore } from '@openpanel/db'; +import { resolveClientProjectId, getAnalyticsOverviewCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -28,7 +28,7 @@ export function registerOverviewTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, interval }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getAnalyticsOverviewCore({ projectId, diff --git a/packages/mcp/src/tools/analytics/page-performance.test.ts b/packages/mcp/src/tools/analytics/page-performance.test.ts index d57e59ddf..8f4e25b10 100644 --- a/packages/mcp/src/tools/analytics/page-performance.test.ts +++ b/packages/mcp/src/tools/analytics/page-performance.test.ts @@ -11,6 +11,7 @@ vi.mock('@openpanel/db', () => ({ })), ch: {}, getSettingsForProject: mockGetSettingsForProject, + resolveClientProjectId: vi.fn(({ clientProjectId }: { clientProjectId: string }) => Promise.resolve(clientProjectId)), })); import { registerPagePerformanceTools } from './page-performance'; diff --git a/packages/mcp/src/tools/analytics/page-performance.ts b/packages/mcp/src/tools/analytics/page-performance.ts index 86e4db5bd..3f97c7eb1 100644 --- a/packages/mcp/src/tools/analytics/page-performance.ts +++ b/packages/mcp/src/tools/analytics/page-performance.ts @@ -1,11 +1,11 @@ -import { PagesService, ch, getSettingsForProject } from '@openpanel/db'; +import { resolveClientProjectId, PagesService, ch, getSettingsForProject } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -47,7 +47,7 @@ export function registerPagePerformanceTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, search, sortBy, sortOrder, limit }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); const { timezone } = await getSettingsForProject(projectId); diff --git a/packages/mcp/src/tools/analytics/pages.ts b/packages/mcp/src/tools/analytics/pages.ts index 1c86461fa..6b39064b6 100644 --- a/packages/mcp/src/tools/analytics/pages.ts +++ b/packages/mcp/src/tools/analytics/pages.ts @@ -1,4 +1,4 @@ -import { getEntryExitPagesCore, getTopPagesCore } from '@openpanel/db'; +import { resolveClientProjectId, getEntryExitPagesCore, getTopPagesCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -6,7 +6,7 @@ import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -21,7 +21,7 @@ export function registerPageTools(server: McpServer, context: McpAuthContext) { }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getTopPagesCore({ projectId, startDate, endDate }); }), @@ -41,7 +41,7 @@ export function registerPageTools(server: McpServer, context: McpAuthContext) { }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, mode }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getEntryExitPagesCore({ projectId, startDate, endDate, mode }); }), diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts index c768a5290..0c0918ad9 100644 --- a/packages/mcp/src/tools/analytics/profile-metrics.ts +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -1,10 +1,10 @@ -import { getProfileMetrics } from '@openpanel/db'; +import { resolveClientProjectId, getProfileMetrics } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, } from '../shared'; @@ -21,7 +21,7 @@ export function registerProfileMetricTools( }, async ({ projectId: inputProjectId, profileId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const raw = await getProfileMetrics(profileId, projectId); if (!raw) { return { error: 'Profile not found or has no events', profileId }; diff --git a/packages/mcp/src/tools/analytics/profiles.ts b/packages/mcp/src/tools/analytics/profiles.ts index 331dbaf79..5d2afa7d8 100644 --- a/packages/mcp/src/tools/analytics/profiles.ts +++ b/packages/mcp/src/tools/analytics/profiles.ts @@ -1,4 +1,4 @@ -import { findProfilesCore, getProfileSessionsCore, getProfileWithEvents } from '@openpanel/db'; +import { resolveClientProjectId, findProfilesCore, getProfileSessionsCore, getProfileWithEvents } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -6,7 +6,7 @@ import type { McpAuthContext } from '../../auth'; import { profileUrl, sessionUrl } from '../dashboard-links'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, } from '../shared'; @@ -72,7 +72,7 @@ export function registerProfileTools( }, async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const profiles = await findProfilesCore({ projectId, ...input }); return profiles.map((p) => ({ ...p, @@ -97,7 +97,7 @@ export function registerProfileTools( }, async ({ projectId: inputProjectId, profileId, eventLimit }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const result = await getProfileWithEvents(projectId, profileId, eventLimit); if (!result.profile) { return { error: 'Profile not found', profileId }; @@ -125,7 +125,7 @@ export function registerProfileTools( }, async ({ projectId: inputProjectId, profileId, limit }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const sessions = await getProfileSessionsCore(projectId, profileId, limit); return { profileId, diff --git a/packages/mcp/src/tools/analytics/property-values.ts b/packages/mcp/src/tools/analytics/property-values.ts index 23b03b769..ac770e6af 100644 --- a/packages/mcp/src/tools/analytics/property-values.ts +++ b/packages/mcp/src/tools/analytics/property-values.ts @@ -1,10 +1,10 @@ -import { TABLE_NAMES, ch, clix } from '@openpanel/db'; +import { resolveClientProjectId, TABLE_NAMES, ch, clix } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, } from '../shared'; @@ -24,7 +24,7 @@ export function registerPropertyValueTools( }, async ({ projectId: inputProjectId, eventName }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const builder = clix(ch) .select<{ property_key: string; event_name: string }>([ 'distinct property_key', @@ -58,7 +58,7 @@ export function registerPropertyValueTools( }, async ({ projectId: inputProjectId, eventName, propertyKey }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const rows = await clix(ch) .select<{ value: string }>(['property_value as value']) .from(TABLE_NAMES.event_property_values_mv) diff --git a/packages/mcp/src/tools/analytics/reports.ts b/packages/mcp/src/tools/analytics/reports.ts index 4f85f4792..cf952b49f 100644 --- a/packages/mcp/src/tools/analytics/reports.ts +++ b/packages/mcp/src/tools/analytics/reports.ts @@ -1,4 +1,4 @@ -import { +import { resolveClientProjectId, AggregateChartEngine, ChartEngine, db, @@ -14,7 +14,7 @@ import type { McpAuthContext } from '../../auth'; import { dashboardBaseUrl } from '../dashboard-links'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, } from '../shared'; @@ -46,7 +46,7 @@ export function registerReportTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const dashboards = await db.dashboard.findMany({ where: { projectId }, orderBy: { createdAt: 'desc' }, @@ -68,7 +68,7 @@ export function registerReportTools( }, async ({ projectId: inputProjectId, dashboardId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const reports = await getReportsByDashboardId(dashboardId); return reports.map((r) => ({ id: r.id, @@ -103,7 +103,7 @@ export function registerReportTools( }, async ({ projectId: inputProjectId, reportId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const report = await getReportById(reportId); if (!report) { diff --git a/packages/mcp/src/tools/analytics/retention.ts b/packages/mcp/src/tools/analytics/retention.ts index 6e9b27543..c40ac48bc 100644 --- a/packages/mcp/src/tools/analytics/retention.ts +++ b/packages/mcp/src/tools/analytics/retention.ts @@ -1,9 +1,9 @@ -import { getRetentionCohortTable } from '@openpanel/db'; +import { resolveClientProjectId, getRetentionCohortTable } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, } from '../shared'; @@ -19,7 +19,7 @@ export function registerRetentionTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); return getRetentionCohortTable({ projectId }); }), ); diff --git a/packages/mcp/src/tools/analytics/sessions.ts b/packages/mcp/src/tools/analytics/sessions.ts index 7e9ee2e2b..40b93ad91 100644 --- a/packages/mcp/src/tools/analytics/sessions.ts +++ b/packages/mcp/src/tools/analytics/sessions.ts @@ -1,11 +1,11 @@ -import { querySessionsCore } from '@openpanel/db'; +import { resolveClientProjectId, querySessionsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { sessionUrl } from '../dashboard-links'; import { projectIdSchema, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -51,7 +51,7 @@ export function registerSessionTools( }, async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const sessions = await querySessionsCore({ projectId, ...input }); return sessions.map((s) => ({ ...s, diff --git a/packages/mcp/src/tools/analytics/traffic.ts b/packages/mcp/src/tools/analytics/traffic.ts index db8a8ee24..05df19754 100644 --- a/packages/mcp/src/tools/analytics/traffic.ts +++ b/packages/mcp/src/tools/analytics/traffic.ts @@ -1,11 +1,11 @@ -import { getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; +import { resolveClientProjectId, getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -30,7 +30,7 @@ export function registerTrafficTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getTrafficBreakdownCore({ projectId, @@ -55,7 +55,7 @@ export function registerTrafficTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getTrafficBreakdownCore({ projectId, @@ -82,7 +82,7 @@ export function registerTrafficTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getTrafficBreakdownCore({ projectId, diff --git a/packages/mcp/src/tools/analytics/user-flow.ts b/packages/mcp/src/tools/analytics/user-flow.ts index 32cb76764..70ff2357a 100644 --- a/packages/mcp/src/tools/analytics/user-flow.ts +++ b/packages/mcp/src/tools/analytics/user-flow.ts @@ -1,4 +1,4 @@ -import { getUserFlowCore } from '@openpanel/db'; +import { resolveClientProjectId, getUserFlowCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -6,7 +6,7 @@ import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -53,7 +53,7 @@ export function registerUserFlowTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, startEvent, endEvent, mode, steps, exclude, include }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getUserFlowCore({ projectId, startDate, endDate, startEvent, endEvent, mode, steps, exclude, include }); }), diff --git a/packages/mcp/src/tools/dashboard-links.ts b/packages/mcp/src/tools/dashboard-links.ts index 59857f8fe..58272186d 100644 --- a/packages/mcp/src/tools/dashboard-links.ts +++ b/packages/mcp/src/tools/dashboard-links.ts @@ -1,7 +1,8 @@ +import { resolveClientProjectId } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../auth'; -import { projectIdSchema, resolveProjectId, withErrorHandling } from './shared'; +import { projectIdSchema, withErrorHandling } from './shared'; export function dashboardBaseUrl() { return ( @@ -35,7 +36,7 @@ export function registerDashboardLinkTools( }, async ({ projectId: inputProjectId, profileId, sessionId, dashboardId, reportId }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const base = `${dashboardBaseUrl()}/${context.organizationId}/${projectId}`; const urls: Record = { diff --git a/packages/mcp/src/tools/gsc/cannibalization.ts b/packages/mcp/src/tools/gsc/cannibalization.ts index f692e16a8..a6e15d5b0 100644 --- a/packages/mcp/src/tools/gsc/cannibalization.ts +++ b/packages/mcp/src/tools/gsc/cannibalization.ts @@ -1,10 +1,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { getGscCannibalization } from '@openpanel/db'; +import { resolveClientProjectId, getGscCannibalization } from '@openpanel/db'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -21,8 +21,8 @@ export function registerGscCannibalizationTools( ...zDateRange, }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => - withErrorHandling(() => { - const projectId = resolveProjectId(context, inputProjectId); + withErrorHandling(async () => { + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscCannibalization(projectId, startDate, endDate); }) diff --git a/packages/mcp/src/tools/gsc/overview.ts b/packages/mcp/src/tools/gsc/overview.ts index 3fd52568e..7dfd40db0 100644 --- a/packages/mcp/src/tools/gsc/overview.ts +++ b/packages/mcp/src/tools/gsc/overview.ts @@ -1,11 +1,11 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { getGscOverview } from '@openpanel/db'; +import { resolveClientProjectId, getGscOverview } from '@openpanel/db'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -33,7 +33,7 @@ export function registerGscOverviewTools( interval, }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); const data = await getGscOverview( projectId, diff --git a/packages/mcp/src/tools/gsc/pages.ts b/packages/mcp/src/tools/gsc/pages.ts index d2f3124c2..b488f1af5 100644 --- a/packages/mcp/src/tools/gsc/pages.ts +++ b/packages/mcp/src/tools/gsc/pages.ts @@ -1,11 +1,11 @@ -import { getGscPageDetails, getGscPages } from '@openpanel/db'; +import { resolveClientProjectId, getGscPageDetails, getGscPages } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -30,7 +30,7 @@ export function registerGscPageTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscPages(projectId, startDate, endDate, limit ?? 100); }), @@ -49,7 +49,7 @@ export function registerGscPageTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, page }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscPageDetails(projectId, page, startDate, endDate); }), diff --git a/packages/mcp/src/tools/gsc/queries.ts b/packages/mcp/src/tools/gsc/queries.ts index 5f3cd7df8..b11d6af2b 100644 --- a/packages/mcp/src/tools/gsc/queries.ts +++ b/packages/mcp/src/tools/gsc/queries.ts @@ -1,4 +1,4 @@ -import { getGscQueryDetails, getGscQueries } from '@openpanel/db'; +import { resolveClientProjectId, getGscQueryDetails, getGscQueries } from '@openpanel/db'; import type { GscQueryOpportunity } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -6,7 +6,7 @@ import type { McpAuthContext } from '../../auth'; import { projectIdSchema, resolveDateRange, - resolveProjectId, + withErrorHandling, zDateRange, } from '../shared'; @@ -90,7 +90,7 @@ export function registerGscQueryTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscQueries(projectId, startDate, endDate, limit ?? 100); }), @@ -113,7 +113,7 @@ export function registerGscQueryTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, minImpressions }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); const queries = await getGscQueries(projectId, startDate, endDate, 5000); const filtered = queries.filter( @@ -140,7 +140,7 @@ export function registerGscQueryTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, query }) => withErrorHandling(async () => { - const projectId = resolveProjectId(context, inputProjectId); + const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscQueryDetails(projectId, query, startDate, endDate); }), diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 8ee13ef0f..c57b8e877 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,3 +1,4 @@ +import { resolveClientProjectId } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../auth'; import { registerActiveUserTools } from './analytics/active-users'; diff --git a/packages/mcp/src/tools/shared.test.ts b/packages/mcp/src/tools/shared.test.ts index c54765aff..20c232dbd 100644 --- a/packages/mcp/src/tools/shared.test.ts +++ b/packages/mcp/src/tools/shared.test.ts @@ -1,6 +1,7 @@ +import { resolveClientProjectId } from '@openpanel/db'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { McpAuthContext } from '../auth'; -import { resolveDateRange, resolveProjectId } from './shared'; +import { resolveDateRange } from './shared'; const READ_CTX: McpAuthContext = { projectId: 'proj-abc', @@ -46,19 +47,28 @@ describe('resolveDateRange', () => { }); }); -describe('resolveProjectId', () => { - it('returns the context projectId for read clients, ignoring any input', () => { - expect(resolveProjectId(READ_CTX, undefined)).toBe('proj-abc'); - expect(resolveProjectId(READ_CTX, 'other-proj')).toBe('proj-abc'); +describe('resolveClientProjectId', () => { + it('returns the context projectId for read clients, ignoring any input', async () => { + await expect(resolveClientProjectId({ + clientType: READ_CTX.clientType, + clientProjectId: READ_CTX.projectId, + organizationId: READ_CTX.organizationId, + inputProjectId: undefined, + })).resolves.toBe('proj-abc'); + await expect(resolveClientProjectId({ + clientType: READ_CTX.clientType, + clientProjectId: READ_CTX.projectId, + organizationId: READ_CTX.organizationId, + inputProjectId: 'other-proj', + })).resolves.toBe('proj-abc'); }); - it('returns the input projectId for root clients', () => { - expect(resolveProjectId(ROOT_CTX, 'proj-xyz')).toBe('proj-xyz'); - }); - - it('throws for root clients when no projectId is provided', () => { - expect(() => resolveProjectId(ROOT_CTX, undefined)).toThrow( - 'projectId is required', - ); + it('throws for root clients when no projectId is provided', async () => { + await expect(resolveClientProjectId({ + clientType: ROOT_CTX.clientType, + clientProjectId: ROOT_CTX.projectId, + organizationId: ROOT_CTX.organizationId, + inputProjectId: undefined, + })).rejects.toThrow('projectId is required'); }); }); diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts index 623dfd8f5..61e47c02a 100644 --- a/packages/mcp/src/tools/shared.ts +++ b/packages/mcp/src/tools/shared.ts @@ -20,23 +20,6 @@ export function projectIdSchema(context: McpAuthContext) { : z.string().optional(); } -/** - * Resolve the effective projectId from context + optional tool input. - */ -export function resolveProjectId( - context: McpAuthContext, - inputProjectId: string | undefined -): string { - if (context.projectId !== null) { - return context.projectId; - } - if (!inputProjectId) { - throw new Error( - 'projectId is required when using a root (organization-level) client' - ); - } - return inputProjectId; -} /** * Zod schema for common date range inputs. Both fields are optional and From 435d3b3ee47cc7ec21efa7d4cdc0eaf47f7e7014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 8 Apr 2026 10:33:01 +0200 Subject: [PATCH 12/18] final fixes --- apps/api/src/routes/mcp.router.ts | 25 ++- apps/api/src/utils/graceful-shutdown.ts | 14 +- packages/db/src/services/pages.service.ts | 64 +++++- packages/mcp/index.ts | 2 +- packages/mcp/src/auth.test.ts | 159 ++++++++++++++ packages/mcp/src/auth.ts | 4 +- packages/mcp/src/session-manager.test.ts | 161 ++++++++++++++ .../mcp/src/tools/analytics/active-users.ts | 7 +- .../mcp/src/tools/analytics/engagement.ts | 8 +- .../mcp/src/tools/analytics/event-names.ts | 5 +- packages/mcp/src/tools/analytics/events.ts | 5 +- packages/mcp/src/tools/analytics/funnel.ts | 5 +- packages/mcp/src/tools/analytics/groups.ts | 12 +- packages/mcp/src/tools/analytics/overview.ts | 5 +- .../tools/analytics/page-conversions.test.ts | 205 ++++++++++++++++++ .../src/tools/analytics/page-conversions.ts | 71 ++++++ .../src/tools/analytics/page-performance.ts | 5 +- packages/mcp/src/tools/analytics/pages.ts | 7 +- .../src/tools/analytics/profile-metrics.ts | 5 +- packages/mcp/src/tools/analytics/profiles.ts | 9 +- .../src/tools/analytics/property-values.ts | 7 +- packages/mcp/src/tools/analytics/reports.ts | 32 +-- packages/mcp/src/tools/analytics/retention.ts | 5 +- packages/mcp/src/tools/analytics/sessions.ts | 5 +- packages/mcp/src/tools/analytics/traffic.ts | 9 +- packages/mcp/src/tools/analytics/user-flow.ts | 5 +- packages/mcp/src/tools/dashboard-links.ts | 8 +- packages/mcp/src/tools/gsc/cannibalization.ts | 5 +- packages/mcp/src/tools/gsc/overview.ts | 5 +- packages/mcp/src/tools/gsc/pages.ts | 7 +- packages/mcp/src/tools/gsc/queries.ts | 9 +- packages/mcp/src/tools/index.ts | 2 + packages/mcp/src/tools/shared.ts | 17 ++ 33 files changed, 802 insertions(+), 92 deletions(-) create mode 100644 packages/mcp/src/auth.test.ts create mode 100644 packages/mcp/src/session-manager.test.ts create mode 100644 packages/mcp/src/tools/analytics/page-conversions.test.ts create mode 100644 packages/mcp/src/tools/analytics/page-conversions.ts diff --git a/apps/api/src/routes/mcp.router.ts b/apps/api/src/routes/mcp.router.ts index e7f23219d..99a8e79d6 100644 --- a/apps/api/src/routes/mcp.router.ts +++ b/apps/api/src/routes/mcp.router.ts @@ -1,5 +1,6 @@ -import { handleMcpGet, handleMcpPost, SessionManager } from '@openpanel/mcp'; +import { McpAuthError, authenticateToken, extractToken, handleMcpGet, handleMcpPost, SessionManager } from '@openpanel/mcp'; import type { FastifyPluginAsync } from 'fastify'; +import { activateRateLimiter } from '@/utils/rate-limiter'; /** * Singleton session manager — lives for the lifetime of the API process. @@ -8,6 +9,8 @@ import type { FastifyPluginAsync } from 'fastify'; export const mcpSessionManager = new SessionManager(); const mcpRouter: FastifyPluginAsync = async (fastify) => { + await activateRateLimiter({ fastify, max: 60, timeWindow: '1 minute' }); + /** * POST /mcp * @@ -44,18 +47,32 @@ const mcpRouter: FastifyPluginAsync = async (fastify) => { * DELETE /mcp * * Explicitly close an MCP session and free its resources. + * Requires the same auth token used to create the session — verified against + * the session's organizationId to prevent one client closing another's session. */ await fastify.delete('/', async (req, reply) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId) { - return reply - .status(400) - .send({ error: 'Mcp-Session-Id header is required' }); + return reply.status(400).send({ error: 'Mcp-Session-Id header is required' }); + } + + const token = extractToken(req.query as Record, req.headers.authorization); + let callerContext; + try { + callerContext = await authenticateToken(token); + } catch (err) { + return reply.status(401).send({ error: err instanceof McpAuthError ? err.message : 'Unauthorized' }); } + const context = await mcpSessionManager.getContext(sessionId); if (!context) { return reply.status(404).send({ error: 'Session not found' }); } + + if (context.organizationId !== callerContext.organizationId) { + return reply.status(403).send({ error: 'Forbidden' }); + } + await mcpSessionManager.close(sessionId); return reply.status(200).send({ ok: true }); }); diff --git a/apps/api/src/utils/graceful-shutdown.ts b/apps/api/src/utils/graceful-shutdown.ts index 9aeb1b046..1b2e970e0 100644 --- a/apps/api/src/utils/graceful-shutdown.ts +++ b/apps/api/src/utils/graceful-shutdown.ts @@ -41,11 +41,11 @@ export async function shutdown( setShuttingDown(true); - // Step 2: Wait for load balancer to stop sending traffic (matches preStop sleep) + // Step 1: Wait for load balancer to stop sending traffic (matches preStop sleep) const gracePeriod = Number(process.env.SHUTDOWN_GRACE_PERIOD_MS || '5000'); await new Promise((resolve) => setTimeout(resolve, gracePeriod)); - // Step 3: Close Fastify to drain in-flight requests + // Step 2: Close Fastify to drain in-flight requests try { await fastify.close(); logger.info('Fastify server closed'); @@ -53,7 +53,7 @@ export async function shutdown( logger.error('Error closing Fastify server', error); } - // Step 4: Destroy MCP sessions + // Step 3: Destroy MCP sessions try { await mcpSessionManager.destroy(); logger.info('MCP sessions closed'); @@ -61,7 +61,7 @@ export async function shutdown( logger.error('Error closing MCP sessions', error); } - // Step 6: Close database connections + // Step 4: Close database connections try { await db.$disconnect(); logger.info('Database connection closed'); @@ -69,7 +69,7 @@ export async function shutdown( logger.error('Error closing database connection', error); } - // Step 7: Close ClickHouse connections + // Step 5: Close ClickHouse connections try { await ch.close(); logger.info('ClickHouse connections closed'); @@ -77,7 +77,7 @@ export async function shutdown( logger.error('Error closing ClickHouse connections', error); } - // Step 8: Close Bull queues (graceful shutdown of queue state) + // Step 6: Close Bull queues (graceful shutdown of queue state) try { await Promise.all([ ...eventsGroupQueues.map((queue) => queue.close()), @@ -91,7 +91,7 @@ export async function shutdown( logger.error('Error closing queue state', error); } - // Step 9: Close Redis connections + // Step 7: Close Redis connections try { const redisConnections = [ getRedisCache(), diff --git a/packages/db/src/services/pages.service.ts b/packages/db/src/services/pages.service.ts index 66133122e..663aaa9db 100644 --- a/packages/db/src/services/pages.service.ts +++ b/packages/db/src/services/pages.service.ts @@ -1,5 +1,6 @@ import type { IInterval } from '@openpanel/validation'; -import { ch, TABLE_NAMES } from '../clickhouse/client'; +import sqlstring from 'sqlstring'; +import { ch, TABLE_NAMES, chQuery } from '../clickhouse/client'; import { clix } from '../clickhouse/query-builder'; export interface IGetPagesInput { @@ -252,3 +253,64 @@ export async function getPagePerformanceCore(input: { pages: annotated, }; } + +export interface IPageConversionRow { + path: string; + origin: string; + unique_converters: number; + total_visitors: number; + conversion_rate: number; +} + +export async function getPageConversionsCore(input: { + projectId: string; + startDate: string; + endDate: string; + conversionEvent: string; + windowHours?: number; + limit?: number; +}): Promise { + const { projectId, startDate, endDate, conversionEvent, windowHours = 24, limit = 100 } = input; + const sql = ` + WITH + conversion_events AS ( + SELECT profile_id, created_at AS conv_time + FROM events + WHERE project_id = ${sqlstring.escape(projectId)} + AND name = ${sqlstring.escape(conversionEvent)} + AND created_at BETWEEN toDateTime(${sqlstring.escape(startDate)}) AND toDateTime(${sqlstring.escape(endDate)}) + ), + views_before_conversions AS ( + SELECT DISTINCT e.profile_id, e.path, e.origin + FROM events AS e + INNER JOIN conversion_events AS c ON e.profile_id = c.profile_id + WHERE e.project_id = ${sqlstring.escape(projectId)} + AND e.name = 'screen_view' + AND e.path != '' + AND e.created_at BETWEEN toDateTime(${sqlstring.escape(startDate)}) AND toDateTime(${sqlstring.escape(endDate)}) + AND e.created_at < c.conv_time + AND e.created_at >= c.conv_time - INTERVAL ${Number(windowHours)} HOUR + ), + total_visitors AS ( + SELECT path, origin, uniq(session_id) AS visitors + FROM events + WHERE project_id = ${sqlstring.escape(projectId)} + AND name = 'screen_view' + AND path != '' + AND created_at BETWEEN toDateTime(${sqlstring.escape(startDate)}) AND toDateTime(${sqlstring.escape(endDate)}) + GROUP BY path, origin + ) + SELECT + vbc.path, + vbc.origin, + count() AS unique_converters, + any(tv.visitors) AS total_visitors, + round(100.0 * count() / any(tv.visitors), 2) AS conversion_rate + FROM views_before_conversions AS vbc + LEFT JOIN total_visitors AS tv ON vbc.path = tv.path AND vbc.origin = tv.origin + GROUP BY vbc.path, vbc.origin + ORDER BY unique_converters DESC + LIMIT ${Number(limit)} + `; + return chQuery(sql); +} diff --git a/packages/mcp/index.ts b/packages/mcp/index.ts index 5f1afe12b..577d576ef 100644 --- a/packages/mcp/index.ts +++ b/packages/mcp/index.ts @@ -1,5 +1,5 @@ export { createMcpServer } from './src/server'; export { SessionManager } from './src/session-manager'; -export { authenticateToken, McpAuthError } from './src/auth'; +export { authenticateToken, McpAuthError, extractToken } from './src/auth'; export { handleMcpGet, handleMcpPost } from './src/handler'; export type { McpAuthContext } from './src/auth'; diff --git a/packages/mcp/src/auth.test.ts b/packages/mcp/src/auth.test.ts new file mode 100644 index 000000000..775e26ed1 --- /dev/null +++ b/packages/mcp/src/auth.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const mockGetClientByIdCached = vi.hoisted(() => vi.fn()); +const mockVerifyPassword = vi.hoisted(() => vi.fn()); +const mockGetCache = vi.hoisted(() => vi.fn()); + +vi.mock('@openpanel/db', () => ({ + ClientType: { write: 'write', read: 'read', root: 'root' }, + getClientByIdCached: mockGetClientByIdCached, +})); + +vi.mock('@openpanel/common/server', () => ({ + verifyPassword: mockVerifyPassword, +})); + +vi.mock('@openpanel/redis', () => ({ + getCache: mockGetCache, +})); + +import { McpAuthError, authenticateToken, extractToken } from './auth'; + +const VALID_CLIENT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; +const VALID_SECRET = 'mysecret'; +const VALID_TOKEN = Buffer.from(`${VALID_CLIENT_ID}:${VALID_SECRET}`).toString('base64'); + +const baseClient = { + id: VALID_CLIENT_ID, + secret: 'hashed_secret', + type: 'read', + projectId: 'proj-123', + organizationId: 'org-456', +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Default: cache calls through to the fn + mockGetCache.mockImplementation((_key: string, _ttl: number, fn: () => Promise) => fn()); + mockVerifyPassword.mockResolvedValue(true); +}); + +// --------------------------------------------------------------------------- +// extractToken +// --------------------------------------------------------------------------- + +describe('extractToken', () => { + it('returns token from ?token= query param', () => { + expect(extractToken({ token: 'abc' }, undefined)).toBe('abc'); + }); + + it('returns token from Authorization Bearer header', () => { + expect(extractToken({}, 'Bearer mytoken')).toBe('mytoken'); + }); + + it('prefers query param over header', () => { + expect(extractToken({ token: 'from-query' }, 'Bearer from-header')).toBe('from-query'); + }); + + it('returns undefined when neither is present', () => { + expect(extractToken({}, undefined)).toBeUndefined(); + }); + + it('returns undefined for non-Bearer auth header', () => { + expect(extractToken({}, 'Basic abc123')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// authenticateToken +// --------------------------------------------------------------------------- + +describe('authenticateToken', () => { + it('throws McpAuthError when token is missing', async () => { + await expect(authenticateToken(undefined)).rejects.toThrow(McpAuthError); + await expect(authenticateToken(undefined)).rejects.toThrow('Missing authentication token'); + }); + + it('throws McpAuthError for non-base64 token', async () => { + // Buffer.from with invalid base64 doesn't throw — but the decoded result won't have a colon + await expect(authenticateToken('!!!invalid!!!')).rejects.toThrow(McpAuthError); + }); + + it('throws McpAuthError when token has no colon separator', async () => { + const token = Buffer.from('nodivider').toString('base64'); + await expect(authenticateToken(token)).rejects.toThrow('Invalid token format'); + }); + + it('throws McpAuthError when clientId is not a UUID', async () => { + const token = Buffer.from('not-a-uuid:secret').toString('base64'); + await expect(authenticateToken(token)).rejects.toThrow('Invalid client ID format'); + }); + + it('throws McpAuthError when clientSecret is empty', async () => { + const token = Buffer.from(`${VALID_CLIENT_ID}:`).toString('base64'); + await expect(authenticateToken(token)).rejects.toThrow('Client secret is required'); + }); + + it('throws McpAuthError when client is not found', async () => { + mockGetClientByIdCached.mockResolvedValue(null); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('Invalid credentials'); + }); + + it('throws McpAuthError when client has no stored secret', async () => { + mockGetClientByIdCached.mockResolvedValue({ ...baseClient, secret: null }); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('no secret'); + }); + + it('throws McpAuthError for write-only clients', async () => { + mockGetClientByIdCached.mockResolvedValue({ ...baseClient, type: 'write' }); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('Write-only clients'); + }); + + it('throws McpAuthError when password verification fails', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + mockVerifyPassword.mockResolvedValue(false); + await expect(authenticateToken(VALID_TOKEN)).rejects.toThrow('Invalid credentials'); + }); + + it('returns read client context on success', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + const ctx = await authenticateToken(VALID_TOKEN); + expect(ctx).toEqual({ + projectId: 'proj-123', + organizationId: 'org-456', + clientType: 'read', + }); + }); + + it('returns root client context with null projectId', async () => { + mockGetClientByIdCached.mockResolvedValue({ ...baseClient, type: 'root', projectId: null }); + const ctx = await authenticateToken(VALID_TOKEN); + expect(ctx).toEqual({ + projectId: null, + organizationId: 'org-456', + clientType: 'root', + }); + }); + + it('uses cache for password verification', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + // Simulate cache returning true without calling verifyPassword + mockGetCache.mockResolvedValue(true); + const ctx = await authenticateToken(VALID_TOKEN); + expect(ctx.clientType).toBe('read'); + expect(mockVerifyPassword).not.toHaveBeenCalled(); + }); + + it('cache key uses SHA-256 hash, not raw secret', async () => { + mockGetClientByIdCached.mockResolvedValue(baseClient); + let capturedKey = ''; + mockGetCache.mockImplementation((key: string, _ttl: number, fn: () => Promise) => { + capturedKey = key; + return fn(); + }); + await authenticateToken(VALID_TOKEN); + expect(capturedKey).toContain(`mcp:auth:${VALID_CLIENT_ID}:`); + expect(capturedKey).not.toContain(VALID_SECRET); + expect(capturedKey).not.toContain(Buffer.from(VALID_SECRET).toString('base64')); + }); +}); diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 28ac7b46f..e16273c38 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { verifyPassword } from '@openpanel/common/server'; import { ClientType, getClientByIdCached } from '@openpanel/db'; import { getCache } from '@openpanel/redis'; @@ -82,7 +83,8 @@ export async function authenticateToken( ); } - const cacheKey = `mcp:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`; + const secretHash = createHash('sha256').update(clientSecret).digest('hex').slice(0, 16); + const cacheKey = `mcp:auth:${clientId}:${secretHash}`; const isVerified = await getCache( cacheKey, 60 * 5, diff --git a/packages/mcp/src/session-manager.test.ts b/packages/mcp/src/session-manager.test.ts new file mode 100644 index 000000000..b5ab162a7 --- /dev/null +++ b/packages/mcp/src/session-manager.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { McpAuthContext } from './auth'; + +const mockSetJson = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockGetJson = vi.hoisted(() => vi.fn()); +const mockExpire = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const mockDel = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock('@openpanel/redis', () => ({ + getRedisCache: () => ({ + setJson: mockSetJson, + getJson: mockGetJson, + expire: mockExpire, + del: mockDel, + }), +})); + +vi.mock('@openpanel/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { SessionManager } from './session-manager'; + +const CTX: McpAuthContext = { + projectId: 'proj-1', + organizationId: 'org-1', + clientType: 'read', +}; + +const mockTransport = { + close: vi.fn().mockResolvedValue(undefined), +}; + +const mockServer = {} as any; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('SessionManager', () => { + describe('generateId', () => { + it('generates unique UUIDs', () => { + const sm = new SessionManager(); + const a = sm.generateId(); + const b = sm.generateId(); + expect(a).not.toBe(b); + expect(a).toMatch(/^[0-9a-f-]{36}$/); + }); + }); + + describe('context (Redis)', () => { + it('stores context in Redis with TTL', async () => { + const sm = new SessionManager(); + await sm.setContext('sess-1', CTX); + expect(mockSetJson).toHaveBeenCalledWith('mcp:session:sess-1', 30 * 60, CTX); + }); + + it('retrieves context from Redis', async () => { + const sm = new SessionManager(); + mockGetJson.mockResolvedValue(CTX); + const result = await sm.getContext('sess-1'); + expect(result).toEqual(CTX); + expect(mockGetJson).toHaveBeenCalledWith('mcp:session:sess-1'); + }); + + it('returns null for missing session', async () => { + const sm = new SessionManager(); + mockGetJson.mockResolvedValue(null); + const result = await sm.getContext('missing'); + expect(result).toBeNull(); + }); + + it('touches TTL on touchContext', async () => { + const sm = new SessionManager(); + await sm.touchContext('sess-1'); + expect(mockExpire).toHaveBeenCalledWith('mcp:session:sess-1', 30 * 60); + }); + + it('deletes context from Redis', async () => { + const sm = new SessionManager(); + await sm.deleteContext('sess-1'); + expect(mockDel).toHaveBeenCalledWith('mcp:session:sess-1'); + }); + }); + + describe('local transport', () => { + it('stores and retrieves local session', () => { + const sm = new SessionManager(); + sm.setLocal('sess-1', { server: mockServer, transport: mockTransport as any }); + expect(sm.getLocal('sess-1')).toBeDefined(); + }); + + it('returns undefined for unknown session', () => { + const sm = new SessionManager(); + expect(sm.getLocal('unknown')).toBeUndefined(); + }); + + it('deletes local session', () => { + const sm = new SessionManager(); + sm.setLocal('sess-1', { server: mockServer, transport: mockTransport as any }); + sm.deleteLocal('sess-1'); + expect(sm.getLocal('sess-1')).toBeUndefined(); + }); + + it('tracks localSize correctly', () => { + const sm = new SessionManager(); + expect(sm.localSize).toBe(0); + sm.setLocal('a', { server: mockServer, transport: mockTransport as any }); + sm.setLocal('b', { server: mockServer, transport: mockTransport as any }); + expect(sm.localSize).toBe(2); + sm.deleteLocal('a'); + expect(sm.localSize).toBe(1); + }); + }); + + describe('close', () => { + it('closes transport, removes local session, and deletes Redis context', async () => { + const sm = new SessionManager(); + sm.setLocal('sess-1', { server: mockServer, transport: mockTransport as any }); + await sm.close('sess-1'); + + expect(mockTransport.close).toHaveBeenCalled(); + expect(sm.getLocal('sess-1')).toBeUndefined(); + expect(mockDel).toHaveBeenCalledWith('mcp:session:sess-1'); + }); + + it('still removes Redis context even when no local session exists', async () => { + const sm = new SessionManager(); + await sm.close('no-local-sess'); + expect(mockDel).toHaveBeenCalledWith('mcp:session:no-local-sess'); + expect(mockTransport.close).not.toHaveBeenCalled(); + }); + + it('does not throw if transport.close fails', async () => { + const sm = new SessionManager(); + const failingTransport = { close: vi.fn().mockRejectedValue(new Error('already closed')) }; + sm.setLocal('sess-1', { server: mockServer, transport: failingTransport as any }); + await expect(sm.close('sess-1')).resolves.toBeUndefined(); + }); + }); + + describe('destroy', () => { + it('closes all local sessions', async () => { + const sm = new SessionManager(); + const t1 = { close: vi.fn().mockResolvedValue(undefined) }; + const t2 = { close: vi.fn().mockResolvedValue(undefined) }; + sm.setLocal('a', { server: mockServer, transport: t1 as any }); + sm.setLocal('b', { server: mockServer, transport: t2 as any }); + + await sm.destroy(); + + expect(t1.close).toHaveBeenCalled(); + expect(t2.close).toHaveBeenCalled(); + expect(sm.localSize).toBe(0); + }); + }); +}); diff --git a/packages/mcp/src/tools/analytics/active-users.ts b/packages/mcp/src/tools/analytics/active-users.ts index b1feb5afc..b48806cb6 100644 --- a/packages/mcp/src/tools/analytics/active-users.ts +++ b/packages/mcp/src/tools/analytics/active-users.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getRollingActiveUsers, getRetentionSeries } from '@openpanel/db'; +import { getRollingActiveUsers, getRetentionSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -6,6 +6,7 @@ import { projectIdSchema, withErrorHandling, + resolveProjectId } from '../shared'; export function registerActiveUserTools( @@ -26,7 +27,7 @@ export function registerActiveUserTools( }, async ({ projectId: inputProjectId, days }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const data = await getRollingActiveUsers({ projectId, days }); return { window_days: days, @@ -44,7 +45,7 @@ export function registerActiveUserTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); return getRetentionSeries({ projectId }); }), ); diff --git a/packages/mcp/src/tools/analytics/engagement.ts b/packages/mcp/src/tools/analytics/engagement.ts index 080f118ea..435227c00 100644 --- a/packages/mcp/src/tools/analytics/engagement.ts +++ b/packages/mcp/src/tools/analytics/engagement.ts @@ -1,7 +1,9 @@ -import { resolveClientProjectId, getRetentionLastSeenSeries } from '@openpanel/db'; +import { getRetentionLastSeenSeries } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; -import { projectIdSchema, withErrorHandling } from '../shared'; +import { projectIdSchema, withErrorHandling, + resolveProjectId +} from '../shared'; export function registerEngagementTools( server: McpServer, @@ -15,7 +17,7 @@ export function registerEngagementTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const raw = await getRetentionLastSeenSeries({ projectId }); // Bucket into meaningful segments for easier reading diff --git a/packages/mcp/src/tools/analytics/event-names.ts b/packages/mcp/src/tools/analytics/event-names.ts index 43163760d..6b547d6d8 100644 --- a/packages/mcp/src/tools/analytics/event-names.ts +++ b/packages/mcp/src/tools/analytics/event-names.ts @@ -1,10 +1,11 @@ -import { resolveClientProjectId, getTopEventNames } from '@openpanel/db'; +import { getTopEventNames } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, withErrorHandling, + resolveProjectId } from '../shared'; export function registerEventNameTools( @@ -19,7 +20,7 @@ export function registerEventNameTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const names = await getTopEventNames(projectId); return { event_names: names }; }), diff --git a/packages/mcp/src/tools/analytics/events.ts b/packages/mcp/src/tools/analytics/events.ts index f0c3e0222..1df91a07b 100644 --- a/packages/mcp/src/tools/analytics/events.ts +++ b/packages/mcp/src/tools/analytics/events.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, queryEventsCore } from '@openpanel/db'; +import { queryEventsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -8,6 +8,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerEventTools(server: McpServer, context: McpAuthContext) { @@ -65,7 +66,7 @@ export function registerEventTools(server: McpServer, context: McpAuthContext) { }, async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); return queryEventsCore({ projectId, ...input }); }), ); diff --git a/packages/mcp/src/tools/analytics/funnel.ts b/packages/mcp/src/tools/analytics/funnel.ts index 3f7955c10..b8dedc2c1 100644 --- a/packages/mcp/src/tools/analytics/funnel.ts +++ b/packages/mcp/src/tools/analytics/funnel.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getFunnelCore } from '@openpanel/db'; +import { getFunnelCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -9,6 +9,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerFunnelTools( @@ -47,7 +48,7 @@ export function registerFunnelTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, steps, windowHours, groupBy }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getFunnelCore({ projectId, diff --git a/packages/mcp/src/tools/analytics/groups.ts b/packages/mcp/src/tools/analytics/groups.ts index 9d9291f76..91c8808a7 100644 --- a/packages/mcp/src/tools/analytics/groups.ts +++ b/packages/mcp/src/tools/analytics/groups.ts @@ -1,8 +1,10 @@ -import { resolveClientProjectId, getGroupById, getGroupList, getGroupMemberProfiles, getGroupTypes } from '@openpanel/db'; +import { getGroupById, getGroupList, getGroupMemberProfiles, getGroupTypes } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; -import { projectIdSchema, withErrorHandling } from '../shared'; +import { projectIdSchema, withErrorHandling, + resolveProjectId +} from '../shared'; export function registerGroupTools( server: McpServer, @@ -16,7 +18,7 @@ export function registerGroupTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const types = await getGroupTypes(projectId); return { types }; }), @@ -46,7 +48,7 @@ export function registerGroupTools( }, async ({ projectId: inputProjectId, type, search, limit }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); return getGroupList({ projectId, type, search, take: limit ?? 20 }); }), ); @@ -68,7 +70,7 @@ export function registerGroupTools( }, async ({ projectId: inputProjectId, groupId, memberLimit }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const [group, members] = await Promise.all([ getGroupById(groupId, projectId), getGroupMemberProfiles({ diff --git a/packages/mcp/src/tools/analytics/overview.ts b/packages/mcp/src/tools/analytics/overview.ts index e4a12a58a..227d9e20d 100644 --- a/packages/mcp/src/tools/analytics/overview.ts +++ b/packages/mcp/src/tools/analytics/overview.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getAnalyticsOverviewCore } from '@openpanel/db'; +import { getAnalyticsOverviewCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -8,6 +8,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerOverviewTools( @@ -28,7 +29,7 @@ export function registerOverviewTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, interval }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getAnalyticsOverviewCore({ projectId, diff --git a/packages/mcp/src/tools/analytics/page-conversions.test.ts b/packages/mcp/src/tools/analytics/page-conversions.test.ts new file mode 100644 index 000000000..ca623ffe8 --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-conversions.test.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetPageConversionsCore = vi.hoisted(() => vi.fn()); + +vi.mock('@openpanel/db', () => ({ + getPageConversionsCore: mockGetPageConversionsCore, + resolveClientProjectId: vi.fn(({ clientProjectId }: { clientProjectId: string }) => + Promise.resolve(clientProjectId), + ), +})); + +import { registerPageConversionTools } from './page-conversions'; + +function makeServer() { + let handler: ((input: unknown) => Promise) | null = null; + return { + tool: ( + _name: string, + _desc: string, + _schema: unknown, + fn: (input: unknown) => Promise, + ) => { + handler = fn; + }, + invoke: (input: unknown) => { + if (!handler) throw new Error('tool not registered'); + return handler(input); + }, + }; +} + +const READ_CTX = { projectId: 'proj-1', organizationId: 'org-1', clientType: 'read' as const }; + +function makePage(overrides: Record = {}) { + return { + path: '/pricing', + origin: 'https://example.com', + unique_converters: 10, + total_visitors: 200, + conversion_rate: 5.0, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('get_page_conversions — output structure', () => { + it('returns pages with all required fields', async () => { + mockGetPageConversionsCore.mockResolvedValue([makePage()]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages[0]).toMatchObject({ + path: '/pricing', + origin: 'https://example.com', + unique_converters: 10, + total_visitors: 200, + conversion_rate: 5.0, + }); + }); + + it('includes metadata fields in response', async () => { + mockGetPageConversionsCore.mockResolvedValue([makePage()]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'purchase', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.conversion_event).toBe('purchase'); + expect(content.window_hours).toBe(24); + expect(content.total_pages).toBe(1); + }); + + it('returns empty pages array when no conversions found', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.pages).toEqual([]); + expect(content.total_pages).toBe(0); + }); +}); + +describe('get_page_conversions — arguments forwarding', () => { + it('passes conversionEvent to core function', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'trial_started', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ conversionEvent: 'trial_started' }), + ); + }); + + it('defaults windowHours to 24 when not provided', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ windowHours: 24 }), + ); + }); + + it('passes custom windowHours through', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + windowHours: 168, + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ windowHours: 168 }), + ); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + windowHours: 168, + })) as any; + const content = JSON.parse(result.content[0].text); + expect(content.window_hours).toBe(168); + }); + + it('defaults limit to 50 when not provided', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50 }), + ); + }); + + it('passes projectId from context when not specified', async () => { + mockGetPageConversionsCore.mockResolvedValue([]); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + }); + + expect(mockGetPageConversionsCore).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'proj-1' }), + ); + }); +}); + +describe('get_page_conversions — total_pages count', () => { + it('reflects the number of pages returned by core', async () => { + const pages = Array.from({ length: 7 }, (_, i) => + makePage({ path: `/page-${i}`, unique_converters: 10 - i }), + ); + mockGetPageConversionsCore.mockResolvedValue(pages); + + const server = makeServer() as any; + registerPageConversionTools(server, READ_CTX); + const result = (await server.invoke({ + projectId: READ_CTX.projectId, + conversionEvent: 'sign_up', + })) as any; + const content = JSON.parse(result.content[0].text); + + expect(content.total_pages).toBe(7); + expect(content.pages).toHaveLength(7); + }); +}); diff --git a/packages/mcp/src/tools/analytics/page-conversions.ts b/packages/mcp/src/tools/analytics/page-conversions.ts new file mode 100644 index 000000000..a18d86f42 --- /dev/null +++ b/packages/mcp/src/tools/analytics/page-conversions.ts @@ -0,0 +1,71 @@ +import { getPageConversionsCore } from '@openpanel/db'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { McpAuthContext } from '../../auth'; +import { + projectIdSchema, + resolveDateRange, + withErrorHandling, + zDateRange, + resolveProjectId, +} from '../shared'; + +export function registerPageConversionTools(server: McpServer, context: McpAuthContext) { + server.tool( + 'get_page_conversions', + 'Find which pages drive the most conversions. Given a conversion event (e.g. "sign_up", "purchase"), returns pages ranked by how many unique visitors went on to convert within a configurable time window after the page view. Includes total_visitors and conversion_rate per page. Useful for identifying high-value content and optimizing landing pages.', + { + projectId: projectIdSchema(context), + ...zDateRange, + conversionEvent: z + .string() + .describe( + 'The event name that counts as a conversion (e.g. "sign_up", "purchase", "trial_started"). Use list_event_names to discover available events.', + ), + windowHours: z + .number() + .min(1) + .max(720) + .default(24) + .optional() + .describe( + 'How many hours after a page view a conversion still counts (default: 24). Use 1 for same-session, 168 for 7-day window.', + ), + limit: z + .number() + .min(1) + .max(500) + .default(50) + .optional() + .describe( + 'Maximum pages to return, sorted by unique_converters descending (default: 50)', + ), + }, + async ({ + projectId: inputProjectId, + startDate: sd, + endDate: ed, + conversionEvent, + windowHours, + limit, + }) => + withErrorHandling(async () => { + const projectId = await resolveProjectId(context, inputProjectId); + const { startDate, endDate } = resolveDateRange(sd, ed); + const pages = await getPageConversionsCore({ + projectId, + startDate, + endDate, + conversionEvent, + windowHours: windowHours ?? 24, + limit: limit ?? 50, + }); + return { + conversion_event: conversionEvent, + window_hours: windowHours ?? 24, + total_pages: pages.length, + pages, + }; + }), + ); +} diff --git a/packages/mcp/src/tools/analytics/page-performance.ts b/packages/mcp/src/tools/analytics/page-performance.ts index 3f97c7eb1..416a12bd1 100644 --- a/packages/mcp/src/tools/analytics/page-performance.ts +++ b/packages/mcp/src/tools/analytics/page-performance.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, PagesService, ch, getSettingsForProject } from '@openpanel/db'; +import { PagesService, ch, getSettingsForProject } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -8,6 +8,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; const pagesService = new PagesService(ch); @@ -47,7 +48,7 @@ export function registerPagePerformanceTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, search, sortBy, sortOrder, limit }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); const { timezone } = await getSettingsForProject(projectId); diff --git a/packages/mcp/src/tools/analytics/pages.ts b/packages/mcp/src/tools/analytics/pages.ts index 6b39064b6..24fae6556 100644 --- a/packages/mcp/src/tools/analytics/pages.ts +++ b/packages/mcp/src/tools/analytics/pages.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getEntryExitPagesCore, getTopPagesCore } from '@openpanel/db'; +import { getEntryExitPagesCore, getTopPagesCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -9,6 +9,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerPageTools(server: McpServer, context: McpAuthContext) { @@ -21,7 +22,7 @@ export function registerPageTools(server: McpServer, context: McpAuthContext) { }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getTopPagesCore({ projectId, startDate, endDate }); }), @@ -41,7 +42,7 @@ export function registerPageTools(server: McpServer, context: McpAuthContext) { }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, mode }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getEntryExitPagesCore({ projectId, startDate, endDate, mode }); }), diff --git a/packages/mcp/src/tools/analytics/profile-metrics.ts b/packages/mcp/src/tools/analytics/profile-metrics.ts index 0c0918ad9..b1a81f177 100644 --- a/packages/mcp/src/tools/analytics/profile-metrics.ts +++ b/packages/mcp/src/tools/analytics/profile-metrics.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getProfileMetrics } from '@openpanel/db'; +import { getProfileMetrics } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -6,6 +6,7 @@ import { projectIdSchema, withErrorHandling, + resolveProjectId } from '../shared'; export function registerProfileMetricTools( @@ -21,7 +22,7 @@ export function registerProfileMetricTools( }, async ({ projectId: inputProjectId, profileId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const raw = await getProfileMetrics(profileId, projectId); if (!raw) { return { error: 'Profile not found or has no events', profileId }; diff --git a/packages/mcp/src/tools/analytics/profiles.ts b/packages/mcp/src/tools/analytics/profiles.ts index 5d2afa7d8..9a24b436d 100644 --- a/packages/mcp/src/tools/analytics/profiles.ts +++ b/packages/mcp/src/tools/analytics/profiles.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, findProfilesCore, getProfileSessionsCore, getProfileWithEvents } from '@openpanel/db'; +import { findProfilesCore, getProfileSessionsCore, getProfileWithEvents } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -8,6 +8,7 @@ import { projectIdSchema, withErrorHandling, + resolveProjectId } from '../shared'; export function registerProfileTools( @@ -72,7 +73,7 @@ export function registerProfileTools( }, async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const profiles = await findProfilesCore({ projectId, ...input }); return profiles.map((p) => ({ ...p, @@ -97,7 +98,7 @@ export function registerProfileTools( }, async ({ projectId: inputProjectId, profileId, eventLimit }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const result = await getProfileWithEvents(projectId, profileId, eventLimit); if (!result.profile) { return { error: 'Profile not found', profileId }; @@ -125,7 +126,7 @@ export function registerProfileTools( }, async ({ projectId: inputProjectId, profileId, limit }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const sessions = await getProfileSessionsCore(projectId, profileId, limit); return { profileId, diff --git a/packages/mcp/src/tools/analytics/property-values.ts b/packages/mcp/src/tools/analytics/property-values.ts index ac770e6af..9b5dc4ae0 100644 --- a/packages/mcp/src/tools/analytics/property-values.ts +++ b/packages/mcp/src/tools/analytics/property-values.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, TABLE_NAMES, ch, clix } from '@openpanel/db'; +import { TABLE_NAMES, ch, clix } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -6,6 +6,7 @@ import { projectIdSchema, withErrorHandling, + resolveProjectId } from '../shared'; export function registerPropertyValueTools( @@ -24,7 +25,7 @@ export function registerPropertyValueTools( }, async ({ projectId: inputProjectId, eventName }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const builder = clix(ch) .select<{ property_key: string; event_name: string }>([ 'distinct property_key', @@ -58,7 +59,7 @@ export function registerPropertyValueTools( }, async ({ projectId: inputProjectId, eventName, propertyKey }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const rows = await clix(ch) .select<{ value: string }>(['property_value as value']) .from(TABLE_NAMES.event_property_values_mv) diff --git a/packages/mcp/src/tools/analytics/reports.ts b/packages/mcp/src/tools/analytics/reports.ts index cf952b49f..97192c9b4 100644 --- a/packages/mcp/src/tools/analytics/reports.ts +++ b/packages/mcp/src/tools/analytics/reports.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, +import { AggregateChartEngine, ChartEngine, db, @@ -6,31 +6,18 @@ import { resolveClientProjectId, getChartStartEndDate, getReportById, getReportsByDashboardId, - getSettingsForProject, -} from '@openpanel/db'; + getSettingsForProject} from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { dashboardBaseUrl } from '../dashboard-links'; -import { - projectIdSchema, - - withErrorHandling, -} from '../shared'; +import { projectIdSchema, resolveProjectId, withErrorHandling } from '../shared'; -function reportUrl( - organizationId: string, - projectId: string, - reportId: string, -) { +function reportUrl(organizationId: string, projectId: string, reportId: string) { return `${dashboardBaseUrl()}/${organizationId}/${projectId}/reports/${reportId}`; } -function dashboardUrl( - organizationId: string, - projectId: string, - dashboardId: string, -) { +function dashboardUrl(organizationId: string, projectId: string, dashboardId: string) { return `${dashboardBaseUrl()}/${organizationId}/${projectId}/dashboards/${dashboardId}`; } @@ -46,7 +33,7 @@ export function registerReportTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const dashboards = await db.dashboard.findMany({ where: { projectId }, orderBy: { createdAt: 'desc' }, @@ -68,8 +55,11 @@ export function registerReportTools( }, async ({ projectId: inputProjectId, dashboardId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const reports = await getReportsByDashboardId(dashboardId); + if (reports.some((r) => r.projectId !== projectId)) { + throw new Error('Dashboard does not belong to this project'); + } return reports.map((r) => ({ id: r.id, name: r.name, @@ -103,7 +93,7 @@ export function registerReportTools( }, async ({ projectId: inputProjectId, reportId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const report = await getReportById(reportId); if (!report) { diff --git a/packages/mcp/src/tools/analytics/retention.ts b/packages/mcp/src/tools/analytics/retention.ts index c40ac48bc..e831bab05 100644 --- a/packages/mcp/src/tools/analytics/retention.ts +++ b/packages/mcp/src/tools/analytics/retention.ts @@ -1,10 +1,11 @@ -import { resolveClientProjectId, getRetentionCohortTable } from '@openpanel/db'; +import { getRetentionCohortTable } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, withErrorHandling, + resolveProjectId } from '../shared'; export function registerRetentionTools( @@ -19,7 +20,7 @@ export function registerRetentionTools( }, async ({ projectId: inputProjectId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); return getRetentionCohortTable({ projectId }); }), ); diff --git a/packages/mcp/src/tools/analytics/sessions.ts b/packages/mcp/src/tools/analytics/sessions.ts index 40b93ad91..4b1b40e1c 100644 --- a/packages/mcp/src/tools/analytics/sessions.ts +++ b/packages/mcp/src/tools/analytics/sessions.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, querySessionsCore } from '@openpanel/db'; +import { querySessionsCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -8,6 +8,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerSessionTools( @@ -51,7 +52,7 @@ export function registerSessionTools( }, async ({ projectId: inputProjectId, ...input }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const sessions = await querySessionsCore({ projectId, ...input }); return sessions.map((s) => ({ ...s, diff --git a/packages/mcp/src/tools/analytics/traffic.ts b/packages/mcp/src/tools/analytics/traffic.ts index 05df19754..1c9fa2147 100644 --- a/packages/mcp/src/tools/analytics/traffic.ts +++ b/packages/mcp/src/tools/analytics/traffic.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; +import { getTrafficBreakdownCore, type TrafficColumn } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -8,6 +8,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerTrafficTools( @@ -30,7 +31,7 @@ export function registerTrafficTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getTrafficBreakdownCore({ projectId, @@ -55,7 +56,7 @@ export function registerTrafficTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getTrafficBreakdownCore({ projectId, @@ -82,7 +83,7 @@ export function registerTrafficTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, breakdown }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getTrafficBreakdownCore({ projectId, diff --git a/packages/mcp/src/tools/analytics/user-flow.ts b/packages/mcp/src/tools/analytics/user-flow.ts index 70ff2357a..48e9035ea 100644 --- a/packages/mcp/src/tools/analytics/user-flow.ts +++ b/packages/mcp/src/tools/analytics/user-flow.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getUserFlowCore } from '@openpanel/db'; +import { getUserFlowCore } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -9,6 +9,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerUserFlowTools( @@ -53,7 +54,7 @@ export function registerUserFlowTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, startEvent, endEvent, mode, steps, exclude, include }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getUserFlowCore({ projectId, startDate, endDate, startEvent, endEvent, mode, steps, exclude, include }); }), diff --git a/packages/mcp/src/tools/dashboard-links.ts b/packages/mcp/src/tools/dashboard-links.ts index 58272186d..c38294a80 100644 --- a/packages/mcp/src/tools/dashboard-links.ts +++ b/packages/mcp/src/tools/dashboard-links.ts @@ -1,8 +1,10 @@ -import { resolveClientProjectId } from '@openpanel/db'; +import { } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../auth'; -import { projectIdSchema, withErrorHandling } from './shared'; +import { projectIdSchema, withErrorHandling, + resolveProjectId +} from './shared'; export function dashboardBaseUrl() { return ( @@ -36,7 +38,7 @@ export function registerDashboardLinkTools( }, async ({ projectId: inputProjectId, profileId, sessionId, dashboardId, reportId }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const base = `${dashboardBaseUrl()}/${context.organizationId}/${projectId}`; const urls: Record = { diff --git a/packages/mcp/src/tools/gsc/cannibalization.ts b/packages/mcp/src/tools/gsc/cannibalization.ts index a6e15d5b0..cdd79e7ba 100644 --- a/packages/mcp/src/tools/gsc/cannibalization.ts +++ b/packages/mcp/src/tools/gsc/cannibalization.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { resolveClientProjectId, getGscCannibalization } from '@openpanel/db'; +import { getGscCannibalization } from '@openpanel/db'; import type { McpAuthContext } from '../../auth'; import { projectIdSchema, @@ -7,6 +7,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerGscCannibalizationTools( @@ -22,7 +23,7 @@ export function registerGscCannibalizationTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscCannibalization(projectId, startDate, endDate); }) diff --git a/packages/mcp/src/tools/gsc/overview.ts b/packages/mcp/src/tools/gsc/overview.ts index 7dfd40db0..9fd74238f 100644 --- a/packages/mcp/src/tools/gsc/overview.ts +++ b/packages/mcp/src/tools/gsc/overview.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { resolveClientProjectId, getGscOverview } from '@openpanel/db'; +import { getGscOverview } from '@openpanel/db'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; import { @@ -8,6 +8,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerGscOverviewTools( @@ -33,7 +34,7 @@ export function registerGscOverviewTools( interval, }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); const data = await getGscOverview( projectId, diff --git a/packages/mcp/src/tools/gsc/pages.ts b/packages/mcp/src/tools/gsc/pages.ts index b488f1af5..0602e0d5e 100644 --- a/packages/mcp/src/tools/gsc/pages.ts +++ b/packages/mcp/src/tools/gsc/pages.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getGscPageDetails, getGscPages } from '@openpanel/db'; +import { getGscPageDetails, getGscPages } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import type { McpAuthContext } from '../../auth'; @@ -8,6 +8,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; export function registerGscPageTools( @@ -30,7 +31,7 @@ export function registerGscPageTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscPages(projectId, startDate, endDate, limit ?? 100); }), @@ -49,7 +50,7 @@ export function registerGscPageTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, page }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscPageDetails(projectId, page, startDate, endDate); }), diff --git a/packages/mcp/src/tools/gsc/queries.ts b/packages/mcp/src/tools/gsc/queries.ts index b11d6af2b..64a4e1713 100644 --- a/packages/mcp/src/tools/gsc/queries.ts +++ b/packages/mcp/src/tools/gsc/queries.ts @@ -1,4 +1,4 @@ -import { resolveClientProjectId, getGscQueryDetails, getGscQueries } from '@openpanel/db'; +import { getGscQueryDetails, getGscQueries } from '@openpanel/db'; import type { GscQueryOpportunity } from '@openpanel/db'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; @@ -9,6 +9,7 @@ import { withErrorHandling, zDateRange, + resolveProjectId } from '../shared'; function computeOpportunities( @@ -90,7 +91,7 @@ export function registerGscQueryTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, limit }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscQueries(projectId, startDate, endDate, limit ?? 100); }), @@ -113,7 +114,7 @@ export function registerGscQueryTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, minImpressions }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); const queries = await getGscQueries(projectId, startDate, endDate, 5000); const filtered = queries.filter( @@ -140,7 +141,7 @@ export function registerGscQueryTools( }, async ({ projectId: inputProjectId, startDate: sd, endDate: ed, query }) => withErrorHandling(async () => { - const projectId = await resolveClientProjectId({ clientType: context.clientType, clientProjectId: context.projectId, organizationId: context.organizationId, inputProjectId }); + const projectId = await resolveProjectId(context, inputProjectId); const { startDate, endDate } = resolveDateRange(sd, ed); return getGscQueryDetails(projectId, query, startDate, endDate); }), diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index c57b8e877..40acdcbcf 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -8,6 +8,7 @@ import { registerEventTools } from './analytics/events'; import { registerFunnelTools } from './analytics/funnel'; import { registerGroupTools } from './analytics/groups'; import { registerOverviewTools } from './analytics/overview'; +import { registerPageConversionTools } from './analytics/page-conversions'; import { registerPagePerformanceTools } from './analytics/page-performance'; import { registerPageTools } from './analytics/pages'; import { registerProfileMetricTools } from './analytics/profile-metrics'; @@ -54,6 +55,7 @@ export function registerAllTools( registerActiveUserTools(server, context); registerPageTools(server, context); registerPagePerformanceTools(server, context); + registerPageConversionTools(server, context); registerTrafficTools(server, context); // Analytics — user behavior diff --git a/packages/mcp/src/tools/shared.ts b/packages/mcp/src/tools/shared.ts index 61e47c02a..3e26a2e15 100644 --- a/packages/mcp/src/tools/shared.ts +++ b/packages/mcp/src/tools/shared.ts @@ -1,9 +1,26 @@ +import { resolveClientProjectId } from '@openpanel/db'; import { createLogger } from '@openpanel/logger'; import { z } from 'zod'; import type { McpAuthContext } from '../auth'; const logger = createLogger({ name: 'mcp' }); +/** + * Resolve the effective projectId from context + optional tool input. + * Thin adapter so tool files don't repeat the full argument object every call. + */ +export function resolveProjectId( + context: McpAuthContext, + inputProjectId: string | undefined, +): Promise { + return resolveClientProjectId({ + clientType: context.clientType, + clientProjectId: context.projectId, + organizationId: context.organizationId, + inputProjectId, + }); +} + /** * Build the projectId portion of an input schema. * From be0d91339f24324a4c5bdaa3059b78fb1d64b787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 8 Apr 2026 10:46:59 +0200 Subject: [PATCH 13/18] fix escapings --- packages/db/src/services/event.service.ts | 2 +- packages/db/src/services/profile.service.ts | 23 +++++++++------------ 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index daa353941..e57ac9b59 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -1277,7 +1277,7 @@ export async function queryEventsCore( if (input.properties) { for (const [key, value] of Object.entries(input.properties)) { - builder.where(`properties['${key}']`, '=', value); + builder.rawWhere(`properties[${sqlstring.escape(key)}] = ${sqlstring.escape(value)}`); } } diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index e3b0d57f9..761738aad 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -326,15 +326,12 @@ export function upsertProfile( return profileBuffer.add(profile, isFromEvent); } +import sqlstring from 'sqlstring'; import { ch } from '../clickhouse/client'; import { clix } from '../clickhouse/query-builder'; import type { IClickhouseEvent } from './event.service'; import type { IClickhouseSession } from './session.service'; -function esc(value: string): string { - return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; -} - const PROFILE_COLUMNS = 'id, first_name, last_name, email, avatar, properties, project_id, is_external, created_at, groups'; @@ -357,27 +354,27 @@ export interface FindProfilesInput { export async function findProfilesCore( input: FindProfilesInput, ): Promise { - const pid = esc(input.projectId); + const pid = sqlstring.escape(input.projectId); const conditions: string[] = [`project_id = ${pid}`]; if (input.email) { - conditions.push(`email LIKE ${esc('%' + input.email + '%')}`); + conditions.push(`email LIKE ${sqlstring.escape('%' + input.email + '%')}`); } if (input.name) { - const escaped = esc('%' + input.name + '%'); + const escaped = sqlstring.escape('%' + input.name + '%'); conditions.push(`(first_name LIKE ${escaped} OR last_name LIKE ${escaped})`); } if (input.country) { - conditions.push(`properties['country'] = ${esc(input.country)}`); + conditions.push(`properties['country'] = ${sqlstring.escape(input.country)}`); } if (input.city) { - conditions.push(`properties['city'] = ${esc(input.city)}`); + conditions.push(`properties['city'] = ${sqlstring.escape(input.city)}`); } if (input.device) { - conditions.push(`properties['device'] = ${esc(input.device)}`); + conditions.push(`properties['device'] = ${sqlstring.escape(input.device)}`); } if (input.browser) { - conditions.push(`properties['browser'] = ${esc(input.browser)}`); + conditions.push(`properties['browser'] = ${sqlstring.escape(input.browser)}`); } if (input.inactiveDays !== undefined) { @@ -406,7 +403,7 @@ export async function findProfilesCore( conditions.push(`id IN ( SELECT DISTINCT profile_id FROM ${TABLE_NAMES.events} WHERE project_id = ${pid} - AND name = ${esc(input.performedEvent)} + AND name = ${sqlstring.escape(input.performedEvent)} )`); } @@ -436,7 +433,7 @@ export async function getProfileWithEvents( chQuery(` SELECT ${PROFILE_COLUMNS} FROM ${TABLE_NAMES.profiles} - WHERE project_id = ${esc(projectId)} AND id = ${esc(profileId)} + WHERE project_id = ${sqlstring.escape(projectId)} AND id = ${sqlstring.escape(profileId)} LIMIT 1 `), clix(ch) From b2e2dbadb685cb767404c321c3eb7f2109a2a8c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 8 Apr 2026 12:33:28 +0200 Subject: [PATCH 14/18] docs --- apps/api/src/app.ts | 23 +- apps/api/src/controllers/track.controller.ts | 18 +- apps/api/src/routes/event.router.ts | 2 +- apps/api/src/routes/export.router.ts | 2 +- apps/api/src/routes/import.router.ts | 4 + apps/api/src/routes/insights.router.ts | 2 +- apps/api/src/routes/manage.router.ts | 34 +- apps/api/src/routes/profile.router.ts | 6 +- apps/api/src/routes/track.router.ts | 20 +- .../docs/api-reference/authentication.mdx | 29 + .../docs/api-reference/rate-limits.mdx | 30 + apps/public/content/docs/api/export.mdx | 438 +---- apps/public/content/docs/api/insights.mdx | 405 +---- .../content/docs/api/manage/clients.mdx | 328 +--- apps/public/content/docs/api/manage/index.mdx | 134 +- .../content/docs/api/manage/projects.mdx | 319 +--- .../content/docs/api/manage/references.mdx | 338 +--- apps/public/content/docs/api/meta.json | 10 +- apps/public/content/docs/api/track.mdx | 196 +- apps/public/content/docs/mcp/index.mdx | 179 ++ apps/public/content/docs/meta.json | 2 + apps/public/next.config.mjs | 2 +- apps/public/package.json | 8 +- apps/public/source.config.ts | 6 + .../docs/{ => (docs)}/[[...slug]]/page.tsx | 0 apps/public/src/app/docs/(docs)/layout.tsx | 29 + .../docs/api-reference/[[...slug]]/page.tsx | 71 + .../src/app/docs/api-reference/layout.tsx | 34 + apps/public/src/app/docs/layout.tsx | 11 - apps/public/src/app/global.css | 1 + apps/public/src/components/navbar.tsx | 4 +- apps/public/src/lib/openapi.ts | 60 + apps/public/src/mdx-components.tsx | 10 - packages/db/src/services/profile.service.ts | 36 +- packages/validation/src/track.validation.ts | 80 +- pnpm-lock.yaml | 1620 +++++++++++------ 36 files changed, 1717 insertions(+), 2774 deletions(-) create mode 100644 apps/public/content/docs/api-reference/authentication.mdx create mode 100644 apps/public/content/docs/api-reference/rate-limits.mdx create mode 100644 apps/public/content/docs/mcp/index.mdx rename apps/public/src/app/docs/{ => (docs)}/[[...slug]]/page.tsx (100%) create mode 100644 apps/public/src/app/docs/(docs)/layout.tsx create mode 100644 apps/public/src/app/docs/api-reference/[[...slug]]/page.tsx create mode 100644 apps/public/src/app/docs/api-reference/layout.tsx delete mode 100644 apps/public/src/app/docs/layout.tsx create mode 100644 apps/public/src/lib/openapi.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a61f16691..442b0a6c3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -187,8 +187,23 @@ export async function buildApp( openapi: { info: { title: 'OpenPanel API', version: '1.0.0' }, openapi: '3.1.0', + tags: [ + { name: 'Track', description: 'Track events and sessions' }, + { name: 'Profile', description: 'Identify and update user profiles' }, + { name: 'Export', description: 'Export data' }, + { name: 'Import', description: 'Import historical data' }, + { name: 'Insights', description: 'Query analytics data' }, + { name: 'Manage', description: 'Manage projects and clients' }, + { name: 'Event', description: 'Legacy event ingestion (deprecated, use /track)' }, + ], }, ...fastifyZodOpenApiTransformers, + transform(args) { + if (args.url === '/metrics') { + return { schema: { ...args.schema, hide: true }, url: args.url }; + } + return fastifyZodOpenApiTransformers.transform(args); + }, }); await instance.register(fastifySwaggerUI, { routePrefix: '/documentation' }); @@ -205,10 +220,10 @@ export async function buildApp( instance.register(trackRouter, { prefix: '/track' }); instance.register(manageRouter, { prefix: '/manage' }); - instance.get('/healthcheck', healthcheck); - instance.get('/healthz/live', liveness); - instance.get('/healthz/ready', readiness); - instance.get('/', (_request, reply) => + instance.get('/healthcheck', { schema: { hide: true } }, healthcheck); + instance.get('/healthz/live', { schema: { hide: true } }, liveness); + instance.get('/healthz/ready', { schema: { hide: true } }, readiness); + instance.get('/', { schema: { hide: true } }, (_request, reply) => reply.send({ status: 'ok', message: 'Successfully running OpenPanel.dev API' }), ); }); diff --git a/apps/api/src/controllers/track.controller.ts b/apps/api/src/controllers/track.controller.ts index 2639d4c71..e0d53dd33 100644 --- a/apps/api/src/controllers/track.controller.ts +++ b/apps/api/src/controllers/track.controller.ts @@ -13,15 +13,15 @@ import { getEventsGroupQueueShard, } from '@openpanel/queue'; import { getRedisCache } from '@openpanel/redis'; -import { - type IAssignGroupPayload, - type IDecrementPayload, - type IGroupPayload, - type IIdentifyPayload, - type IIncrementPayload, - type IReplayPayload, - type ITrackHandlerPayload, - type ITrackPayload, +import type { + IAssignGroupPayload, + IDecrementPayload, + IGroupPayload, + IIdentifyPayload, + IIncrementPayload, + IReplayPayload, + ITrackHandlerPayload, + ITrackPayload, } from '@openpanel/validation'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { assocPath, pathOr, pick } from 'ramda'; diff --git a/apps/api/src/routes/event.router.ts b/apps/api/src/routes/event.router.ts index ef8fc341f..f1b791393 100644 --- a/apps/api/src/routes/event.router.ts +++ b/apps/api/src/routes/event.router.ts @@ -13,7 +13,7 @@ const eventRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { method: 'POST', url: '/', schema: { - tags: ['ingestion'], + tags: ['Event'], description: 'Deprecated direct event ingestion endpoint. Use /track instead.', }, handler: controller.postEvent, diff --git a/apps/api/src/routes/export.router.ts b/apps/api/src/routes/export.router.ts index ff3d0aed7..4440e7428 100644 --- a/apps/api/src/routes/export.router.ts +++ b/apps/api/src/routes/export.router.ts @@ -10,7 +10,7 @@ import { validateExportRequest } from '@/utils/auth'; import { parseQueryString } from '@/utils/parse-zod-query-string'; import { activateRateLimiter } from '@/utils/rate-limiter'; -const TAGS = ['export'] as const; +const TAGS = ['Export'] as const; const exportRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { await activateRateLimiter({ fastify, max: 100, timeWindow: '10 seconds' }); diff --git a/apps/api/src/routes/import.router.ts b/apps/api/src/routes/import.router.ts index fbad804dc..5c55765a9 100644 --- a/apps/api/src/routes/import.router.ts +++ b/apps/api/src/routes/import.router.ts @@ -32,6 +32,10 @@ const importRouter: FastifyPluginCallback = async (fastify) => { fastify.route({ method: 'POST', url: '/events', + schema: { + tags: ['Import'], + description: 'Bulk import historical events.', + }, handler: controller.importEvents, }); }; diff --git a/apps/api/src/routes/insights.router.ts b/apps/api/src/routes/insights.router.ts index 4c506aa90..79bbb7a1e 100644 --- a/apps/api/src/routes/insights.router.ts +++ b/apps/api/src/routes/insights.router.ts @@ -42,7 +42,7 @@ const profileParam = z.object({ projectId: z.string(), profileId: z.string() }); const groupParam = z.object({ projectId: z.string(), groupId: z.string() }); const reportParam = z.object({ projectId: z.string(), reportId: z.string() }); -const TAGS = ['insights'] as const; +const TAGS = ['Insights'] as const; const insightsRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { await activateRateLimiter({ fastify, max: 100, timeWindow: '10 seconds' }); diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts index 43e72167c..815028509 100644 --- a/apps/api/src/routes/manage.router.ts +++ b/apps/api/src/routes/manage.router.ts @@ -68,35 +68,35 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.route({ method: 'GET', url: '/projects', - schema: { tags: ['manage'], description: 'List all projects for the organization.' }, + schema: { tags: ['Manage'], description: 'List all projects for the organization.' }, handler: controller.listProjects, }); fastify.route({ method: 'GET', url: '/projects/:id', - schema: { params: idParam, tags: ['manage'], description: 'Get a single project by ID.' }, + schema: { params: idParam, tags: ['Manage'], description: 'Get a single project by ID.' }, handler: controller.getProject, }); fastify.route({ method: 'POST', url: '/projects', - schema: { body: zCreateProject, tags: ['manage'], description: 'Create a new project and its first write client.' }, + schema: { body: zCreateProject, tags: ['Manage'], description: 'Create a new project and its first write client.' }, handler: controller.createProject, }); fastify.route({ method: 'PATCH', url: '/projects/:id', - schema: { params: idParam, body: zUpdateProject, tags: ['manage'], description: 'Update project settings (name, domain, CORS, tracking options).' }, + schema: { params: idParam, body: zUpdateProject, tags: ['Manage'], description: 'Update project settings (name, domain, CORS, tracking options).' }, handler: controller.updateProject, }); fastify.route({ method: 'DELETE', url: '/projects/:id', - schema: { params: idParam, tags: ['manage'], description: 'Soft-delete a project (scheduled for removal in 24 hours).' }, + schema: { params: idParam, tags: ['Manage'], description: 'Soft-delete a project (scheduled for removal in 24 hours).' }, handler: controller.deleteProject, }); @@ -104,35 +104,35 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.route({ method: 'GET', url: '/clients', - schema: { tags: ['manage'], description: 'List all API clients for the organization, optionally filtered by project.' }, + schema: { tags: ['Manage'], description: 'List all API clients for the organization, optionally filtered by project.' }, handler: controller.listClients, }); fastify.route({ method: 'GET', url: '/clients/:id', - schema: { params: idParam, tags: ['manage'], description: 'Get a single API client by ID.' }, + schema: { params: idParam, tags: ['Manage'], description: 'Get a single API client by ID.' }, handler: controller.getClient, }); fastify.route({ method: 'POST', url: '/clients', - schema: { body: zCreateClient, tags: ['manage'], description: 'Create a new API client (read, write, or root type) and return its generated secret.' }, + schema: { body: zCreateClient, tags: ['Manage'], description: 'Create a new API client (read, write, or root type) and return its generated secret.' }, handler: controller.createClient, }); fastify.route({ method: 'PATCH', url: '/clients/:id', - schema: { params: idParam, body: zUpdateClient, tags: ['manage'], description: 'Update an API client name.' }, + schema: { params: idParam, body: zUpdateClient, tags: ['Manage'], description: 'Update an API client name.' }, handler: controller.updateClient, }); fastify.route({ method: 'DELETE', url: '/clients/:id', - schema: { params: idParam, tags: ['manage'], description: 'Delete an API client.' }, + schema: { params: idParam, tags: ['Manage'], description: 'Delete an API client.' }, handler: controller.deleteClient, }); @@ -140,35 +140,35 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.route({ method: 'GET', url: '/references', - schema: { tags: ['manage'], description: 'List annotation references for a project.' }, + schema: { tags: ['Manage'], description: 'List annotation references for a project.' }, handler: controller.listReferences, }); fastify.route({ method: 'GET', url: '/references/:id', - schema: { params: idParam, tags: ['manage'], description: 'Get a single annotation reference by ID.' }, + schema: { params: idParam, tags: ['Manage'], description: 'Get a single annotation reference by ID.' }, handler: controller.getReference, }); fastify.route({ method: 'POST', url: '/references', - schema: { body: zCreateReference, tags: ['manage'] }, + schema: { body: zCreateReference, tags: ['Manage'] }, handler: controller.createReference, }); fastify.route({ method: 'PATCH', url: '/references/:id', - schema: { params: idParam, body: zUpdateReference, tags: ['manage'] }, + schema: { params: idParam, body: zUpdateReference, tags: ['Manage'] }, handler: controller.updateReference, }); fastify.route({ method: 'DELETE', url: '/references/:id', - schema: { params: idParam, tags: ['manage'] }, + schema: { params: idParam, tags: ['Manage'] }, handler: controller.deleteReference, }); @@ -178,7 +178,7 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { url: '/projects/:projectId/dashboards', schema: { params: z.object({ projectId: z.string() }), - tags: ['manage'], + tags: ['Manage'], description: 'List all dashboards for a project.', }, handler: listDashboards, @@ -189,7 +189,7 @@ const manageRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { url: '/projects/:projectId/dashboards/:dashboardId/reports', schema: { params: z.object({ projectId: z.string(), dashboardId: z.string() }), - tags: ['manage'], + tags: ['Manage'], description: 'List all reports in a dashboard.', }, handler: listReports, diff --git a/apps/api/src/routes/profile.router.ts b/apps/api/src/routes/profile.router.ts index 8060f6975..dcdba4e32 100644 --- a/apps/api/src/routes/profile.router.ts +++ b/apps/api/src/routes/profile.router.ts @@ -11,7 +11,7 @@ const profileRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { method: 'POST', url: '/', schema: { - tags: ['ingestion'], + tags: ['Profile'], description: 'Identify or update a user profile.', }, handler: controller.updateProfile, @@ -21,7 +21,7 @@ const profileRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { method: 'POST', url: '/increment', schema: { - tags: ['ingestion'], + tags: ['Profile'], description: 'Increment a numeric property on a user profile.', }, handler: controller.incrementProfileProperty, @@ -31,7 +31,7 @@ const profileRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { method: 'POST', url: '/decrement', schema: { - tags: ['ingestion'], + tags: ['Profile'], description: 'Decrement a numeric property on a user profile.', }, handler: controller.decrementProfileProperty, diff --git a/apps/api/src/routes/track.router.ts b/apps/api/src/routes/track.router.ts index 19a788805..77d0bed38 100644 --- a/apps/api/src/routes/track.router.ts +++ b/apps/api/src/routes/track.router.ts @@ -11,23 +11,31 @@ const trackRouter: FastifyPluginAsyncZodOpenApi = async (fastify) => { fastify.addHook('preHandler', clientHook); fastify.addHook('preHandler', isBotHook); - fastify.route({ + await fastify.route({ method: 'POST', url: '/', schema: { body: zTrackHandlerPayload, - tags: ['ingestion'], - description: 'Ingest a tracking event (track, identify, group, increment, decrement, replay).', + tags: ['Track'], + description: + 'Ingest a tracking event (track, identify, group, increment, decrement, replay).', + response: { + 200: z.object({ + deviceId: z.string(), + sessionId: z.string(), + }), + }, }, handler, }); - fastify.route({ + await fastify.route({ method: 'GET', url: '/device-id', schema: { - tags: ['ingestion'], - description: 'Get or generate a stable device ID and session ID for the current visitor.', + tags: ['Track'], + description: + 'Get or generate a stable device ID and session ID for the current visitor.', response: { 200: z.object({ deviceId: z.string(), diff --git a/apps/public/content/docs/api-reference/authentication.mdx b/apps/public/content/docs/api-reference/authentication.mdx new file mode 100644 index 000000000..87b1cfcfb --- /dev/null +++ b/apps/public/content/docs/api-reference/authentication.mdx @@ -0,0 +1,29 @@ +--- +title: Authentication +description: How to authenticate with the OpenPanel API +--- + +## Client ID & Secret + +OpenPanel uses client credentials for authentication. Every API client has a **Client ID** and a **Client Secret** that you generate from the dashboard. + +Pass them as request headers: + +```http +openpanel-client-id: +openpanel-client-secret: +``` + +## API Client Types + +| Type | Description | +| ------- | ------------------------------------------------ | +| `write` | Can ingest events and profile updates | +| `read` | Can query analytics data (insights, export, etc) | +| `root` | Full access — use only for server-side admin | + +## Creating a Client + +Go to **Settings → API Clients** in your dashboard and create a client with the appropriate type. Copy the secret immediately — it is only shown once. + +For more details on authentication, permissions, and security best practices, see the [Authentication guide](/docs/api/authentication). diff --git a/apps/public/content/docs/api-reference/rate-limits.mdx b/apps/public/content/docs/api-reference/rate-limits.mdx new file mode 100644 index 000000000..2e86324aa --- /dev/null +++ b/apps/public/content/docs/api-reference/rate-limits.mdx @@ -0,0 +1,30 @@ +--- +title: Rate Limits +description: Request limits and how to handle them +--- + +## Limits + +| Endpoint group | Limit | +| -------------- | ------------------- | +| Insights | 100 req / 10 seconds | +| Export | 100 req / 10 seconds | +| Manage | 20 req / 10 seconds | + +Limits are applied per **Client ID**. + +Track, Profile, and Import endpoints do not have a rate limit applied. + +## Handling 429s + +When you exceed the limit the API returns `429 Too Many Requests`: + +```json +{ + "status": 429, + "error": "Too Many Requests", + "message": "You have exceeded the rate limit for this endpoint." +} +``` + +Wait for the rate limit window to reset before retrying. diff --git a/apps/public/content/docs/api/export.mdx b/apps/public/content/docs/api/export.mdx index bb7250463..2ccb9fa6a 100644 --- a/apps/public/content/docs/api/export.mdx +++ b/apps/public/content/docs/api/export.mdx @@ -1,441 +1,25 @@ --- title: Export -description: The Export API allows you to retrieve event data and chart data from your OpenPanel projects for analysis, reporting, and data integration. +description: Retrieve raw events and aggregated chart data from your projects. --- ## Authentication -To authenticate with the Export API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Export API. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel client ID -- `openpanel-client-secret`: Your OpenPanel client secret - -## Base URL - -All Export API requests should be made to: - -``` -https://api.openpanel.dev/export -``` - -## Common Query Parameters - -Most endpoints support the following query parameters: - -| Parameter | Type | Description | Default | -|-----------|------|-------------|---------| -| `projectId` | string | The ID of the project (alternative: `project_id`) | Required | -| `startDate` | string | Start date (ISO format: YYYY-MM-DD) | Based on range | -| `endDate` | string | End date (ISO format: YYYY-MM-DD) | Based on range | -| `range` | string | Predefined date range (`7d`, `30d`, `today`, etc.) | None | +Requires a `read` or `root` client — the default `write` client does not have access. See the [Authentication](/docs/api/authentication) guide. ## Endpoints -### Get Events - -Retrieve individual events from a specific project within a date range. This endpoint provides raw event data with optional filtering and pagination. - -``` -GET /export/events -``` - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `projectId` | string | The ID of the project to fetch events from | `abc123` | -| `profileId` | string | Filter events by specific profile/user ID | `user_123` | -| `event` | string or string[] | Event name(s) to filter | `screen_view` or `["screen_view","button_click"]` | -| `start` | string | Start date for the event range (ISO format) | `2024-04-15` | -| `end` | string | End date for the event range (ISO format) | `2024-04-18` | -| `page` | number | Page number for pagination (default: 1) | `2` | -| `limit` | number | Number of events per page (default: 50, max: 1000) | `100` | -| `includes` | string or string[] | Additional fields to include in the response. Pass multiple as comma-separated (`profile,meta`) or repeated params (`includes=profile&includes=meta`). | `profile` or `profile,meta` | - -#### Include Options - -The `includes` parameter allows you to fetch additional related data. When using query parameters, you can pass multiple values in either of these ways: - -- **Comma-separated**: `?includes=profile,meta` (include both profile and meta in the response) -- **Repeated parameter**: `?includes=profile&includes=meta` (same result; useful when building URLs programmatically) - -Supported values (any of these can be combined; names match the response keys): - -**Related data** (adds nested objects or extra lookups): - -- `profile` — User profile for the event (id, email, firstName, lastName, etc.) -- `meta` — Event metadata from project config (name, description, conversion flag) - -**Event fields** (optional columns; these are in addition to the default fields): - -- `properties` — Custom event properties -- `region`, `longitude`, `latitude` — Extra geo (default already has `city`, `country`) -- `osVersion`, `browserVersion`, `device`, `brand`, `model` — Extra device (default already has `os`, `browser`) -- `origin`, `referrer`, `referrerName`, `referrerType` — Referrer/navigation -- `revenue` — Revenue amount -- `importedAt`, `sdkName`, `sdkVersion` — Import/SDK info - -The response always includes: `id`, `name`, `deviceId`, `profileId`, `sessionId`, `projectId`, `createdAt`, `path`, `duration`, `city`, `country`, `os`, `browser`. Use `includes` to add any of the values above. - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/export/events?projectId=abc123&event=screen_view&start=2024-04-15&end=2024-04-18&page=1&limit=100&includes=profile,meta' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "meta": { - "count": 50, - "totalCount": 1250, - "pages": 25, - "current": 1 - }, - "data": [ - { - "id": "evt_123456789", - "name": "screen_view", - "deviceId": "device_abc123", - "profileId": "user_789", - "projectId": "abc123", - "sessionId": "session_xyz", - "properties": { - "path": "/dashboard", - "title": "Dashboard", - "url": "https://example.com/dashboard" - }, - "createdAt": "2024-04-15T10:30:00.000Z", - "country": "United States", - "city": "New York", - "region": "New York", - "os": "macOS", - "browser": "Chrome", - "device": "Desktop", - "duration": 0, - "path": "/dashboard", - "origin": "https://example.com", - "profile": { - "id": "user_789", - "email": "user@example.com", - "firstName": "John", - "lastName": "Doe", - "isExternal": true, - "createdAt": "2024-04-01T08:00:00.000Z" - }, - "meta": { - "name": "screen_view", - "description": "Page view tracking", - "conversion": false - } - } - ] -} -``` - -### Get Charts - -Retrieve aggregated chart data for analytics and visualization. This endpoint provides time-series data with advanced filtering, breakdowns, and comparison capabilities. - -``` -GET /export/charts -``` - -**Note:** The endpoint accepts either `series` or `events` for the event configuration; `series` is the preferred parameter name. Both use the same structure. - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `projectId` | string | The ID of the project to fetch chart data from | `abc123` | -| `series` | object[] | Array of event/series configurations to analyze (preferred over `events`) | `[{"name":"screen_view","filters":[]}]` | -| `events` | object[] | Array of event configurations (deprecated in favor of `series`) | `[{"name":"screen_view","filters":[]}]` | -| `breakdowns` | object[] | Array of breakdown dimensions | `[{"name":"country"}]` | -| `interval` | string | Time interval for data points | `day` | -| `range` | string | Predefined date range | `7d` | -| `previous` | boolean | Include data from the previous period for comparison | `true` | -| `startDate` | string | Custom start date (ISO format) | `2024-04-01` | -| `endDate` | string | Custom end date (ISO format) | `2024-04-30` | - -#### Event Configuration - -Each item in the `series` or `events` array supports the following properties: - -| Property | Type | Description | Required | Default | -|----------|------|-------------|----------|---------| -| `name` | string | Name of the event to track | Yes | - | -| `filters` | Filter[] | Array of filters to apply to the event | No | `[]` | -| `segment` | string | Type of segmentation | No | `event` | -| `property` | string | Property name for property-based segments | No | - | - -#### Segmentation Options - -- `event`: Count individual events (default) -- `user`: Count unique users/profiles -- `session`: Count unique sessions -- `user_average`: Average events per user -- `one_event_per_user`: One event per user (deduplicated) -- `property_sum`: Sum of a numeric property -- `property_average`: Average of a numeric property -- `property_min`: Minimum value of a numeric property -- `property_max`: Maximum value of a numeric property - -#### Filter Configuration - -Each filter in the `filters` array supports: - -| Property | Type | Description | Required | -|----------|------|-------------|----------| -| `name` | string | Property name to filter on | Yes | -| `operator` | string | Comparison operator | Yes | -| `value` | array | Array of values to compare against | Yes | - -#### Filter Operators - -- `is`: Exact match -- `isNot`: Not equal to -- `contains`: Contains substring -- `doesNotContain`: Does not contain substring -- `startsWith`: Starts with -- `endsWith`: Ends with -- `regex`: Regular expression match -- `isNull`: Property is null or empty -- `isNotNull`: Property has a value - -#### Breakdown Dimensions - -Common breakdown dimensions include: - -| Dimension | Description | Example Values | -|-----------|-------------|----------------| -| `country` | User's country | `United States`, `Canada` | -| `region` | User's region/state | `California`, `New York` | -| `city` | User's city | `San Francisco`, `New York` | -| `device` | Device type | `Desktop`, `Mobile`, `Tablet` | -| `browser` | Browser name | `Chrome`, `Firefox`, `Safari` | -| `os` | Operating system | `macOS`, `Windows`, `iOS` | -| `referrer` | Referrer URL | `google.com`, `facebook.com` | -| `path` | Page path | `/`, `/dashboard`, `/pricing` | - -#### Time Intervals - -- `minute`: Minute-by-minute data -- `hour`: Hourly aggregation -- `day`: Daily aggregation (default) -- `week`: Weekly aggregation -- `month`: Monthly aggregation - -#### Date Ranges - -- `30min`: Last 30 minutes -- `lastHour`: Last hour -- `today`: Current day -- `yesterday`: Previous day -- `7d`: Last 7 days -- `30d`: Last 30 days -- `6m`: Last 6 months -- `12m`: Last 12 months -- `monthToDate`: Current month to date -- `lastMonth`: Previous month -- `yearToDate`: Current year to date -- `lastYear`: Previous year - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/export/charts?projectId=abc123&series=[{"name":"screen_view","segment":"user"}]&breakdowns=[{"name":"country"}]&interval=day&range=30d&previous=true' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -You can use `events` instead of `series` in the query for backward compatibility; both accept the same structure. - -#### Example Advanced Request - -```bash -curl 'https://api.openpanel.dev/export/charts' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' \ - -G \ - --data-urlencode 'projectId=abc123' \ - --data-urlencode 'series=[{"name":"purchase","segment":"property_sum","property":"properties.total","filters":[{"name":"properties.total","operator":"isNotNull","value":[]}]}]' \ - --data-urlencode 'breakdowns=[{"name":"country"}]' \ - --data-urlencode 'interval=day' \ - --data-urlencode 'range=30d' -``` - -#### Response - -```json -{ - "series": [ - { - "id": "screen_view-united-states", - "names": ["screen_view", "United States"], - "event": { - "id": "evt1", - "name": "screen_view" - }, - "metrics": { - "sum": 1250, - "average": 41.67, - "min": 12, - "max": 89, - "previous": { - "sum": { - "value": 1100, - "change": 13.64 - }, - "average": { - "value": 36.67, - "change": 13.64 - } - } - }, - "data": [ - { - "date": "2024-04-01T00:00:00.000Z", - "count": 45, - "previous": { - "value": 38, - "change": 18.42 - } - }, - { - "date": "2024-04-02T00:00:00.000Z", - "count": 52, - "previous": { - "value": 41, - "change": 26.83 - } - } - ] - } - ], - "metrics": { - "sum": 1250, - "average": 41.67, - "min": 12, - "max": 89, - "previous": { - "sum": { - "value": 1100, - "change": 13.64 - } - } - } -} -``` - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid query parameters", - "details": [ - { - "path": ["events", 0, "name"], - "message": "Required" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Invalid client credentials" -} -``` - -### 403 Forbidden - -```json -{ - "error": "Forbidden", - "message": "You do not have access to this project" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Project not found" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The Export API implements rate limiting: -- **100 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Data Types and Formats - -### Event Properties - -Event properties are stored as key-value pairs and can include: - -- **Built-in properties**: `path`, `origin`, `title`, `url`, `hash` -- **UTM parameters**: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` -- **Custom properties**: Any custom data you track with your events - -### Property Access - -Properties can be accessed in filters and breakdowns using dot notation: - -- `properties.custom_field`: Access custom properties -- `profile.properties.user_type`: Access profile properties -- `properties.__query.utm_source`: Access query parameters - -### Date Handling - -- All dates are in ISO 8601 format -- Timezone handling is done server-side based on project settings -- Date ranges are inclusive of start and end dates - -### Geographic Data - -Geographic information is automatically collected when available: - -- `country`: Full country name -- `region`: State/province/region -- `city`: City name -- `longitude`/`latitude`: Coordinates (when available) - -### Device Information +| Endpoint | Description | +|----------|-------------| +| `GET /export/events` | Paginated list of raw events with optional filtering | +| `GET /export/charts` | Aggregated time-series data with breakdowns | -Device data is collected from user agents: +### Filtering events -- `device`: Device type (Desktop, Mobile, Tablet) -- `browser`: Browser name and version -- `os`: Operating system and version -- `brand`/`model`: Device brand and model (mobile devices) +The `/export/events` endpoint accepts filters, pagination, and an `includes` parameter to attach related data (profile, meta, properties, geo, device, referrer). -## Notes +### Chart series -- Event data is typically available within seconds of tracking -- All timezone handling is done server-side based on project settings -- Property names are case-sensitive in filters and breakdowns +The `/export/charts` endpoint accepts a `series` array where each item can specify an event name, filters, and a segment type (`event`, `user`, `session`, `property_sum`, etc.). -Remember to replace `YOUR_CLIENT_ID` and `YOUR_CLIENT_SECRET` with your actual OpenPanel API credentials. \ No newline at end of file +For full query parameter and response schemas, see the [API Reference](/docs/api-reference/export). diff --git a/apps/public/content/docs/api/insights.mdx b/apps/public/content/docs/api/insights.mdx index 9d800ac1e..1a744e8be 100644 --- a/apps/public/content/docs/api/insights.mdx +++ b/apps/public/content/docs/api/insights.mdx @@ -1,405 +1,30 @@ --- title: Insights -description: The Insights API provides access to website analytics data including metrics, page views, visitor statistics, and detailed breakdowns by various dimensions. +description: Query analytics data including metrics, pages, referrers, devices, and geo breakdowns. --- ## Authentication -To authenticate with the Insights API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Insights API. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel client ID -- `openpanel-client-secret`: Your OpenPanel client secret +Requires a `read` or `root` client — the default `write` client does not have access. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Insights API requests should be made to: - -``` -https://api.openpanel.dev/insights -``` - -## Common Query Parameters - -Most endpoints support the following query parameters: - -| Parameter | Type | Description | Default | -|-----------|------|-------------|---------| -| `startDate` | string | Start date (ISO format: YYYY-MM-DD) | Based on range | -| `endDate` | string | End date (ISO format: YYYY-MM-DD) | Based on range | -| `range` | string | Predefined date range (`7d`, `30d`, `90d`, etc.) | `7d` | -| `filters` | array | Event filters to apply | `[]` | -| `cursor` | number | Page number for pagination | `1` | -| `limit` | number | Number of results per page (max: 50) | `10` | - -### Filter Configuration - -Filters can be applied to narrow down results. Each filter has the following structure: - -```json -{ - "name": "property_name", - "operator": "is|isNot|contains|doesNotContain|startsWith|endsWith|regex", - "value": ["value1", "value2"] -} -``` - -## Endpoints - -### Get Metrics - -Retrieve comprehensive website metrics including visitors, sessions, page views, and engagement data. - -``` -GET /insights/{projectId}/metrics -``` - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `startDate` | string | Start date for metrics | `2024-01-01` | -| `endDate` | string | End date for metrics | `2024-01-31` | -| `range` | string | Predefined range | `7d` | -| `filters` | array | Event filters | `[{"name":"path","operator":"is","value":["/home"]}]` | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/metrics?range=30d&filters=[{"name":"path","operator":"contains","value":["/product"]}]' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "metrics": { - "bounce_rate": 45.2, - "unique_visitors": 1250, - "total_sessions": 1580, - "avg_session_duration": 185.5, - "total_screen_views": 4230, - "views_per_session": 2.67 - }, - "series": [ - { - "date": "2024-01-01T00:00:00.000Z", - "bounce_rate": 42.1, - "unique_visitors": 85, - "total_sessions": 98, - "avg_session_duration": 195.2, - "total_screen_views": 156, - "views_per_session": 1.59 - } - ] -} -``` - -### Get Live Visitors - -Get the current number of active visitors on your website in real-time. - -``` -GET /insights/{projectId}/live -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/live' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "visitors": 23 -} -``` - -### Get Top Pages - -Retrieve the most visited pages with detailed analytics including session count, bounce rate, and average time on page. - -``` -GET /insights/{projectId}/pages -``` - -#### Query Parameters - -| Parameter | Type | Description | Example | -|-----------|------|-------------|---------| -| `startDate` | string | Start date | `2024-01-01` | -| `endDate` | string | End date | `2024-01-31` | -| `range` | string | Predefined range | `7d` | -| `filters` | array | Event filters | `[]` | -| `cursor` | number | Page number | `1` | -| `limit` | number | Results per page (max: 50) | `10` | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/pages?range=7d&limit=20' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "title": "Homepage - Example Site", - "origin": "https://example.com", - "path": "/", - "sessions": 456, - "bounce_rate": 35.2, - "avg_duration": 125.8 - }, - { - "title": "About Us", - "origin": "https://example.com", - "path": "/about", - "sessions": 234, - "bounce_rate": 45.1, - "avg_duration": 89.3 - } -] -``` - -### Get Referrer Data - -Retrieve referrer analytics to understand where your traffic is coming from. - -``` -GET /insights/{projectId}/referrer -GET /insights/{projectId}/referrer_name -GET /insights/{projectId}/referrer_type -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/referrer?range=30d&limit=15' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "google.com", - "sessions": 567, - "bounce_rate": 42.1, - "avg_session_duration": 156.7 - }, - { - "name": "facebook.com", - "sessions": 234, - "bounce_rate": 38.9, - "avg_session_duration": 189.2 - } -] -``` - -### Get UTM Campaign Data - -Analyze your marketing campaigns with UTM parameter breakdowns. - -``` -GET /insights/{projectId}/utm_source -GET /insights/{projectId}/utm_medium -GET /insights/{projectId}/utm_campaign -GET /insights/{projectId}/utm_term -GET /insights/{projectId}/utm_content -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/utm_source?range=30d' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "google", - "sessions": 890, - "bounce_rate": 35.4, - "avg_session_duration": 178.9 - }, - { - "name": "facebook", - "sessions": 456, - "bounce_rate": 41.2, - "avg_session_duration": 142.3 - } -] -``` - -### Get Geographic Data - -Understand your audience location with country, region, and city breakdowns. - -``` -GET /insights/{projectId}/country -GET /insights/{projectId}/region -GET /insights/{projectId}/city -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/country?range=30d&limit=20' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "United States", - "sessions": 1234, - "bounce_rate": 38.7, - "avg_session_duration": 167.4 - }, - { - "name": "United Kingdom", - "sessions": 567, - "bounce_rate": 42.1, - "avg_session_duration": 145.8 - } -] -``` - -For region and city endpoints, an additional `prefix` field may be included: - -```json -[ - { - "prefix": "United States", - "name": "California", - "sessions": 456, - "bounce_rate": 35.2, - "avg_session_duration": 172.1 - } -] -``` - -### Get Device & Technology Data - -Analyze visitor devices, browsers, and operating systems. - -``` -GET /insights/{projectId}/device -GET /insights/{projectId}/browser -GET /insights/{projectId}/browser_version -GET /insights/{projectId}/os -GET /insights/{projectId}/os_version -GET /insights/{projectId}/brand -GET /insights/{projectId}/model ``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/insights/abc123/browser?range=7d' \ - -H 'openpanel-client-id: YOUR_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_CLIENT_SECRET' -``` - -#### Response - -```json -[ - { - "name": "Chrome", - "sessions": 789, - "bounce_rate": 36.4, - "avg_session_duration": 162.3 - }, - { - "name": "Firefox", - "sessions": 234, - "bounce_rate": 41.7, - "avg_session_duration": 148.9 - } -] +https://api.openpanel.dev/insights/{projectId} ``` -For version-specific endpoints (browser_version, os_version), a `prefix` field shows the parent: - -```json -[ - { - "prefix": "Chrome", - "name": "118.0.0.0", - "sessions": 456, - "bounce_rate": 35.8, - "avg_session_duration": 165.7 - } -] -``` - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid query parameters", - "details": { - "issues": [ - { - "path": ["range"], - "message": "Invalid enum value" - } - ] - } -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Invalid client credentials" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting +## Available endpoints -The Insights API implements rate limiting: -- **100 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries +| Endpoint | Description | +|----------|-------------| +| `GET /metrics` | Visitors, sessions, bounce rate, and engagement | +| `GET /live` | Current active visitor count | +| `GET /pages` | Top pages by sessions | +| `GET /referrer` | Traffic sources | +| `GET /country`, `/region`, `/city` | Geographic breakdown | +| `GET /device`, `/browser`, `/os` | Device and technology breakdown | +| `GET /utm_source`, `/utm_campaign`, … | UTM parameter breakdown | -## Notes +Most endpoints accept `startDate`, `endDate`, `range`, `filters`, `cursor`, and `limit` query parameters. -- All dates are returned in ISO 8601 format -- Durations are in seconds -- Bounce rates and percentages are returned as decimal numbers (e.g., 45.2 = 45.2%) -- Session duration is the average time spent on the website -- All timezone handling is done server-side based on project settings +For full schemas and all available endpoints, see the [API Reference](/docs/api-reference/insights). diff --git a/apps/public/content/docs/api/manage/clients.mdx b/apps/public/content/docs/api/manage/clients.mdx index 91c9fd2eb..ec014d0a7 100644 --- a/apps/public/content/docs/api/manage/clients.mdx +++ b/apps/public/content/docs/api/manage/clients.mdx @@ -5,328 +5,32 @@ description: Manage API clients for your OpenPanel projects. Create, read, updat ## Authentication -To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +Requires a `root` client. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Clients API requests should be made to: - ``` https://api.openpanel.dev/manage/clients ``` -## Client Types +## Client types -OpenPanel supports three client types with different access levels: - -| Type | Description | Use Case | -|------|-------------|----------| -| `read` | Read-only access | Export data, view insights, read-only operations | -| `write` | Write access | Track events, send data to OpenPanel | -| `root` | Full access | Manage resources, access Manage API | - -**Note**: Only `root` clients can access the Manage API. +| Type | Description | +|------|-------------| +| `write` | Ingest events and profile updates | +| `read` | Query analytics data (insights, export) | +| `root` | Full access — manage API, read, and write | ## Endpoints -### List Clients - -Retrieve all clients in your organization, optionally filtered by project. - -``` -GET /manage/clients -``` - -#### Query Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `projectId` | string | Optional. Filter clients by project ID | - -#### Example Request - -```bash -# List all clients -curl 'https://api.openpanel.dev/manage/clients' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' - -# List clients for a specific project -curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": [ - { - "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", - "name": "First client", - "type": "write", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z" - }, - { - "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", - "name": "Read-only Client", - "type": "read", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T11:00:00.000Z", - "updatedAt": "2024-01-15T11:00:00.000Z" - } - ] -} -``` - -**Note**: Client secrets are never returned in list or get responses for security reasons. - -### Get Client - -Retrieve a specific client by ID. - -``` -GET /manage/clients/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the client (UUID) | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": { - "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", - "name": "First client", - "type": "write", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z" - } -} -``` - -### Create Client - -Create a new API client. A secure secret is automatically generated and returned once. - -``` -POST /manage/clients -``` - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | Yes | Client name (minimum 1 character) | -| `projectId` | string | No | Associate client with a specific project | -| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) | - -#### Example Request - -```bash -curl -X POST 'https://api.openpanel.dev/manage/clients' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "My API Client", - "projectId": "my-project", - "type": "read" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", - "name": "My API Client", - "type": "read", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T11:00:00.000Z", - "updatedAt": "2024-01-15T11:00:00.000Z", - "secret": "sec_b2521ca283bf903b46b3" - } -} -``` - -**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client. - -### Update Client - -Update an existing client's name. - -``` -PATCH /manage/clients/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the client (UUID) | - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | No | New client name (minimum 1 character) | - -**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client. - -#### Example Request - -```bash -curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "Updated Client Name" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", - "name": "Updated Client Name", - "type": "read", - "projectId": "my-project", - "organizationId": "org_123", - "ignoreCorsAndSecret": false, - "createdAt": "2024-01-15T11:00:00.000Z", - "updatedAt": "2024-01-15T11:30:00.000Z" - } -} -``` - -### Delete Client - -Permanently delete a client. This action cannot be undone. - -``` -DELETE /manage/clients/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the client (UUID) | - -#### Example Request - -```bash -curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "success": true -} -``` - -**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client. - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid request body", - "details": [ - { - "path": ["name"], - "message": "String must contain at least 1 character(s)" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Manage: Only root clients are allowed to manage resources" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Client not found" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The Clients API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Security Best Practices - -1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems -2. **Use Appropriate Client Types**: Use the minimum required access level for each use case -3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets -4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code -5. **Monitor Client Usage**: Regularly review and remove unused clients +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/manage/clients` | List all clients (optionally filter by `projectId`) | +| `GET` | `/manage/clients/{id}` | Get a specific client | +| `POST` | `/manage/clients` | Create a new client | +| `PATCH` | `/manage/clients/{id}` | Update client name | +| `DELETE` | `/manage/clients/{id}` | Permanently delete a client | -## Notes +Client secrets are only returned once at creation time and are never retrievable afterwards. -- Client IDs are UUIDs (Universally Unique Identifiers) -- Client secrets are automatically generated with the format `sec_` followed by random hex characters -- Secrets are hashed using argon2 before storage -- Clients can be associated with a project or exist at the organization level -- Clients are scoped to your organization - you can only manage clients in your organization -- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution) +For full request/response schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/manage/index.mdx b/apps/public/content/docs/api/manage/index.mdx index 64b7ed89b..e3bf7fa6b 100644 --- a/apps/public/content/docs/api/manage/index.mdx +++ b/apps/public/content/docs/api/manage/index.mdx @@ -1,140 +1,28 @@ --- title: Manage API Overview -description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API. +description: Programmatically manage projects, clients, and references in your OpenPanel organization. --- -## Overview - -The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks. - ## Authentication -The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization. - -To authenticate with the Manage API, you need: -- A client with `type: 'root'` -- Your `clientId` and `clientSecret` - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +The Manage API requires a **root** client. Root clients have organization-wide access and can manage all resources. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Manage API requests should be made to: - ``` https://api.openpanel.dev/manage ``` -## Available Resources - -The Manage API provides CRUD operations for three resource types: - -### Projects - -Manage your analytics projects programmatically: -- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects -- Automatically creates a default write client when creating a project -- Supports project configuration including domains, CORS settings, and project types - -### Clients +## Resources -Manage API clients for your projects: -- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients -- Supports different client types: `read`, `write`, and `root` -- Auto-generates secure secrets on creation (returned once) +| Resource | Description | +|----------|-------------| +| Projects | Create, update, and delete analytics projects | +| Clients | Manage API clients (read / write / root) and their secrets | +| References | Mark important dates or events on your analytics timeline | -### References - -Manage reference points for your analytics: -- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references -- Useful for marking important dates or events in your analytics timeline -- Can be filtered by project - -## Common Features - -All endpoints share these common characteristics: - -### Organization Scope - -All operations are scoped to your organization. You can only manage resources that belong to your organization. - -### Response Format - -Successful responses follow this structure: - -```json -{ - "data": { - // Resource data - } -} -``` - -For list endpoints: - -```json -{ - "data": [ - // Array of resources - ] -} -``` - -### Error Handling - -The API uses standard HTTP response codes: - -- `200 OK` - Request successful -- `400 Bad Request` - Invalid request parameters -- `401 Unauthorized` - Authentication failed -- `404 Not Found` - Resource not found -- `429 Too Many Requests` - Rate limit exceeded - -## Rate Limiting - -The Manage API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Use Cases - -The Manage API is ideal for: - -- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure -- **Automation**: Automatically create projects and clients for new deployments -- **Bulk Operations**: Programmatically manage multiple resources -- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline -- **Administrative Tools**: Build custom admin interfaces - -## Security Best Practices - -1. **Root Clients Only**: Only root clients can access the Manage API -2. **Store Credentials Securely**: Never expose root client credentials in client-side code -3. **Use HTTPS**: Always use HTTPS for API requests -4. **Rotate Credentials**: Regularly rotate your root client credentials -5. **Limit Access**: Restrict root client creation to trusted administrators - -## Getting Started - -1. **Create a Root Client**: Use the dashboard to create a root client in your organization -2. **Store Credentials**: Securely store your root client ID and secret -3. **Make Your First Request**: Start with listing projects to verify authentication - -Example: - -```bash -curl 'https://api.openpanel.dev/manage/projects' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` +## Rate limiting -## Next Steps +20 requests per 10 seconds per client. -- Read the [Projects documentation](/docs/api/manage/projects) to manage projects -- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients -- Read the [References documentation](/docs/api/manage/references) to manage reference points +For full endpoint schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/manage/projects.mdx b/apps/public/content/docs/api/manage/projects.mdx index d12e2e293..035650ce0 100644 --- a/apps/public/content/docs/api/manage/projects.mdx +++ b/apps/public/content/docs/api/manage/projects.mdx @@ -5,323 +5,24 @@ description: Manage your OpenPanel projects programmatically. Create, read, upda ## Authentication -To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +Requires a `root` client. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All Projects API requests should be made to: - ``` https://api.openpanel.dev/manage/projects ``` ## Endpoints -### List Projects - -Retrieve all projects in your organization. - -``` -GET /manage/projects -``` - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/projects' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": [ - { - "id": "my-project", - "name": "My Project", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com", "https://www.example.com"], - "crossDomain": false, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z", - "deleteAt": null - } - ] -} -``` - -### Get Project - -Retrieve a specific project by ID. - -``` -GET /manage/projects/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the project | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/projects/my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": { - "id": "my-project", - "name": "My Project", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com"], - "crossDomain": false, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z", - "deleteAt": null - } -} -``` - -### Create Project - -Create a new project in your organization. A default write client is automatically created with the project. - -``` -POST /manage/projects -``` - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `name` | string | Yes | Project name (minimum 1 character) | -| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) | -| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) | -| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) | -| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) | - -#### Project Types - -- `website`: Web-based project -- `app`: Mobile application -- `backend`: Backend/server-side project - -#### Example Request - -```bash -curl -X POST 'https://api.openpanel.dev/manage/projects' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "My New Project", - "domain": "https://example.com", - "cors": ["https://example.com", "https://www.example.com"], - "crossDomain": false, - "types": ["website"] - }' -``` - -#### Response - -```json -{ - "data": { - "id": "my-new-project", - "name": "My New Project", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com", "https://www.example.com"], - "crossDomain": false, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T10:30:00.000Z", - "deleteAt": null, - "client": { - "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", - "secret": "sec_6c8ae85a092d6c66b242" - } - } -} -``` - -**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately. - -### Update Project - -Update an existing project's configuration. - -``` -PATCH /manage/projects/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the project | - -#### Request Body - -All fields are optional. Only include fields you want to update. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `name` | string | Project name (minimum 1 character) | -| `domain` | string \| null | Primary domain (URL format, empty string, or null) | -| `cors` | string[] | Array of allowed CORS origins | -| `crossDomain` | boolean | Enable cross-domain tracking | -| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret | - -#### Example Request - -```bash -curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "name": "Updated Project Name", - "crossDomain": true, - "allowUnsafeRevenueTracking": false - }' -``` - -#### Response - -```json -{ - "data": { - "id": "my-project", - "name": "Updated Project Name", - "organizationId": "org_123", - "domain": "https://example.com", - "cors": ["https://example.com"], - "crossDomain": true, - "allowUnsafeRevenueTracking": false, - "filters": [], - "types": ["website"], - "eventsCount": 0, - "createdAt": "2024-01-15T10:30:00.000Z", - "updatedAt": "2024-01-15T11:00:00.000Z", - "deleteAt": null - } -} -``` - -### Delete Project - -Soft delete a project. The project will be scheduled for deletion after 24 hours. - -``` -DELETE /manage/projects/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the project | - -#### Example Request - -```bash -curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "success": true -} -``` - -**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time. - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid request body", - "details": [ - { - "path": ["name"], - "message": "String must contain at least 1 character(s)" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Manage: Only root clients are allowed to manage resources" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Project not found" -} -``` - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The Projects API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/manage/projects` | List all projects in your organization | +| `GET` | `/manage/projects/{id}` | Get a specific project | +| `POST` | `/manage/projects` | Create a new project | +| `PATCH` | `/manage/projects/{id}` | Update a project | +| `DELETE` | `/manage/projects/{id}` | Soft-delete a project (24h grace period) | -## Notes +When you create a project, a default `write` client is automatically created and returned with the response. The client secret is only shown once. -- Project IDs are automatically generated from the project name using a slug format -- If a project ID already exists, a numeric suffix is added -- CORS domains are automatically normalized (trailing slashes removed) -- The default client created with a project has `type: 'write'` -- Projects are scoped to your organization - you can only manage projects in your organization -- Soft-deleted projects are excluded from list endpoints +For full request/response schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/manage/references.mdx b/apps/public/content/docs/api/manage/references.mdx index 54b799a06..263971d84 100644 --- a/apps/public/content/docs/api/manage/references.mdx +++ b/apps/public/content/docs/api/manage/references.mdx @@ -1,344 +1,30 @@ --- title: References -description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline. +description: Manage reference points for your OpenPanel projects. References mark important dates or events on your analytics timeline. --- ## Authentication -To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. - -For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. - -Include the following headers with your requests: -- `openpanel-client-id`: Your OpenPanel root client ID -- `openpanel-client-secret`: Your OpenPanel root client secret +Requires a `root` client. See the [Authentication](/docs/api/authentication) guide. ## Base URL -All References API requests should be made to: - ``` https://api.openpanel.dev/manage/references ``` -## What are References? - -References are markers you can add to your analytics timeline to track important events such as: -- Product launches -- Marketing campaign start dates -- Feature releases -- Website redesigns -- Major announcements +## What are references? -References appear in your analytics charts and help you correlate changes in metrics with specific events. +References are markers on your analytics timeline — useful for product launches, campaign start dates, feature releases, or any event you want to correlate with changes in your metrics. ## Endpoints -### List References - -Retrieve all references in your organization, optionally filtered by project. - -``` -GET /manage/references -``` - -#### Query Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `projectId` | string | Optional. Filter references by project ID | - -#### Example Request - -```bash -# List all references -curl 'https://api.openpanel.dev/manage/references' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' - -# List references for a specific project -curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": [ - { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch", - "description": "Version 2.0 released", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T08:00:00.000Z" - }, - { - "id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96", - "title": "Marketing Campaign Start", - "description": "Q1 2024 campaign launched", - "date": "2024-01-20T09:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-18T10:00:00.000Z", - "updatedAt": "2024-01-18T10:00:00.000Z" - } - ] -} -``` - -### Get Reference - -Retrieve a specific reference by ID. - -``` -GET /manage/references/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the reference (UUID) | - -#### Example Request - -```bash -curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "data": { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch", - "description": "Version 2.0 released", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T08:00:00.000Z" - } -} -``` - -### Create Reference - -Create a new reference point for a project. - -``` -POST /manage/references -``` - -#### Request Body - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `projectId` | string | Yes | The ID of the project this reference belongs to | -| `title` | string | Yes | Reference title (minimum 1 character) | -| `description` | string | No | Optional description or notes | -| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) | - -#### Example Request - -```bash -curl -X POST 'https://api.openpanel.dev/manage/references' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "projectId": "my-project", - "title": "Product Launch", - "description": "Version 2.0 released with new features", - "datetime": "2024-01-15T10:00:00.000Z" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch", - "description": "Version 2.0 released with new features", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T08:00:00.000Z" - } -} -``` - -**Note**: The `date` field in the response is parsed from the `datetime` string you provided. - -### Update Reference - -Update an existing reference. - -``` -PATCH /manage/references/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the reference (UUID) | - -#### Request Body - -All fields are optional. Only include fields you want to update. - -| Parameter | Type | Description | -|-----------|------|-------------| -| `title` | string | Reference title (minimum 1 character) | -| `description` | string \| null | Description or notes (set to `null` to clear) | -| `datetime` | string | Date and time for the reference (ISO 8601 format) | - -#### Example Request - -```bash -curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ - -H 'Content-Type: application/json' \ - -d '{ - "title": "Product Launch v2.1", - "description": "Updated: Version 2.1 released with bug fixes", - "datetime": "2024-01-15T10:00:00.000Z" - }' -``` - -#### Response - -```json -{ - "data": { - "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", - "title": "Product Launch v2.1", - "description": "Updated: Version 2.1 released with bug fixes", - "date": "2024-01-15T10:00:00.000Z", - "projectId": "my-project", - "createdAt": "2024-01-10T08:00:00.000Z", - "updatedAt": "2024-01-10T09:30:00.000Z" - } -} -``` - -### Delete Reference - -Permanently delete a reference. This action cannot be undone. - -``` -DELETE /manage/references/{id} -``` - -#### Path Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| `id` | string | The ID of the reference (UUID) | - -#### Example Request - -```bash -curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ - -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ - -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' -``` - -#### Response - -```json -{ - "success": true -} -``` - -## Error Handling - -The API uses standard HTTP response codes. Common error responses: - -### 400 Bad Request - -```json -{ - "error": "Bad Request", - "message": "Invalid request body", - "details": [ - { - "path": ["title"], - "message": "String must contain at least 1 character(s)" - } - ] -} -``` - -### 401 Unauthorized - -```json -{ - "error": "Unauthorized", - "message": "Manage: Only root clients are allowed to manage resources" -} -``` - -### 404 Not Found - -```json -{ - "error": "Not Found", - "message": "Reference not found" -} -``` - -This error can occur if: -- The reference ID doesn't exist -- The reference belongs to a different organization - -### 429 Too Many Requests - -Rate limiting response includes headers indicating your rate limit status. - -## Rate Limiting - -The References API implements rate limiting: -- **20 requests per 10 seconds** per client -- Rate limit headers included in responses -- Implement exponential backoff for retries - -## Date Format - -References use ISO 8601 date format. Examples: - -- `2024-01-15T10:00:00.000Z` - UTC timezone -- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5) -- `2024-01-15` - Date only (time defaults to 00:00:00) - -The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp. - -## Use Cases - -References are useful for: - -- **Product Launches**: Mark when new versions or features are released -- **Marketing Campaigns**: Track campaign start and end dates -- **Website Changes**: Note when major redesigns or updates occur -- **Business Events**: Record important business milestones -- **A/B Testing**: Mark when experiments start or end -- **Seasonal Events**: Track holidays, sales periods, or seasonal changes - -## Notes +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/manage/references` | List all references (optionally filter by `projectId`) | +| `GET` | `/manage/references/{id}` | Get a specific reference | +| `POST` | `/manage/references` | Create a new reference | +| `PATCH` | `/manage/references/{id}` | Update a reference | +| `DELETE` | `/manage/references/{id}` | Delete a reference | -- Reference IDs are UUIDs (Universally Unique Identifiers) -- References are scoped to projects - each reference belongs to a specific project -- References are scoped to your organization - you can only manage references for projects in your organization -- The `description` field is optional and can be set to `null` to clear it -- References appear in analytics charts to help correlate metrics with events -- When filtering by `projectId`, the project must exist and belong to your organization +For full request/response schemas, see the [API Reference](/docs/api-reference/manage). diff --git a/apps/public/content/docs/api/meta.json b/apps/public/content/docs/api/meta.json index 3e49c2529..6683673ec 100644 --- a/apps/public/content/docs/api/meta.json +++ b/apps/public/content/docs/api/meta.json @@ -1,4 +1,10 @@ { "title": "API", - "pages": ["track", "export", "insights", "manage"] -} + "pages": [ + "authentication", + "track", + "export", + "insights", + "manage" + ] +} \ No newline at end of file diff --git a/apps/public/content/docs/api/track.mdx b/apps/public/content/docs/api/track.mdx index 033f8cfb9..6c75ad587 100644 --- a/apps/public/content/docs/api/track.mdx +++ b/apps/public/content/docs/api/track.mdx @@ -1,203 +1,43 @@ --- title: Track -description: This guide demonstrates how to interact with the OpenPanel API using cURL. These examples provide a low-level understanding of the API endpoints and can be useful for testing or for integrations where a full SDK isn't available. +description: How to send events, identify users, and manage groups via the HTTP API. --- ## Good to know -- If you want to track **geo location** you'll need to pass the `ip` property as a header `x-client-ip` -- If you want to track **device information** you'll need to pass the `user-agent` property as a header `user-agent` +- Pass the `x-client-ip` header to enable geo location tracking +- Pass the `user-agent` header to enable device detection ## Authentication -All requests to the OpenPanel API require authentication. You'll need to include your `clientId` and `clientSecret` in the headers of each request. +All requests require a `write` or `root` client. See the [Authentication](/docs/api/authentication) guide. ```bash -H "openpanel-client-id: YOUR_CLIENT_ID" \ -H "openpanel-client-secret: YOUR_CLIENT_SECRET" ``` -## Usage - -### Base URL - -All API requests should be made to: +## Base URL ``` https://api.openpanel.dev ``` -### Tracking Events - -To track an event: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "track", - "payload": { - "name": "my_event", - "properties": { - "foo": "bar" - } - } -}' -``` - -### Identifying Users - -To identify a user: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "identify", - "payload": { - "profileId": "123", - "firstName": "Joe", - "lastName": "Doe", - "email": "joe@doe.com", - "properties": { - "tier": "premium" - } - } -}' -``` - -### Incrementing Properties -To increment a numeric property: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "increment", - "payload": { - "profileId": "1", - "property": "visits", - "value": 1 - } -}' -``` - -### Decrementing Properties -To decrement a numeric property: - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "decrement", - "payload": { - "profileId": "1", - "property": "visits", - "value": 1 - } -}' -``` - -### Creating or updating a group - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "group", - "payload": { - "id": "org_acme", - "type": "company", - "name": "Acme Inc", - "properties": { - "plan": "enterprise", - "seats": 25 - } - } -}' -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `id` | `string` | Yes | Unique identifier for the group | -| `type` | `string` | Yes | Category of group (e.g. `"company"`, `"workspace"`) | -| `name` | `string` | Yes | Display name | -| `properties` | `object` | No | Custom metadata | - -### Assigning a user to a group - -Links a profile to one or more groups. This updates the profile record but does not auto-attach groups to future events — you still need to pass `groups` explicitly on each track call. - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "assign_group", - "payload": { - "profileId": "user_123", - "groupIds": ["org_acme"] - } -}' -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `profileId` | `string` | No | Profile to assign. Falls back to the device ID if omitted | -| `groupIds` | `string[]` | Yes | Group IDs to link to the profile | +## Event types -### Tracking events with groups +The `/track` endpoint accepts a `type` field that determines what gets recorded: -Groups are never auto-populated on events — even if the profile has been assigned to a group via `assign_group`. Pass `groups` on every track event where you want group data. - -```bash -curl -X POST https://api.openpanel.dev/track \ --H "Content-Type: application/json" \ --H "openpanel-client-id: YOUR_CLIENT_ID" \ --H "openpanel-client-secret: YOUR_CLIENT_SECRET" \ --d '{ - "type": "track", - "payload": { - "name": "report_exported", - "profileId": "user_123", - "groups": ["org_acme"], - "properties": { - "format": "pdf" - } - } -}' -``` - -Unlike the SDK, where `setGroup()` stores group IDs on the instance and attaches them to every subsequent `track()` call, the API has no such state. You must pass `groups` on each event. - -### Error Handling -The API uses standard HTTP response codes to indicate the success or failure of requests. In case of an error, the response body will contain more information about the error. -Example error response: - -```json -{ - "error": "Invalid client credentials", - "status": 401 -} -``` +| Type | Description | +|------|-------------| +| `track` | Record a named event with optional properties | +| `identify` | Create or update a user profile | +| `increment` | Increment a numeric profile property | +| `decrement` | Decrement a numeric profile property | +| `group` | Create or update a group | +| `assign_group` | Link a profile to one or more groups | -### Rate Limiting +### Groups and events -The API implements rate limiting to prevent abuse. If you exceed the rate limit, you'll receive a 429 (Too Many Requests) response. The response will include headers indicating your rate limit status. +Groups are never auto-populated on events — even after `assign_group`. Pass `groups` explicitly on each `track` call where you need group data. -Best Practices - 1. Always use HTTPS to ensure secure communication. - 2. Store your clientId and clientSecret securely and never expose them in client-side code. - 3. Implement proper error handling in your applications. - 4. Respect rate limits and implement exponential backoff for retries. \ No newline at end of file +For full request/response schemas for every event type, see the [API Reference](/docs/api-reference/track/post). diff --git a/apps/public/content/docs/mcp/index.mdx b/apps/public/content/docs/mcp/index.mdx new file mode 100644 index 000000000..4b17b3000 --- /dev/null +++ b/apps/public/content/docs/mcp/index.mdx @@ -0,0 +1,179 @@ +--- +title: MCP Server +description: Connect AI assistants to your OpenPanel analytics data using the Model Context Protocol. +--- + +OpenPanel exposes an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that lets AI assistants — Claude, Cursor, Windsurf, and others — query your analytics data directly in conversation. + +## Endpoint + +``` +https://api.openpanel.dev/mcp +``` + +## Authentication + +### Why `?token=` instead of headers + +MCP clients establish a long-lived SSE connection to the server. Most MCP clients do not support setting custom HTTP headers on SSE connections — only on the initial HTTP request. Because of this, the token is passed as a **query parameter** instead: + +``` +https://api.openpanel.dev/mcp?token=YOUR_TOKEN +``` + +The `Authorization: Bearer` header is also accepted as a fallback, but `?token=` is the recommended approach for MCP clients. + +### Token format + +The token is a **base64-encoded** string of your client ID and client secret joined by a colon: + +``` +base64(clientId:clientSecret) +``` + +Generate it in your terminal: + +```bash +echo -n "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" | base64 +``` + +Then append it to the MCP URL: + +``` +https://api.openpanel.dev/mcp?token= +``` + +### Required client type + +Only `read` and `root` clients can authenticate with MCP. Write-only clients are rejected. + +| Client type | Access | +|-------------|--------| +| `read` | Scoped to a single project (the one the client belongs to) | +| `root` | Can query any project in your organization | + +Use a `read` client if you want to limit the AI assistant to one project. Use a `root` client if you need cross-project access or want to list all projects. + +Go to **Settings → API Clients** in your dashboard to create a client. See the [Authentication guide](/docs/api/authentication) for more details. + +## Connecting to Claude Desktop + +Add the following to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "openpanel": { + "type": "streamable-http", + "url": "https://api.openpanel.dev/mcp?token=YOUR_TOKEN" + } + } +} +``` + +## Connecting with Claude Code CLI + +With the Claude CLI you can use the `--header` flag to pass the token via `Authorization: Bearer` rather than embedding it in the URL: + +```bash +claude mcp add --transport http openpanel https://api.openpanel.dev/mcp \ + --header "Authorization: Bearer YOUR_TOKEN" +``` + +Or with the token in the URL (equivalent): + +```bash +claude mcp add --transport http openpanel "https://api.openpanel.dev/mcp?token=YOUR_TOKEN" +``` + +## Available tools + +### Project access + +| Tool | Description | +|------|-------------| +| `list_projects` | List all projects accessible with your credentials. Root clients see all organization projects; read clients see only their own. | +| `get_dashboard_urls` | Get clickable dashboard links for the current project — overview, events, profiles, sessions, and deep-links to specific items. | + +### Dashboards & reports + +| Tool | Description | +|------|-------------| +| `list_dashboards` | List all dashboards for a project. | +| `list_reports` | List all reports in a dashboard with their chart types and tracked events. | +| `get_report_data` | Execute a saved report and return its data (time-series, funnel, metric, etc.). | + +### Discovery + +| Tool | Description | +|------|-------------| +| `list_event_names` | Get the top 50 most common event names in the project. Call this first if you don't know exact event names. | +| `list_event_properties` | List all property keys tracked for an event (or across all events). | +| `get_event_property_values` | Get all distinct values for a specific event property. | + +### Events & sessions + +| Tool | Description | +|------|-------------| +| `query_events` | Query raw events with optional filters. Returns individual records with path, device, country, referrer, and custom properties. | +| `query_sessions` | Query sessions with optional filters. Each session includes duration, entry/exit pages, bounce status, and attribution data. | + +### Profiles (users) + +| Tool | Description | +|------|-------------| +| `find_profiles` | Search and filter user profiles by name, email, location, inactivity, session count, or whether they performed a specific event. | +| `get_profile` | Get a specific user profile with their most recent events. | +| `get_profile_sessions` | Get all sessions for a user profile, ordered by most recent first. | +| `get_profile_metrics` | Get computed lifetime metrics for a user: sessions, pageviews, bounce rate, revenue, and more. | + +### Groups (B2B) + +| Tool | Description | +|------|-------------| +| `list_group_types` | List all group types defined in the project (e.g. `company`, `team`). Call this first before querying groups. | +| `find_groups` | Search for groups by name, ID, or type. | +| `get_group` | Get a specific group with its properties and member profiles. | + +### Aggregated metrics + +| Tool | Description | +|------|-------------| +| `get_analytics_overview` | Key metrics for a date range: visitors, pageviews, sessions, bounce rate, and avg session duration. | +| `get_rolling_active_users` | Time series of active users using a rolling window — DAU (1 day), WAU (7 days), or MAU (30 days). | +| `get_top_pages` | Most visited pages ranked by pageviews. | +| `get_page_performance` | Per-page bounce rate, avg session duration, sessions, and pageviews. | +| `get_page_conversions` | Pages ranked by how many visitors went on to convert after viewing them. | +| `get_entry_exit_pages` | Most common entry pages (session start) or exit pages (session end). | +| `get_top_referrers` | Top traffic sources broken down by referrer name and type. | +| `get_country_breakdown` | Visitor counts by country, region, or city. | +| `get_device_breakdown` | Visitor counts by device type, browser, or OS. | + +### User behavior + +| Tool | Description | +|------|-------------| +| `get_funnel` | Analyze a conversion funnel between 2+ events — sign-up flows, checkout, onboarding. | +| `get_retention_cohort` | Weekly user retention cohort table showing long-term product stickiness. | +| `get_weekly_retention_series` | Week-over-week retention as a time series. | +| `get_user_last_seen_distribution` | Histogram of user recency — useful for churn analysis. | +| `get_user_flow` | Visualize user navigation flows as a Sankey diagram (before/after/between events). | +| `get_engagement_metrics` | Engagement metrics over time. | + +### Google Search Console + +These tools require GSC to be connected for the project in your dashboard settings. + +| Tool | Description | +|------|-------------| +| `gsc_get_overview` | GSC performance over time: clicks, impressions, CTR, and avg position. | +| `gsc_get_top_pages` | Top-performing pages from GSC ranked by clicks. | +| `gsc_get_page_details` | Detailed GSC performance for a specific page including all queries driving traffic to it. | +| `gsc_get_top_queries` | Top search queries ranked by clicks. | +| `gsc_get_query_opportunities` | Low-hanging-fruit SEO opportunities: queries ranking 4–20 with meaningful search volume. | +| `gsc_get_query_details` | Detailed GSC data for a specific search query with all pages that rank for it. | +| `gsc_get_cannibalization` | Queries where multiple pages on your site compete against each other in Google. | + +## Rate limiting + +60 requests per minute per client. diff --git a/apps/public/content/docs/meta.json b/apps/public/content/docs/meta.json index 654afdb98..6302f6b94 100644 --- a/apps/public/content/docs/meta.json +++ b/apps/public/content/docs/meta.json @@ -8,6 +8,8 @@ "...(tracking)", "---API---", "...api", + "---MCP---", + "...mcp", "---Dashboard---", "...dashboard", "---Self-hosting---", diff --git a/apps/public/next.config.mjs b/apps/public/next.config.mjs index 5da54f4c8..2fb90a3d8 100644 --- a/apps/public/next.config.mjs +++ b/apps/public/next.config.mjs @@ -9,7 +9,7 @@ const config = { unoptimized: true, domains: ['localhost', 'openpanel.dev', 'api.openpanel.dev'], }, - serverExternalPackages: ['@hyperdx/node-opentelemetry', '@openpanel/geo'], + serverExternalPackages: ['@hyperdx/node-opentelemetry', '@openpanel/geo', 'shiki'], redirects: [ { source: '/articles/top-7-open-source-web-analytics-tools', diff --git a/apps/public/package.json b/apps/public/package.json index 8819edacc..4105672a7 100644 --- a/apps/public/package.json +++ b/apps/public/package.json @@ -33,9 +33,10 @@ "clsx": "2.1.1", "dotted-map": "2.2.3", "framer-motion": "12.23.25", - "fumadocs-core": "16.2.2", - "fumadocs-mdx": "14.0.4", - "fumadocs-ui": "16.2.2", + "fumadocs-core": "16.7.11", + "fumadocs-mdx": "14.2.11", + "fumadocs-openapi": "^10.6.7", + "fumadocs-ui": "16.7.11", "geist": "1.5.1", "lucide-react": "^0.555.0", "next": "16.0.7", @@ -45,6 +46,7 @@ "react-markdown": "^10.1.0", "recharts": "^2.15.0", "rehype-external-links": "3.0.0", + "shiki": "^4.0.2", "tailwind-merge": "3.4.0", "tailwindcss-animate": "1.0.7", "zod": "catalog:" diff --git a/apps/public/source.config.ts b/apps/public/source.config.ts index 552c64bd3..a967cef5a 100644 --- a/apps/public/source.config.ts +++ b/apps/public/source.config.ts @@ -89,6 +89,12 @@ export const guideMeta = defineCollections({ schema: zGuide, }); +export const apiRefCollection = defineCollections({ + type: 'doc', + dir: './content/docs/api-reference', + schema: frontmatterSchema, +}); + export default defineConfig({ mdxOptions: { // MDX options diff --git a/apps/public/src/app/docs/[[...slug]]/page.tsx b/apps/public/src/app/docs/(docs)/[[...slug]]/page.tsx similarity index 100% rename from apps/public/src/app/docs/[[...slug]]/page.tsx rename to apps/public/src/app/docs/(docs)/[[...slug]]/page.tsx diff --git a/apps/public/src/app/docs/(docs)/layout.tsx b/apps/public/src/app/docs/(docs)/layout.tsx new file mode 100644 index 000000000..34949bee2 --- /dev/null +++ b/apps/public/src/app/docs/(docs)/layout.tsx @@ -0,0 +1,29 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { BookOpenIcon, CodeIcon } from 'lucide-react'; +import { baseOptions } from '@/lib/layout.shared'; +import { API_REFERENCE_BASE_URL } from '@/lib/openapi'; +import { source } from '@/lib/source'; + +export default function Layout({ children }: { children: React.ReactNode }) { + const tabs = [ + { + title: 'Documentation', + description: 'Guides and references', + url: '/docs', + icon: , + $folder: source.pageTree as never, + }, + { + title: 'API Reference', + description: 'REST API endpoints', + url: API_REFERENCE_BASE_URL, + icon: , + }, + ]; + + return ( + + {children} + + ); +} diff --git a/apps/public/src/app/docs/api-reference/[[...slug]]/page.tsx b/apps/public/src/app/docs/api-reference/[[...slug]]/page.tsx new file mode 100644 index 000000000..71dac17c1 --- /dev/null +++ b/apps/public/src/app/docs/api-reference/[[...slug]]/page.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { getMDXComponents } from '@/mdx-components'; +import { getApiReferenceSource, openapi } from '@/lib/openapi'; +import { createAPIPage } from 'fumadocs-openapi/ui'; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from 'fumadocs-ui/page'; +import { notFound, redirect } from 'next/navigation'; + +const APIPage = createAPIPage(openapi); + +interface PageProps { + params: Promise<{ slug?: string[] }>; +} + +export default async function Page(props: PageProps) { + const params = await props.params; + const source = await getApiReferenceSource(); + + if (!params.slug) { + const first = source.getPages()[0]; + if (first) redirect(first.url); + notFound(); + } + + const page = source.getPage(params.slug); + if (!page) notFound(); + + const data = page.data as Record; + + // Static MDX page + if (typeof data.body === 'function') { + const MDX = data.body as React.FC<{ components?: Record }>; + const toc = data.toc as React.ComponentProps['toc']; + return ( + + {page.data.title} + {page.data.description && ( + {page.data.description} + )} + + + + + ); + } + + // OpenAPI generated page + const { getAPIPageProps } = data as { getAPIPageProps: () => React.ComponentProps }; + return ( + + {page.data.title} + {page.data.description && ( + {page.data.description} + )} + + + + + ); +} + +export async function generateStaticParams() { + const source = await getApiReferenceSource(); + return source.generateParams(); +} + +export const dynamic = 'force-dynamic'; diff --git a/apps/public/src/app/docs/api-reference/layout.tsx b/apps/public/src/app/docs/api-reference/layout.tsx new file mode 100644 index 000000000..0949a62ce --- /dev/null +++ b/apps/public/src/app/docs/api-reference/layout.tsx @@ -0,0 +1,34 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { BookOpenIcon, CodeIcon } from 'lucide-react'; +import { baseOptions } from '@/lib/layout.shared'; +import { API_REFERENCE_BASE_URL, getApiReferenceSource } from '@/lib/openapi'; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const apiSource = await getApiReferenceSource(); + + const tabs = [ + { + title: 'Documentation', + description: 'Guides and references', + url: '/docs', + icon: , + }, + { + title: 'API Reference', + description: 'REST API endpoints', + url: API_REFERENCE_BASE_URL, + icon: , + $folder: apiSource.pageTree as never, + }, + ]; + + return ( + + {children} + + ); +} diff --git a/apps/public/src/app/docs/layout.tsx b/apps/public/src/app/docs/layout.tsx deleted file mode 100644 index f5052057b..000000000 --- a/apps/public/src/app/docs/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { baseOptions } from '@/lib/layout.shared'; -import { source } from '@/lib/source'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/apps/public/src/app/global.css b/apps/public/src/app/global.css index a5ce430eb..7a6310a29 100644 --- a/apps/public/src/app/global.css +++ b/apps/public/src/app/global.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @import 'fumadocs-ui/css/neutral.css'; @import 'fumadocs-ui/css/preset.css'; +@import 'fumadocs-openapi/css/preset.css'; @custom-variant dark (&:is(.dark *)); @custom-variant light (&:is(.light *)); diff --git a/apps/public/src/components/navbar.tsx b/apps/public/src/components/navbar.tsx index 33a8209d2..bf848dfb4 100644 --- a/apps/public/src/components/navbar.tsx +++ b/apps/public/src/components/navbar.tsx @@ -9,7 +9,7 @@ import { GithubButton } from './github-button'; import { Logo } from './logo'; import { SignUpButton } from './sign-up-button'; import { Button } from './ui/button'; -import { baseOptions } from '@/lib/layout.shared'; +import { baseOptions, siteName } from '@/lib/layout.shared'; import { cn } from '@/lib/utils'; const LINKS = [ @@ -76,7 +76,7 @@ const Navbar = () => { - {baseOptions().nav?.title} + {siteName} diff --git a/apps/public/src/lib/openapi.ts b/apps/public/src/lib/openapi.ts new file mode 100644 index 000000000..5a11318c7 --- /dev/null +++ b/apps/public/src/lib/openapi.ts @@ -0,0 +1,60 @@ +import { loader } from 'fumadocs-core/source'; +import { + createOpenAPI, + openapiPlugin, + openapiSource, +} from 'fumadocs-openapi/server'; +import { apiRefCollection } from 'fumadocs-mdx:collections/server'; +import { toFumadocsSource } from 'fumadocs-mdx/runtime/server'; +import path from 'node:path'; +import { cache } from 'react'; + +const API_URL = + process.env.NODE_ENV === 'production' + ? 'https://api.openpanel.dev' + : (process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3333'); + +export const openapi = createOpenAPI({ + input: [`${API_URL}/documentation/json`], +}); + +export const API_REFERENCE_BASE_URL = '/docs/api-reference'; + +export const getApiReferenceSource = cache(async () => { + const openapiFiles = await openapiSource(openapi, { + groupBy: 'tag', + meta: { folderStyle: 'separator' }, + }).catch(() => ({ files: [] as never[] })); + + const staticSource = toFumadocsSource(apiRefCollection, []); + + // Collect the slugs of static pages so we can inject them into the + // OpenAPI-generated root meta.json (which only lists the tag groups). + const staticSlugs = staticSource.files + .filter((f): f is typeof f & { type: 'page' } => f.type === 'page') + .map((f) => path.basename(f.path, path.extname(f.path))); + + // Inject static page slugs at the top of the root meta.json that + // openapiSource generates for the tag separator groups. + const patchedOpenapiFiles = openapiFiles.files.map((f) => { + if (f.type === 'meta' && (f.path === 'meta.json' || f.path === '/meta.json')) { + const data = f.data as { pages?: string[] }; + return { + ...f, + data: { + ...data, + pages: [...staticSlugs, ...(data.pages ?? [])], + }, + }; + } + return f; + }); + + return loader({ + baseUrl: API_REFERENCE_BASE_URL, + source: { + files: [...staticSource.files, ...patchedOpenapiFiles], + }, + plugins: [openapiPlugin()], + }); +}); diff --git a/apps/public/src/mdx-components.tsx b/apps/public/src/mdx-components.tsx index c43915820..06ff02708 100644 --- a/apps/public/src/mdx-components.tsx +++ b/apps/public/src/mdx-components.tsx @@ -24,16 +24,6 @@ export function getMDXComponents(components?: MDXComponents) { } satisfies MDXComponents; } -declare module 'mdx/types.js' { - // Augment the MDX types to make it understand React. - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace JSX { - type Element = React.JSX.Element; - type ElementClass = React.JSX.ElementClass; - type ElementType = React.JSX.ElementType; - type IntrinsicElements = React.JSX.IntrinsicElements; - } -} declare global { type MDXProvidedComponents = ReturnType; diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index 761738aad..ec4ed02bc 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -5,13 +5,17 @@ import { uniq } from 'ramda'; import sqlstring from 'sqlstring'; import { profileBuffer } from '../buffers'; import { + ch, chQuery, convertClickhouseDateToJs, formatClickhouseDate, isClickhouseDefaultMinDate, TABLE_NAMES, } from '../clickhouse/client'; +import { clix } from '../clickhouse/query-builder'; import { createSqlBuilder } from '../sql-builder'; +import type { IClickhouseEvent } from './event.service'; +import type { IClickhouseSession } from './session.service'; export interface IProfileMetrics { lastSeen: Date | null; @@ -326,12 +330,6 @@ export function upsertProfile( return profileBuffer.add(profile, isFromEvent); } -import sqlstring from 'sqlstring'; -import { ch } from '../clickhouse/client'; -import { clix } from '../clickhouse/query-builder'; -import type { IClickhouseEvent } from './event.service'; -import type { IClickhouseSession } from './session.service'; - const PROFILE_COLUMNS = 'id, first_name, last_name, email, avatar, properties, project_id, is_external, created_at, groups'; @@ -351,21 +349,25 @@ export interface FindProfilesInput { limit?: number; } -export async function findProfilesCore( - input: FindProfilesInput, +export function findProfilesCore( + input: FindProfilesInput ): Promise { const pid = sqlstring.escape(input.projectId); const conditions: string[] = [`project_id = ${pid}`]; if (input.email) { - conditions.push(`email LIKE ${sqlstring.escape('%' + input.email + '%')}`); + conditions.push(`email LIKE ${sqlstring.escape(`%${input.email}%`)}`); } if (input.name) { - const escaped = sqlstring.escape('%' + input.name + '%'); - conditions.push(`(first_name LIKE ${escaped} OR last_name LIKE ${escaped})`); + const escaped = sqlstring.escape(`%${input.name}%`); + conditions.push( + `(first_name LIKE ${escaped} OR last_name LIKE ${escaped})` + ); } if (input.country) { - conditions.push(`properties['country'] = ${sqlstring.escape(input.country)}`); + conditions.push( + `properties['country'] = ${sqlstring.escape(input.country)}` + ); } if (input.city) { conditions.push(`properties['city'] = ${sqlstring.escape(input.city)}`); @@ -374,7 +376,9 @@ export async function findProfilesCore( conditions.push(`properties['device'] = ${sqlstring.escape(input.device)}`); } if (input.browser) { - conditions.push(`properties['browser'] = ${sqlstring.escape(input.browser)}`); + conditions.push( + `properties['browser'] = ${sqlstring.escape(input.browser)}` + ); } if (input.inactiveDays !== undefined) { @@ -424,7 +428,7 @@ export async function findProfilesCore( export async function getProfileWithEvents( projectId: string, profileId: string, - eventLimit = 10, + eventLimit = 10 ): Promise<{ profile: IClickhouseProfile | null; recent_events: IClickhouseEvent[]; @@ -449,10 +453,10 @@ export async function getProfileWithEvents( return { profile: profiles[0] ?? null, recent_events }; } -export async function getProfileSessionsCore( +export function getProfileSessionsCore( projectId: string, profileId: string, - limit = 20, + limit = 20 ): Promise { return clix(ch) .select([]) diff --git a/packages/validation/src/track.validation.ts b/packages/validation/src/track.validation.ts index 8d479b688..7ea8c1fd8 100644 --- a/packages/validation/src/track.validation.ts +++ b/packages/validation/src/track.validation.ts @@ -86,38 +86,54 @@ export const zReplayPayload = z.object({ }); export const zTrackHandlerPayload = z.discriminatedUnion('type', [ - z.object({ - type: z.literal('track'), - payload: zTrackPayload, - }), - z.object({ - type: z.literal('identify'), - payload: zIdentifyPayload, - }), - z.object({ - type: z.literal('increment'), - payload: zIncrementPayload, - }), - z.object({ - type: z.literal('decrement'), - payload: zDecrementPayload, - }), - z.object({ - type: z.literal('alias'), - payload: zAliasPayload, - }), - z.object({ - type: z.literal('replay'), - payload: zReplayPayload, - }), - z.object({ - type: z.literal('group'), - payload: zGroupPayload, - }), - z.object({ - type: z.literal('assign_group'), - payload: zAssignGroupPayload, - }), + z + .object({ + type: z.enum(['track']), + payload: zTrackPayload, + }) + .meta({ title: 'Track' }), + z + .object({ + type: z.enum(['identify']), + payload: zIdentifyPayload, + }) + .meta({ title: 'Identify' }), + z + .object({ + type: z.enum(['increment']), + payload: zIncrementPayload, + }) + .meta({ title: 'Increment' }), + z + .object({ + type: z.enum(['decrement']), + payload: zDecrementPayload, + }) + .meta({ title: 'Decrement' }), + z + .object({ + type: z.enum(['alias']), + payload: zAliasPayload, + }) + .meta({ title: 'Alias' }), + z + .object({ + type: z.enum(['replay']), + payload: zReplayPayload, + }) + .meta({ title: 'Replay' }), + z + .object({ + type: z.enum(['group']), + payload: zGroupPayload, + }) + .meta({ title: 'Group' }), + z + .object({ + type: z.enum(['assign_group']), + payload: zAssignGroupPayload, + }) + .meta({ title: 'Assign Group' }), ]); export type ITrackPayload = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c0fd55a6..fb9abb712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,7 +274,7 @@ importers: version: 4.1.0 tsdown: specifier: 0.14.2 - version: 0.14.2(typescript@5.9.3) + version: 0.14.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -339,14 +339,17 @@ importers: specifier: 12.23.25 version: 12.23.25(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) fumadocs-core: - specifier: 16.2.2 - version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + specifier: 16.7.11 + version: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) fumadocs-mdx: - specifier: 14.0.4 - version: 14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) + specifier: 14.2.11 + version: 14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)) + fumadocs-openapi: + specifier: ^10.6.7 + version: 10.6.7(20b279af7e6f0aee6451333d6680f64b) fumadocs-ui: - specifier: 16.2.2 - version: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17) + specifier: 16.7.11 + version: 16.7.11(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@emotion/is-prop-valid@0.8.8)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.0.2)(tailwindcss@4.1.17) geist: specifier: 1.5.1 version: 1.5.1(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -374,6 +377,9 @@ importers: rehype-external-links: specifier: 3.0.0 version: 3.0.0 + shiki: + specifier: ^4.0.2 + version: 4.0.2 tailwind-merge: specifier: 3.4.0 version: 3.4.0 @@ -1019,7 +1025,7 @@ importers: version: 9.0.8 tsdown: specifier: 0.14.2 - version: 0.14.2(typescript@5.9.3) + version: 0.14.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -3463,12 +3469,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.1': - resolution: {integrity: sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -3517,12 +3517,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.1': - resolution: {integrity: sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -3571,12 +3565,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.1': - resolution: {integrity: sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -3625,12 +3613,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.1': - resolution: {integrity: sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -3679,12 +3661,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.1': - resolution: {integrity: sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -3733,12 +3709,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.1': - resolution: {integrity: sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -3787,12 +3757,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.1': - resolution: {integrity: sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -3841,12 +3805,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.1': - resolution: {integrity: sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -3895,12 +3853,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.1': - resolution: {integrity: sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -3949,12 +3901,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.1': - resolution: {integrity: sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -4003,12 +3949,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.1': - resolution: {integrity: sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -4057,12 +3997,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.1': - resolution: {integrity: sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -4111,12 +4045,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.1': - resolution: {integrity: sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -4165,12 +4093,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.1': - resolution: {integrity: sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -4219,12 +4141,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.1': - resolution: {integrity: sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -4273,12 +4189,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.1': - resolution: {integrity: sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -4327,12 +4237,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.1': - resolution: {integrity: sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -4363,12 +4267,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.1': - resolution: {integrity: sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -4417,12 +4315,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.1': - resolution: {integrity: sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -4453,12 +4345,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.1': - resolution: {integrity: sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -4507,12 +4393,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.1': - resolution: {integrity: sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -4531,12 +4411,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.1': - resolution: {integrity: sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -4585,12 +4459,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.1': - resolution: {integrity: sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -4639,12 +4507,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.1': - resolution: {integrity: sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -4693,12 +4555,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.1': - resolution: {integrity: sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -4747,12 +4603,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.1': - resolution: {integrity: sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -4861,6 +4711,9 @@ packages: '@fastify/cors@11.1.0': resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.0.0': resolution: {integrity: sha512-OO/SA8As24JtT1usTUTKgGH7uLvhfwZPwlptRi2Dp5P4KKmJI3gvsZ8MIHnNwDs4sLf/aai5LzTyl66xr7qMxA==} @@ -4909,8 +4762,38 @@ packages: '@floating-ui/utils@0.2.1': resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} - '@formatjs/intl-localematcher@0.6.2': - resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} + '@formatjs/fast-memoize@3.1.1': + resolution: {integrity: sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==} + + '@formatjs/intl-localematcher@0.8.2': + resolution: {integrity: sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==} + + '@fumadocs/tailwind@0.0.3': + resolution: {integrity: sha512-/FWcggMz9BhoX+13xBoZLX+XX9mYvJ50dkTqy3IfocJqua65ExcsKfxwKH8hgTO3vA5KnWv4+4jU7LaW2AjAmQ==} + peerDependencies: + tailwindcss: ^4.0.0 + peerDependenciesMeta: + tailwindcss: + optional: true + + '@fumari/json-schema-ts@0.0.2': + resolution: {integrity: sha512-A2x8nj45r8Kc3Gqa+HpWRF9uzIMc9dySB6L2R2kiyjLHXWBsZUX99Atj5+Yup/iRQXQ9s8AX+uAPwPze7Xn05A==} + engines: {node: '>=20.0.0'} + peerDependencies: + json-schema-typed: ^8.0.2 + peerDependenciesMeta: + json-schema-typed: + optional: true + + '@fumari/stf@1.0.4': + resolution: {integrity: sha512-ozyRDo4GjOEuE+XZlcMSP/7lwoAMwP4tZI+hdwhVWS1MeCSJqVpHFWIKucocXUUhusX2oqZb3K5ip1J79mR6zw==} + peerDependencies: + '@types/react': '*' + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} @@ -5381,6 +5264,12 @@ packages: '@napi-rs/wasm-runtime@1.1.0': resolution: {integrity: sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==} + '@napi-rs/wasm-runtime@1.1.3': + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@15.0.3': resolution: {integrity: sha512-t9Xy32pjNOvVn2AS+Utt6VmyrshbpfUMhIjFO60gI58deSo/KgLOp31XZ4O+kY/Is8WAGYwA5gR7kOb1eORDBA==} @@ -6489,8 +6378,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@orama/orama@3.1.16': - resolution: {integrity: sha512-scSmQBD8eANlMUOglxHrN1JdSW8tDghsPuS83otqealBiIeMukCQMOf/wc0JJjDXomqwNdEQFLXLGHrU6PGxuA==} + '@orama/orama@3.1.18': + resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} '@oslojs/asn1@1.0.0': @@ -6600,101 +6489,323 @@ packages: cpu: [x64] os: [win32] + '@oxc-parser/binding-android-arm-eabi@0.124.0': + resolution: {integrity: sha512-+R9zCafSL8ovjokdPtorUp3sXrh8zQ2AC2L0ivXNvlLR0WS+5WdPkNVrnENq5UvzagM4Xgl0NPsJKz3Hv9+y8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm64@0.102.0': resolution: {integrity: sha512-pD2if3w3cxPvYbsBSTbhxAYGDaG6WVwnqYG0mYRQ142D6SJ6BpNs7YVQrqpRA2AJQCmzaPP5TRp/koFLebagfQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.124.0': + resolution: {integrity: sha512-ULHC/gVZ+nP4pd3kNNQTYaQ/e066BW/KuY5qUsvwkVWwOUQGDg+WpfyVOmQ4xfxoue6cMlkKkJ+ntdzfDXpNlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-darwin-arm64@0.102.0': resolution: {integrity: sha512-RzMN6f6MrjjpQC2Dandyod3iOscofYBpHaTecmoRRbC5sJMwsurkqUMHzoJX9F6IM87kn8m/JcClnoOfx5Sesw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.124.0': + resolution: {integrity: sha512-fGJ2hw7bnbUYn6UvTjp0m4WJ9zXz3cohgcwcgeo7gUZehpPNpvcVEVeIVHNmHnAuAw/ysf4YJR8DA1E+xCA4Lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.102.0': resolution: {integrity: sha512-Sr2/3K6GEcejY+HgWp5HaxRPzW5XHe9IfGKVn9OhLt8fzVLnXbK5/GjXj7JjMCNKI3G3ZPZDG2Dgm6CX3MaHCA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.124.0': + resolution: {integrity: sha512-j0+re9pgps5BH2Tk3fm59Hi3QuLP3C4KhqXi6A+wRHHHJWDFR8mc/KI9mBrfk2JRT+15doGo+zv1eN75/9DuOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.102.0': resolution: {integrity: sha512-s9F2N0KJCGEpuBW6ChpFfR06m2Id9ReaHSl8DCca4HvFNt8SJFPp8fq42n2PZy68rtkremQasM0JDrK2BoBeBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.124.0': + resolution: {integrity: sha512-0k5mS0npnrhKy72UfF51lpOZ2ESoPWn6gdFw+RdeRWcokraDW1O2kSx3laQ+yk7cCEavQdJSpWCYS/GvBbUCXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.102.0': resolution: {integrity: sha512-zRCIOWzLbqhfY4g8KIZDyYfO2Fl5ltxdQI1v2GlePj66vFWRl8cf4qcBGzxKfsH3wCZHAhmWd1Ht59mnrfH/UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.124.0': + resolution: {integrity: sha512-P/i4eguRWvAUfGdfhQYg1jpwYkyUV6D3gefIH7HhmRl1Ph6P4IqTIEVcyJr1i/3vr1V5OHU4wonH6/ue/Qzvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.124.0': + resolution: {integrity: sha512-/ameqFQH5fFP+66Atr8Ynv/2rYe4utcU7L4MoWS5JtrFLVO78g4qDLavyIlJxa6caSwYOvG/eO3c/DXqY5/6Rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.102.0': resolution: {integrity: sha512-5n5RbHgfjulRhKB0pW5p0X/NkQeOpI4uI9WHgIZbORUDATGFC8yeyPA6xYGEs+S3MyEAFxl4v544UEIWwqAgsA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.124.0': + resolution: {integrity: sha512-gNeyEcXTtfrRCbj2EfxWU85Fs0wIX3p44Y3twnvuMfkWlLrb9M1Z25AYNSKjJM+fdAjeeQCjw0on47zFuBYwQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@oxc-parser/binding-linux-arm64-musl@0.102.0': resolution: {integrity: sha512-/XWcmglH/VJ4yKAGTLRgPKSSikh3xciNxkwGiURt8dS30b+3pwc4ZZmudMu0tQ3mjSu0o7V9APZLMpbHK8Bp5w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@oxc-parser/binding-linux-arm64-musl@0.124.0': + resolution: {integrity: sha512-uvG7v4Tz9S8/PVqY0SP0DLHxo4hZGe+Pv2tGVnwcsjKCCUPjplbrFVvDzXq+kOaEoUkiCY0Kt1hlZ6FDJ1LKNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-ppc64-gnu@0.124.0': + resolution: {integrity: sha512-t7KZaaUhfp2au0MRpoENEFqwLKYDdptEry6V7pTAVdPEcFG4P6ii8yeGU9m6p5vb+b8WEKmdpGMNXBEYy7iJdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + '@oxc-parser/binding-linux-riscv64-gnu@0.102.0': resolution: {integrity: sha512-2jtIq4nswvy6xdqv1ndWyvVlaRpS0yqomLCvvHdCFx3pFXo5Aoq4RZ39kgvFWrbAtpeYSYeAGFnwgnqjx9ftdw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + '@oxc-parser/binding-linux-riscv64-gnu@0.124.0': + resolution: {integrity: sha512-eurGGaxHZiIQ+fBSageS8TAkRqZgdOiBeqNrWAqAPup9hXBTmQ0WcBjwsLElf+3jvDL9NhnX0dOgOqPfsjSjdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-musl@0.124.0': + resolution: {integrity: sha512-d1V7/ll1i/LhqE/gZy6Wbz6evlk0egh2XKkwMI3epiojtbtUwQSLIER0Y3yDBBocPuWOjJdvmjtEmPTTLXje/w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + '@oxc-parser/binding-linux-s390x-gnu@0.102.0': resolution: {integrity: sha512-Yp6HX/574mvYryiqj0jNvNTJqo4pdAsNP2LPBTxlDQ1cU3lPd7DUA4MQZadaeLI8+AGB2Pn50mPuPyEwFIxeFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + '@oxc-parser/binding-linux-s390x-gnu@0.124.0': + resolution: {integrity: sha512-w1+cBvriUteOpox6ATqCFVkpGL47PFdcfCPGmgUZbd78Fw44U0gQkc+kVGvAOTvGrptMYgwomD1c6OTVvkrpGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + '@oxc-parser/binding-linux-x64-gnu@0.102.0': resolution: {integrity: sha512-R4b0xZpDRhoNB2XZy0kLTSYm0ZmWeKjTii9fcv1Mk3/SIGPrrglwt4U6zEtwK54Dfi4Bve5JnQYduigR/gyDzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@oxc-parser/binding-linux-x64-gnu@0.124.0': + resolution: {integrity: sha512-RRB1evQiXRtMCsQQiAh9U0H3HzguLpE0ytfStuhRgmOj7tqUCOVxkHsvM9geZjAax6NqVRj7VXx32qjjkZPsBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@oxc-parser/binding-linux-x64-musl@0.102.0': resolution: {integrity: sha512-xM5A+03Ti3jvWYZoqaBRS3lusvnvIQjA46Fc9aBE/MHgvKgHSkrGEluLWg/33QEwBwxupkH25Pxc1yu97oZCtg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@oxc-parser/binding-linux-x64-musl@0.124.0': + resolution: {integrity: sha512-asVYN0qmSHlCU8H9Q47SmeJ/Z5EG4IWCC+QGxkfFboI5qh15aLlJnHmnrV61MwQRPXGnVC/sC3qKhrUyqGxUqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@oxc-parser/binding-openharmony-arm64@0.102.0': resolution: {integrity: sha512-AieLlsliblyaTFq7Iw9Nc618tgwV02JT4fQ6VIUd/3ZzbluHIHfPjIXa6Sds+04krw5TvCS8lsegtDYAyzcyhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.124.0': + resolution: {integrity: sha512-nhwuxm6B8pn9lzAzMUfa571L5hCXYwQo8C8cx5aGOuHWCzruR8gPJnRRXGBci+uGaIIQEZDyU/U6HDgrSp/JlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-wasm32-wasi@0.102.0': resolution: {integrity: sha512-w6HRyArs1PBb9rDsQSHlooe31buUlUI2iY8sBzp62jZ1tmvaJo9EIVTQlRNDkwJmk9DF9uEyIJ82EkZcCZTs9A==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@oxc-parser/binding-wasm32-wasi@0.124.0': + resolution: {integrity: sha512-LWuq4Dl9tff7n+HjJcqoBjDlVCtruc0shgtdtGM+rTUIE9aFxHA/P+wCYR+aWMjN8m9vNaRME/sKXErmhmeKrA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@oxc-parser/binding-win32-arm64-msvc@0.102.0': resolution: {integrity: sha512-pqP5UuLiiFONQxqGiUFMdsfybaK1EOK4AXiPlvOvacLaatSEPObZGpyCkAcj9aZcvvNwYdeY9cxGM9IT3togaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.124.0': + resolution: {integrity: sha512-aOh3Lf3AeH0dgzT4yBXcArFZ8VhqNXwZ/xlN0GqBtgVaGoHOOqL2YHlcVIgT+ghsXPVR2PTtYgBiQ1CNK7jp5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.124.0': + resolution: {integrity: sha512-sib5xC0nz/+SCpaETBuHBz4SXS02KuG5HtyOcHsO/SK5ZvLRGhOZx0elDKawjb6adFkD7dQCqpXUS25wY6ELKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.102.0': resolution: {integrity: sha512-ntMcL35wuLR1A145rLSmm7m7j8JBZGkROoB9Du0KFIFcfi/w1qk75BdCeiTl3HAKrreAnuhW3QOGs6mJhntowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.124.0': + resolution: {integrity: sha512-UgojtjGUgZgAZQYt7SC6VO65OVdxEkRe2q+2vbHJO//18qw3Hrk6UvHGQKldsQKgbVcIBT/YBrt85YberiYIPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/types@0.102.0': resolution: {integrity: sha512-8Skrw405g+/UJPKWJ1twIk3BIH2nXdiVlVNtYT23AXVwpsd79es4K+KYt06Fbnkc5BaTvk/COT2JuCLYdwnCdA==} + '@oxc-project/types@0.124.0': + resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxc-project/types@0.94.0': resolution: {integrity: sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.19.1': + resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.19.1': + resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.19.1': + resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.19.1': + resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.19.1': + resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} + cpu: [ia32] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} + cpu: [x64] + os: [win32] + '@oxc-transform/binding-android-arm64@0.102.0': resolution: {integrity: sha512-JLBT7EiExsGmB6LuBBnm6qTfg0rLSxBU+F7xjqy6UXYpL7zhqelGJL7IAq6Pu5UYFT55zVlXXmgzLOXQfpQjXA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8481,6 +8592,22 @@ packages: '@rrweb/utils@2.0.0-alpha.20': resolution: {integrity: sha512-MTQOmhPRe39C0fYaCnnVYOufQsyGzwNXpUStKiyFSfGLUJrzuwhbRoUAKR5w6W2j5XuA0bIz3ZDIBztkquOhLw==} + '@scalar/helpers@0.4.3': + resolution: {integrity: sha512-Gv2V7SFreLx3DltzF2lKXdaJSH5cP1LOyt9PxON1cSWGxkrs3sg93c1taEJsW24E9ckfYXkL5hjCAVLfAN3wQw==} + engines: {node: '>=22'} + + '@scalar/json-magic@0.12.5': + resolution: {integrity: sha512-MkGOjodEeQ7V7M78W6Oq+t3q1LaUR+SRLZLqFbU6s26Gc+12T+v89JXcHvd+3ug0xFVMg/kdczZ3O6miBhyNsA==} + engines: {node: '>=22'} + + '@scalar/openapi-types@0.7.0': + resolution: {integrity: sha512-kN0PwlJW0de4bwQ4ib+mBHzKJUvBCyR/gwU4zLEq6SCbj+GfgYUh+2a0/yl1WYVUiSkkwFsHjfmQ8KjhR3HK0Q==} + engines: {node: '>=22'} + + '@scalar/openapi-upgrader@0.2.4': + resolution: {integrity: sha512-AcrF7BMxKCTHnT82SHbHun6dJO4XC9tS5gD7EJsr/7YwFkx9JtbtZCryJXtqWJ5c7i1v1KH4PRRjDga/hCULTQ==} + engines: {node: '>=22'} + '@segment/loosely-validate-event@2.0.0': resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==} @@ -8562,62 +8689,56 @@ packages: '@shikijs/core@3.17.0': resolution: {integrity: sha512-/HjeOnbc62C+n33QFNFrAhUlIADKwfuoS50Ht0pxujxP4QjZAlFp5Q+OkDo531SCTzivx5T18khwyBdKoPdkuw==} - '@shikijs/core@3.19.0': - resolution: {integrity: sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA==} - - '@shikijs/core@3.3.0': - resolution: {integrity: sha512-CovkFL2WVaHk6PCrwv6ctlmD4SS1qtIfN8yEyDXDYWh4ONvomdM9MaFw20qHuqJOcb8/xrkqoWQRJ//X10phOQ==} + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} '@shikijs/engine-javascript@3.17.0': resolution: {integrity: sha512-WwF99xdP8KfuDrIbT4wxyypfhoIxMeeOCp1AiuvzzZ6JT5B3vIuoclL8xOuuydA6LBeeNXUF/XV5zlwwex1jlA==} - '@shikijs/engine-javascript@3.19.0': - resolution: {integrity: sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ==} - - '@shikijs/engine-javascript@3.3.0': - resolution: {integrity: sha512-XlhnFGv0glq7pfsoN0KyBCz9FJU678LZdQ2LqlIdAj6JKsg5xpYKay3DkazXWExp3DTJJK9rMOuGzU2911pg7Q==} + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} '@shikijs/engine-oniguruma@3.17.0': resolution: {integrity: sha512-flSbHZAiOZDNTrEbULY8DLWavu/TyVu/E7RChpLB4WvKX4iHMfj80C6Hi3TjIWaQtHOW0KC6kzMcuB5TO1hZ8Q==} - '@shikijs/engine-oniguruma@3.19.0': - resolution: {integrity: sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg==} - - '@shikijs/engine-oniguruma@3.3.0': - resolution: {integrity: sha512-l0vIw+GxeNU7uGnsu6B+Crpeqf+WTQ2Va71cHb5ZYWEVEPdfYwY5kXwYqRJwHrxz9WH+pjSpXQz+TJgAsrkA5A==} + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} '@shikijs/langs@3.17.0': resolution: {integrity: sha512-icmur2n5Ojb+HAiQu6NEcIIJ8oWDFGGEpiqSCe43539Sabpx7Y829WR3QuUW2zjTM4l6V8Sazgb3rrHO2orEAw==} - '@shikijs/langs@3.19.0': - resolution: {integrity: sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg==} + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} - '@shikijs/langs@3.3.0': - resolution: {integrity: sha512-zt6Kf/7XpBQKSI9eqku+arLkAcDQ3NHJO6zFjiChI8w0Oz6Jjjay7pToottjQGjSDCFk++R85643WbyINcuL+g==} + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} - '@shikijs/rehype@3.19.0': - resolution: {integrity: sha512-pzp/JVxrTd95HgMimHgYb9lCGSzVYEp1BweWUprFAEgGOF15d9IyX+IVW/+1Z5ZxdT9IUUF27UbC5YdA5oCzjw==} + '@shikijs/rehype@4.0.2': + resolution: {integrity: sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA==} + engines: {node: '>=20'} '@shikijs/themes@3.17.0': resolution: {integrity: sha512-/xEizMHLBmMHwtx4JuOkRf3zwhWD2bmG5BRr0IPjpcWpaq4C3mYEuTk/USAEglN0qPrTwEHwKVpSu/y2jhferA==} - '@shikijs/themes@3.19.0': - resolution: {integrity: sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A==} - - '@shikijs/themes@3.3.0': - resolution: {integrity: sha512-tXeCvLXBnqq34B0YZUEaAD1lD4lmN6TOHAhnHacj4Owh7Ptb/rf5XCDeROZt2rEOk5yuka3OOW2zLqClV7/SOg==} + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} - '@shikijs/transformers@3.19.0': - resolution: {integrity: sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw==} + '@shikijs/transformers@4.0.2': + resolution: {integrity: sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg==} + engines: {node: '>=20'} '@shikijs/types@3.17.0': resolution: {integrity: sha512-wjLVfutYWVUnxAjsWEob98xgyaGv0dTEnMZDruU5mRjVN7szcGOfgO+997W2yR6odp+1PtSBNeSITRRTfUzK/g==} - '@shikijs/types@3.19.0': - resolution: {integrity: sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ==} - - '@shikijs/types@3.3.0': - resolution: {integrity: sha512-KPCGnHG6k06QG/2pnYGbFtFvpVJmC3uIpXrAiPrawETifujPBv0Se2oUxm5qYgjCvGJS9InKvjytOdN+bGuX+Q==} + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -8935,6 +9056,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -9696,6 +9820,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@8.5.9': resolution: {integrity: sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==} @@ -9948,6 +10075,10 @@ packages: '@types/yargs@17.0.32': resolution: {integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==} + '@typescript-eslint/types@8.58.1': + resolution: {integrity: sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -10263,9 +10394,6 @@ packages: ajv: optional: true - ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} - ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -12126,11 +12254,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.1: - resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -12171,6 +12294,9 @@ packages: engines: {node: '>=4'} hasBin: true + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + estree-util-attach-comments@3.0.0: resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} @@ -12343,6 +12469,9 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-content-type-parse@3.0.0: + resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -12401,6 +12530,9 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + fast-xml-parser@4.3.4: resolution: {integrity: sha512-utnwm92SyozgA3hhH2I8qldf2lBqm6qHOICawRNRFu1qMe3+oqr+GcXjGqTmXTMGE5T4eC03kr/rlh5C1IRdZA==} hasBin: true @@ -12409,6 +12541,10 @@ packages: resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} hasBin: true + fast-xml-parser@5.5.10: + resolution: {integrity: sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==} + hasBin: true + fastify-metrics@12.1.0: resolution: {integrity: sha512-EpbT+W1jm8kMbkCPvfW4j23y3BZlXGOcO6+75EFTKDxbJIyXbldrFIoVoP0oD4CsqrKeIARvrOjbZNqK5MdRwQ==} peerDependencies: @@ -12593,6 +12729,9 @@ packages: for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + foreach@2.0.6: + resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==} + foreground-child@3.1.1: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} @@ -12669,6 +12808,20 @@ packages: react-dom: optional: true + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + freeport-async@2.0.0: resolution: {integrity: sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==} engines: {node: '>=8'} @@ -12708,31 +12861,53 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - fumadocs-core@16.2.2: - resolution: {integrity: sha512-CMU/jp/Gb6lr/qvRrTMRv1FX2VuAixHaqop4yguCwKt/iqkgJP4MJ2SpXcFheSUraJ2hIgDyYVoXIK1onKqagw==} + fuma-cli@0.0.3: + resolution: {integrity: sha512-DefY1l9+PdairAjOeMfHMCHwkP9kRo7pqqpqb2qFpjb3Vxa8XIpPVVrFGR+9eMfE7Bp6rxsg8NAE3XRPLMWiDA==} + + fumadocs-core@16.7.11: + resolution: {integrity: sha512-v09UEizAi7NfqwYEq2hvDimicj6ZX7xBYcOjp3vGwXZr9Vn/S2bI76HM6YWr38DmIcj1ed8zFTf1lWJsJRYN+w==} peerDependencies: - '@mixedbread/sdk': ^0.19.0 + '@mdx-js/mdx': '*' + '@mixedbread/sdk': ^0.46.0 '@orama/core': 1.x.x + '@oramacloud/client': 2.x.x '@tanstack/react-router': 1.x.x + '@types/estree-jsx': '*' + '@types/hast': '*' + '@types/mdast': '*' '@types/react': '*' algoliasearch: 5.x.x + flexsearch: '*' lucide-react: '*' next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 react-router: 7.x.x - waku: ^0.26.0 || ^0.27.0 + waku: ^0.26.0 || ^0.27.0 || ^1.0.0 + zod: 4.x.x peerDependenciesMeta: + '@mdx-js/mdx': + optional: true '@mixedbread/sdk': optional: true '@orama/core': optional: true + '@oramacloud/client': + optional: true '@tanstack/react-router': optional: true + '@types/estree-jsx': + optional: true + '@types/hast': + optional: true + '@types/mdast': + optional: true '@types/react': optional: true algoliasearch: optional: true + flexsearch: + optional: true lucide-react: optional: true next: @@ -12745,19 +12920,33 @@ packages: optional: true waku: optional: true + zod: + optional: true - fumadocs-mdx@14.0.4: - resolution: {integrity: sha512-q8g/cnFByFkdxvkUgHLsn7QrT4uHY3XkBFd5YJrbpI8cxlV8v64lS6Yrkmu/gigiuvLkysZN6zXVVIbdZcoZvw==} + fumadocs-mdx@14.2.11: + resolution: {integrity: sha512-j0gHKs45c62ARteE8/yBM2Nu2I8AE2Cs37ktPEdc/8EX7TL66XP74un5OpHp6itLyWTu8Jur0imOiiIDq8+rDg==} hasBin: true peerDependencies: '@fumadocs/mdx-remote': ^1.4.0 + '@types/mdast': '*' + '@types/mdx': '*' + '@types/react': '*' fumadocs-core: ^15.0.0 || ^16.0.0 + mdast-util-directive: '*' next: ^15.3.0 || ^16.0.0 react: '*' - vite: 6.x.x || 7.x.x + vite: 6.x.x || 7.x.x || 8.x.x peerDependenciesMeta: '@fumadocs/mdx-remote': optional: true + '@types/mdast': + optional: true + '@types/mdx': + optional: true + '@types/react': + optional: true + mdast-util-directive: + optional: true next: optional: true react: @@ -12765,20 +12954,48 @@ packages: vite: optional: true - fumadocs-ui@16.2.2: - resolution: {integrity: sha512-qYvPbVRMMFiuzrsmvGYpEj/cT5XyGzvwrrRklrHPMegywY+jxQ0TUeRKHzQgxkkTl0MDPnejRbHHAfafz01/TQ==} + fumadocs-openapi@10.6.7: + resolution: {integrity: sha512-WxjX0U6SG4PFDCSwUkuzA8LQ2X+Ou5vYA2RtE4t4i9oXstQAZKTV4llA4BI2ZbU+QVjzbzDXtG0HIykYIembgg==} + peerDependencies: + '@scalar/api-client-react': '*' + '@types/react': '*' + fumadocs-core: ^16.7.0 + fumadocs-ui: ^16.7.0 + json-schema-typed: '*' + react: ^19.2.0 + react-dom: ^19.2.0 + shiki: '*' + peerDependenciesMeta: + '@scalar/api-client-react': + optional: true + '@types/react': + optional: true + json-schema-typed: + optional: true + shiki: + optional: true + + fumadocs-ui@16.7.11: + resolution: {integrity: sha512-vEo4bGuWhhM3BBX/vRYDSpF666WJ+EbPke50LgdAdPlQUstRsvkOjWkta/GA9Vggph+aPKSk0AK8isJiMu4t8Q==} peerDependencies: + '@takumi-rs/image-response': '*' + '@types/mdx': '*' '@types/react': '*' + fumadocs-core: 16.7.11 next: 16.x.x react: ^19.2.0 react-dom: ^19.2.0 - tailwindcss: ^4.0.0 + shiki: '*' peerDependenciesMeta: + '@takumi-rs/image-response': + optional: true + '@types/mdx': + optional: true '@types/react': optional: true next: optional: true - tailwindcss: + shiki: optional: true function-bind@1.1.2: @@ -13086,9 +13303,6 @@ packages: hast-util-to-estree@3.1.3: resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} - hast-util-to-html@9.0.3: - resolution: {integrity: sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==} - hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -13810,6 +14024,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-pointer@0.6.2: + resolution: {integrity: sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==} + json-schema-deref-sync@0.13.0: resolution: {integrity: sha512-YBOEogm5w9Op337yb6pAT6ZXDqlxAsQCanM3grid8lMWNxRJO/zWEJi3ZzqDL8boWfwhTFym5EFrNgWwpqcBRg==} engines: {node: '>=6.0.0'} @@ -14276,6 +14493,11 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.7.0: + resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} @@ -14408,9 +14630,6 @@ packages: mdast-util-to-hast@13.1.0: resolution: {integrity: sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==} - mdast-util-to-markdown@2.1.1: - resolution: {integrity: sha512-OrkcCoqAkEg9b1ykXBrA0ehRc8H4fGU/03cACmW2xXzau1+dIdS+qJugh1Cqex3hMumSBgSE/5pc7uqP12nLAw==} - mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -14826,12 +15045,18 @@ packages: motion-dom@12.23.23: resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + motion-utils@11.18.1: resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} motion-utils@12.23.6: resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + motion@11.18.2: resolution: {integrity: sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg==} peerDependencies: @@ -14846,6 +15071,20 @@ packages: react-dom: optional: true + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -15254,15 +15493,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} - oniguruma-parser@0.12.0: - resolution: {integrity: sha512-fD9o5ebCmEAA9dLysajdQvuKzLL7cj+w7DQjuO3Cb6IwafENfx6iL+RGkmyW82pVRsvgzixsWinHvgxTMJvdIA==} - oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} - oniguruma-to-es@4.3.1: - resolution: {integrity: sha512-VtX1kepWO+7HG7IWV5v72JhiqofK7XsiHmtgnvurnNOTdIvE5mrdWYtsOrQyrXCv1L2Ckm08hywp+MFO7rC4Ug==} - oniguruma-to-es@4.3.4: resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} @@ -15282,6 +15515,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-sampler@1.7.2: + resolution: {integrity: sha512-OKytvqB5XIaTgA9xtw8W8UTar+uymW2xPVpFN0NihMtuHPdPTGxBEhGnfFnJW5g/gOSIvkP+H0Xh3XhVI9/n7g==} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -15317,6 +15553,13 @@ packages: resolution: {integrity: sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.124.0: + resolution: {integrity: sha512-h07SFj/tp2U3cf3+LFX6MmOguQiM9ahwpGs0ZK5CGhgL8p4kk24etrJKsEzhXAvo7mfvoKTZooZ5MLKAPRmJ1g==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.19.1: + resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + oxc-transform@0.102.0: resolution: {integrity: sha512-MR5ohiBS6/kvxRpmUZ3LIDTTJBEC4xLAEZXfYr7vrA0eP7WHewQaNQPFDgT4Bee89TdmVQ5ZKrifGwxLjSyHHw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -15392,6 +15635,9 @@ packages: package-manager-detector@1.2.0: resolution: {integrity: sha512-PutJepsOtsqVfUsxCzgTTpyXmiAgvKptIgY4th5eq5UXXFhj5PxfQ9hnGkypMeovpAvVshFRItoFHYO18TCOqA==} + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -15461,6 +15707,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.4.0: + resolution: {integrity: sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -15504,6 +15754,9 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -15567,6 +15820,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -16149,6 +16406,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 + react-hook-form@7.72.1: + resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-in-viewport@1.0.0-beta.8: resolution: {integrity: sha512-vcQLHOBNHdbB9sIdtDW6LnbGPlY+WDDYbv/uWL3x/Fk61vMK1ugw1cwDwo9hpD4oNF6SAaToXLbMJ8l8KOntXQ==} peerDependencies: @@ -16173,8 +16436,8 @@ packages: '@types/react': '>=18' react: '>=18' - react-medium-image-zoom@5.4.0: - resolution: {integrity: sha512-BsE+EnFVQzFIlyuuQrZ9iTwyKpKkqdFZV1ImEQN573QPqGrIUuNni7aF+sZwDcxlsuOMayCr6oO/PZR/yJnbRg==} + react-medium-image-zoom@5.4.3: + resolution: {integrity: sha512-cDIwdn35fRUPsGnnj/cG6Pacll+z+Mfv6EWU2wDO5ngbZjg5uLRb2ZhEnh92ufbXCJDFvXHekb8G3+oKqUcv5g==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -16225,16 +16488,6 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-remove-scroll-bar@2.3.6: - resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -16265,6 +16518,16 @@ packages: '@types/react': optional: true + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-resizable@3.0.5: resolution: {integrity: sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==} peerDependencies: @@ -16311,16 +16574,6 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-style-singleton@2.2.1: - resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -17028,11 +17281,9 @@ packages: shiki@3.17.0: resolution: {integrity: sha512-lUZfWsyW7czITYTdo/Tb6ZM4VfyXlzmKYBQBjTz+pBzPPkP08RgIt00Ls1Z50Cl3SfwJsue6WbJeF3UgqLVI9Q==} - shiki@3.19.0: - resolution: {integrity: sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA==} - - shiki@3.3.0: - resolution: {integrity: sha512-j0Z1tG5vlOFGW8JVj0Cpuatzvshes7VJy5ncDmmMaYcmnGW0Js1N81TOW98ivTFNZfKRn9uwEg/aIm638o368g==} + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} @@ -17358,6 +17609,9 @@ packages: strnum@2.1.2: resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} + structured-clone-es@1.0.0: resolution: {integrity: sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==} @@ -17479,6 +17733,9 @@ packages: tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwindcss-animate@1.0.7: resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} peerDependencies: @@ -17622,6 +17879,10 @@ packages: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} + engines: {node: '>=18'} + tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} @@ -17630,6 +17891,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@0.8.4: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} @@ -18068,6 +18333,9 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -18316,9 +18584,6 @@ packages: uqr@0.1.2: resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - url-join@4.0.0: resolution: {integrity: sha512-EGXjXJZhIHiQMK2pQukuFcL303nskqIRzWvPvV5O8miOfwoUb9G+a/Cld60kUyeaybEI94wvVClT10DtfeAExA==} @@ -18332,16 +18597,6 @@ packages: urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} - use-callback-ref@1.3.1: - resolution: {integrity: sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -18358,16 +18613,6 @@ packages: peerDependencies: react: '*' - use-sidecar@1.1.2: - resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -19021,6 +19266,10 @@ packages: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -19184,6 +19433,9 @@ packages: zod@4.1.13: resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -22370,9 +22622,6 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true - '@esbuild/aix-ppc64@0.27.1': - optional: true - '@esbuild/aix-ppc64@0.27.3': optional: true @@ -22397,9 +22646,6 @@ snapshots: '@esbuild/android-arm64@0.27.0': optional: true - '@esbuild/android-arm64@0.27.1': - optional: true - '@esbuild/android-arm64@0.27.3': optional: true @@ -22424,9 +22670,6 @@ snapshots: '@esbuild/android-arm@0.27.0': optional: true - '@esbuild/android-arm@0.27.1': - optional: true - '@esbuild/android-arm@0.27.3': optional: true @@ -22451,9 +22694,6 @@ snapshots: '@esbuild/android-x64@0.27.0': optional: true - '@esbuild/android-x64@0.27.1': - optional: true - '@esbuild/android-x64@0.27.3': optional: true @@ -22478,9 +22718,6 @@ snapshots: '@esbuild/darwin-arm64@0.27.0': optional: true - '@esbuild/darwin-arm64@0.27.1': - optional: true - '@esbuild/darwin-arm64@0.27.3': optional: true @@ -22505,9 +22742,6 @@ snapshots: '@esbuild/darwin-x64@0.27.0': optional: true - '@esbuild/darwin-x64@0.27.1': - optional: true - '@esbuild/darwin-x64@0.27.3': optional: true @@ -22532,9 +22766,6 @@ snapshots: '@esbuild/freebsd-arm64@0.27.0': optional: true - '@esbuild/freebsd-arm64@0.27.1': - optional: true - '@esbuild/freebsd-arm64@0.27.3': optional: true @@ -22559,9 +22790,6 @@ snapshots: '@esbuild/freebsd-x64@0.27.0': optional: true - '@esbuild/freebsd-x64@0.27.1': - optional: true - '@esbuild/freebsd-x64@0.27.3': optional: true @@ -22586,9 +22814,6 @@ snapshots: '@esbuild/linux-arm64@0.27.0': optional: true - '@esbuild/linux-arm64@0.27.1': - optional: true - '@esbuild/linux-arm64@0.27.3': optional: true @@ -22613,9 +22838,6 @@ snapshots: '@esbuild/linux-arm@0.27.0': optional: true - '@esbuild/linux-arm@0.27.1': - optional: true - '@esbuild/linux-arm@0.27.3': optional: true @@ -22640,9 +22862,6 @@ snapshots: '@esbuild/linux-ia32@0.27.0': optional: true - '@esbuild/linux-ia32@0.27.1': - optional: true - '@esbuild/linux-ia32@0.27.3': optional: true @@ -22667,9 +22886,6 @@ snapshots: '@esbuild/linux-loong64@0.27.0': optional: true - '@esbuild/linux-loong64@0.27.1': - optional: true - '@esbuild/linux-loong64@0.27.3': optional: true @@ -22694,9 +22910,6 @@ snapshots: '@esbuild/linux-mips64el@0.27.0': optional: true - '@esbuild/linux-mips64el@0.27.1': - optional: true - '@esbuild/linux-mips64el@0.27.3': optional: true @@ -22721,9 +22934,6 @@ snapshots: '@esbuild/linux-ppc64@0.27.0': optional: true - '@esbuild/linux-ppc64@0.27.1': - optional: true - '@esbuild/linux-ppc64@0.27.3': optional: true @@ -22748,9 +22958,6 @@ snapshots: '@esbuild/linux-riscv64@0.27.0': optional: true - '@esbuild/linux-riscv64@0.27.1': - optional: true - '@esbuild/linux-riscv64@0.27.3': optional: true @@ -22775,9 +22982,6 @@ snapshots: '@esbuild/linux-s390x@0.27.0': optional: true - '@esbuild/linux-s390x@0.27.1': - optional: true - '@esbuild/linux-s390x@0.27.3': optional: true @@ -22802,9 +23006,6 @@ snapshots: '@esbuild/linux-x64@0.27.0': optional: true - '@esbuild/linux-x64@0.27.1': - optional: true - '@esbuild/linux-x64@0.27.3': optional: true @@ -22820,9 +23021,6 @@ snapshots: '@esbuild/netbsd-arm64@0.27.0': optional: true - '@esbuild/netbsd-arm64@0.27.1': - optional: true - '@esbuild/netbsd-arm64@0.27.3': optional: true @@ -22847,9 +23045,6 @@ snapshots: '@esbuild/netbsd-x64@0.27.0': optional: true - '@esbuild/netbsd-x64@0.27.1': - optional: true - '@esbuild/netbsd-x64@0.27.3': optional: true @@ -22865,9 +23060,6 @@ snapshots: '@esbuild/openbsd-arm64@0.27.0': optional: true - '@esbuild/openbsd-arm64@0.27.1': - optional: true - '@esbuild/openbsd-arm64@0.27.3': optional: true @@ -22892,9 +23084,6 @@ snapshots: '@esbuild/openbsd-x64@0.27.0': optional: true - '@esbuild/openbsd-x64@0.27.1': - optional: true - '@esbuild/openbsd-x64@0.27.3': optional: true @@ -22904,9 +23093,6 @@ snapshots: '@esbuild/openharmony-arm64@0.27.0': optional: true - '@esbuild/openharmony-arm64@0.27.1': - optional: true - '@esbuild/openharmony-arm64@0.27.3': optional: true @@ -22931,9 +23117,6 @@ snapshots: '@esbuild/sunos-x64@0.27.0': optional: true - '@esbuild/sunos-x64@0.27.1': - optional: true - '@esbuild/sunos-x64@0.27.3': optional: true @@ -22958,9 +23141,6 @@ snapshots: '@esbuild/win32-arm64@0.27.0': optional: true - '@esbuild/win32-arm64@0.27.1': - optional: true - '@esbuild/win32-arm64@0.27.3': optional: true @@ -22985,9 +23165,6 @@ snapshots: '@esbuild/win32-ia32@0.27.0': optional: true - '@esbuild/win32-ia32@0.27.1': - optional: true - '@esbuild/win32-ia32@0.27.3': optional: true @@ -23012,9 +23189,6 @@ snapshots: '@esbuild/win32-x64@0.27.0': optional: true - '@esbuild/win32-x64@0.27.1': - optional: true - '@esbuild/win32-x64@0.27.3': optional: true @@ -23326,8 +23500,8 @@ snapshots: '@fastify/ajv-compiler@4.0.2': dependencies: - ajv: 8.12.0 - ajv-formats: 3.0.1(ajv@8.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) fast-uri: 3.0.6 '@fastify/compress@8.1.0': @@ -23351,6 +23525,8 @@ snapshots: fastify-plugin: 5.0.1 toad-cache: 3.7.0 + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.0.0': {} '@fastify/fast-json-stringify-compiler@5.0.2': @@ -23435,9 +23611,30 @@ snapshots: '@floating-ui/utils@0.2.1': {} - '@formatjs/intl-localematcher@0.6.2': + '@formatjs/fast-memoize@3.1.1': {} + + '@formatjs/intl-localematcher@0.8.2': dependencies: - tslib: 2.8.1 + '@formatjs/fast-memoize': 3.1.1 + + '@fumadocs/tailwind@0.0.3(tailwindcss@4.1.17)': + dependencies: + postcss-selector-parser: 7.1.1 + optionalDependencies: + tailwindcss: 4.1.17 + + '@fumari/json-schema-ts@0.0.2(json-schema-typed@8.0.2)': + dependencies: + esrap: 2.2.4 + optionalDependencies: + json-schema-typed: 8.0.2 + + '@fumari/stf@1.0.4(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 '@gar/promisify@1.1.3': {} @@ -23899,7 +24096,7 @@ snapshots: unified: 11.0.5 unist-util-position-from-estree: 2.0.0 unist-util-stringify-position: 4.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 transitivePeerDependencies: - supports-color @@ -23958,6 +24155,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + '@next/env@15.0.3': {} '@next/env@15.0.4': {} @@ -24553,7 +24757,7 @@ snapshots: consola: 3.4.2 cssnano: 7.1.2(postcss@8.5.6) defu: 6.1.4 - esbuild: 0.27.1 + esbuild: 0.27.3 escape-string-regexp: 5.0.0 exsolve: 1.0.8 get-port-please: 3.2.0 @@ -25586,7 +25790,7 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@orama/orama@3.1.16': {} + '@orama/orama@3.1.18': {} '@oslojs/asn1@1.0.0': dependencies: @@ -25654,57 +25858,189 @@ snapshots: '@oxc-minify/binding-win32-x64-msvc@0.102.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.124.0': + optional: true + '@oxc-parser/binding-android-arm64@0.102.0': optional: true + '@oxc-parser/binding-android-arm64@0.124.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.102.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.124.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.102.0': optional: true + '@oxc-parser/binding-darwin-x64@0.124.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.102.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.124.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.102.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.124.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.124.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.102.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.124.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.124.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.124.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.102.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.124.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.102.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.124.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.102.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.124.0': + optional: true + '@oxc-parser/binding-wasm32-wasi@0.102.0': dependencies: '@napi-rs/wasm-runtime': 1.1.0 optional: true + '@oxc-parser/binding-wasm32-wasi@0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.102.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.124.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.124.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.102.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.124.0': + optional: true + '@oxc-project/types@0.102.0': {} + '@oxc-project/types@0.124.0': {} + '@oxc-project/types@0.94.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + optional: true + + '@oxc-resolver/binding-android-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + optional: true + '@oxc-transform/binding-android-arm64@0.102.0': optional: true @@ -27637,6 +27973,20 @@ snapshots: '@rrweb/utils@2.0.0-alpha.20': {} + '@scalar/helpers@0.4.3': {} + + '@scalar/json-magic@0.12.5': + dependencies: + '@scalar/helpers': 0.4.3 + pathe: 2.0.3 + yaml: 2.8.2 + + '@scalar/openapi-types@0.7.0': {} + + '@scalar/openapi-upgrader@0.2.4': + dependencies: + '@scalar/openapi-types': 0.7.0 + '@segment/loosely-validate-event@2.0.0': dependencies: component-type: 1.2.2 @@ -27774,16 +28124,10 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 - '@shikijs/core@3.19.0': + '@shikijs/core@4.0.2': dependencies: - '@shikijs/types': 3.19.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - - '@shikijs/core@3.3.0': - dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 @@ -27794,82 +28138,64 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.4 - '@shikijs/engine-javascript@3.19.0': + '@shikijs/engine-javascript@4.0.2': dependencies: - '@shikijs/types': 3.19.0 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.4 - '@shikijs/engine-javascript@3.3.0': - dependencies: - '@shikijs/types': 3.3.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.1 - '@shikijs/engine-oniguruma@3.17.0': dependencies: '@shikijs/types': 3.17.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/engine-oniguruma@3.19.0': + '@shikijs/engine-oniguruma@4.0.2': dependencies: - '@shikijs/types': 3.19.0 - '@shikijs/vscode-textmate': 10.0.2 - - '@shikijs/engine-oniguruma@3.3.0': - dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 '@shikijs/langs@3.17.0': dependencies: '@shikijs/types': 3.17.0 - '@shikijs/langs@3.19.0': + '@shikijs/langs@4.0.2': dependencies: - '@shikijs/types': 3.19.0 + '@shikijs/types': 4.0.2 - '@shikijs/langs@3.3.0': + '@shikijs/primitive@4.0.2': dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 - '@shikijs/rehype@3.19.0': + '@shikijs/rehype@4.0.2': dependencies: - '@shikijs/types': 3.19.0 + '@shikijs/types': 4.0.2 '@types/hast': 3.0.4 hast-util-to-string: 3.0.1 - shiki: 3.19.0 + shiki: 4.0.2 unified: 11.0.5 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 '@shikijs/themes@3.17.0': dependencies: '@shikijs/types': 3.17.0 - '@shikijs/themes@3.19.0': - dependencies: - '@shikijs/types': 3.19.0 - - '@shikijs/themes@3.3.0': + '@shikijs/themes@4.0.2': dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 4.0.2 - '@shikijs/transformers@3.19.0': + '@shikijs/transformers@4.0.2': dependencies: - '@shikijs/core': 3.19.0 - '@shikijs/types': 3.19.0 + '@shikijs/core': 4.0.2 + '@shikijs/types': 4.0.2 '@shikijs/types@3.17.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - '@shikijs/types@3.19.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/types@3.3.0': + '@shikijs/types@4.0.2': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -28368,6 +28694,8 @@ snapshots: '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@swc/counter@0.1.3': {} @@ -29324,6 +29652,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@8.5.9': dependencies: '@types/node': 20.19.24 @@ -29620,6 +29950,8 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@typescript-eslint/types@8.58.1': {} + '@ungap/structured-clone@1.2.0': {} '@unhead/vue@2.0.19(vue@3.5.25(typescript@5.9.3))': @@ -30046,21 +30378,10 @@ snapshots: optionalDependencies: react: 19.2.3 - ajv-formats@3.0.1(ajv@8.12.0): - optionalDependencies: - ajv: 8.12.0 - ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: ajv: 8.18.0 - ajv@8.12.0: - dependencies: - fast-deep-equal: 3.1.3 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - uri-js: 4.4.1 - ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 @@ -30286,7 +30607,7 @@ snapshots: prompts: 2.4.2 rehype: 13.0.2 semver: 7.7.1 - shiki: 3.3.0 + shiki: 3.17.0 tinyexec: 0.3.2 tinyglobby: 0.2.13 tsconfck: 3.1.5(typescript@5.9.3) @@ -31968,7 +32289,9 @@ snapshots: dset@3.1.4: {} - dts-resolver@2.1.2: {} + dts-resolver@2.1.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)): + optionalDependencies: + oxc-resolver: 11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) dunder-proto@1.0.1: dependencies: @@ -32458,35 +32781,6 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 - esbuild@0.27.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.1 - '@esbuild/android-arm': 0.27.1 - '@esbuild/android-arm64': 0.27.1 - '@esbuild/android-x64': 0.27.1 - '@esbuild/darwin-arm64': 0.27.1 - '@esbuild/darwin-x64': 0.27.1 - '@esbuild/freebsd-arm64': 0.27.1 - '@esbuild/freebsd-x64': 0.27.1 - '@esbuild/linux-arm': 0.27.1 - '@esbuild/linux-arm64': 0.27.1 - '@esbuild/linux-ia32': 0.27.1 - '@esbuild/linux-loong64': 0.27.1 - '@esbuild/linux-mips64el': 0.27.1 - '@esbuild/linux-ppc64': 0.27.1 - '@esbuild/linux-riscv64': 0.27.1 - '@esbuild/linux-s390x': 0.27.1 - '@esbuild/linux-x64': 0.27.1 - '@esbuild/netbsd-arm64': 0.27.1 - '@esbuild/netbsd-x64': 0.27.1 - '@esbuild/openbsd-arm64': 0.27.1 - '@esbuild/openbsd-x64': 0.27.1 - '@esbuild/openharmony-arm64': 0.27.1 - '@esbuild/sunos-x64': 0.27.1 - '@esbuild/win32-arm64': 0.27.1 - '@esbuild/win32-ia32': 0.27.1 - '@esbuild/win32-x64': 0.27.1 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -32534,6 +32828,11 @@ snapshots: esprima@4.0.1: {} + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.58.1 + estree-util-attach-comments@3.0.0: dependencies: '@types/estree': 1.0.8 @@ -32895,6 +33194,8 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-content-type-parse@3.0.0: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@2.0.1: {} @@ -32972,6 +33273,10 @@ snapshots: fast-uri@3.0.6: {} + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.4.0 + fast-xml-parser@4.3.4: dependencies: strnum: 1.0.5 @@ -32980,6 +33285,12 @@ snapshots: dependencies: strnum: 2.1.2 + fast-xml-parser@5.5.10: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.4.0 + strnum: 2.2.3 + fastify-metrics@12.1.0(fastify@5.6.1): dependencies: fastify: 5.6.1 @@ -33064,6 +33375,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fecha@4.2.3: {} fetch-retry@4.1.1: {} @@ -33220,6 +33535,8 @@ snapshots: dependencies: is-callable: 1.2.7 + foreach@2.0.6: {} + foreground-child@3.1.1: dependencies: cross-spawn: 7.0.6 @@ -33293,6 +33610,16 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + framer-motion@12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + freeport-async@2.0.0: {} fresh@0.5.2: {} @@ -33330,67 +33657,131 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + fuma-cli@0.0.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1): dependencies: - '@formatjs/intl-localematcher': 0.6.2 - '@orama/orama': 3.1.16 - '@shikijs/rehype': 3.19.0 - '@shikijs/transformers': 3.19.0 + magic-string: 0.30.21 + oxc-parser: 0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + oxc-resolver: 11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + tinyexec: 1.1.1 + zod: 4.3.6 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13): + dependencies: + '@formatjs/intl-localematcher': 0.8.2 + '@orama/orama': 3.1.18 + '@shikijs/rehype': 4.0.2 + '@shikijs/transformers': 4.0.2 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 hast-util-to-estree: 3.1.3 hast-util-to-jsx-runtime: 2.3.6 image-size: 2.0.2 + mdast-util-mdx: 3.0.0 + mdast-util-to-markdown: 2.1.2 negotiator: 1.0.0 npm-to-yarn: 3.0.1 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 remark: 15.0.1 remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 3.19.0 - unist-util-visit: 5.0.0 + shiki: 4.0.2 + tinyglobby: 0.2.16 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 optionalDependencies: + '@mdx-js/mdx': 3.1.1 '@tanstack/react-router': 1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 '@types/react': 19.2.7 lucide-react: 0.555.0(react@19.2.3) next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) react-router: 7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + zod: 4.1.13 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.0.4(fumadocs-core@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)): + fumadocs-mdx@14.2.11(@types/mdast@4.0.4)(@types/mdx@2.0.13)(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2)): dependencies: '@mdx-js/mdx': 3.1.1 - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 - esbuild: 0.27.0 + esbuild: 0.27.3 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) js-yaml: 4.1.1 - lru-cache: 11.2.2 + mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 picocolors: 1.1.1 picomatch: 4.0.3 - remark-mdx: 3.1.1 - tinyexec: 1.0.2 + tinyexec: 1.1.1 tinyglobby: 0.2.15 unified: 11.0.5 unist-util-remove-position: 5.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 - zod: 4.1.13 + zod: 4.3.6 optionalDependencies: + '@types/mdast': 4.0.4 + '@types/mdx': 2.0.13 + '@types/react': 19.2.7 next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 vite: 7.3.0(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.27.1)(tsx@4.20.5)(yaml@2.8.2) transitivePeerDependencies: - supports-color - fumadocs-ui@16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(tailwindcss@4.1.17): + fumadocs-openapi@10.6.7(20b279af7e6f0aee6451333d6680f64b): + dependencies: + '@fastify/deepmerge': 3.2.1 + '@fumari/json-schema-ts': 0.0.2(json-schema-typed@8.0.2) + '@fumari/stf': 1.0.4(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) + '@scalar/json-magic': 0.12.5 + '@scalar/openapi-upgrader': 0.2.4 + ajv: 8.18.0 + chokidar: 5.0.0 + class-variance-authority: 0.7.1 + fast-content-type-parse: 3.0.0 + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) + fumadocs-ui: 16.7.11(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@emotion/is-prop-valid@0.8.8)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.0.2)(tailwindcss@4.1.17) + github-slugger: 2.0.0 + hast-util-to-jsx-runtime: 2.3.6 + js-yaml: 4.1.1 + lucide-react: 1.7.0(react@19.2.3) + next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + openapi-sampler: 1.7.2 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-hook-form: 7.72.1(react@19.2.3) + remark: 15.0.1 + remark-rehype: 11.1.2 + tailwind-merge: 3.5.0 + xml-js: 1.6.11 + optionalDependencies: + '@types/react': 19.2.7 + json-schema-typed: 8.0.2 + shiki: 4.0.2 + transitivePeerDependencies: + - '@types/react-dom' + - supports-color + + fumadocs-ui@16.7.11(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@emotion/is-prop-valid@0.8.8)(@types/mdx@2.0.13)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(fumadocs-core@16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.0.2)(tailwindcss@4.1.17): dependencies: + '@fumadocs/tailwind': 0.0.3(tailwindcss@4.1.17) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -33402,29 +33793,30 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) class-variance-authority: 0.7.1 - fumadocs-core: 16.2.2(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) - lodash.merge: 4.6.2 + fuma-cli: 0.0.3(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + fumadocs-core: 16.7.11(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.132.47(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.7)(lucide-react@0.555.0(react@19.2.3))(next@16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react-router@7.13.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(zod@4.1.13) + lucide-react: 1.7.0(react@19.2.3) + motion: 12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - postcss-selector-parser: 7.1.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-medium-image-zoom: 5.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-medium-image-zoom: 5.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.7)(react@19.2.3) + rehype-raw: 7.0.0 scroll-into-view-if-needed: 3.1.0 - tailwind-merge: 3.4.0 + tailwind-merge: 3.5.0 + unist-util-visit: 5.1.0 optionalDependencies: + '@types/mdx': 2.0.13 '@types/react': 19.2.7 next: 16.0.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - tailwindcss: 4.1.17 + shiki: 4.0.2 transitivePeerDependencies: - - '@mixedbread/sdk' - - '@orama/core' - - '@tanstack/react-router' + - '@emnapi/core' + - '@emnapi/runtime' + - '@emotion/is-prop-valid' - '@types/react-dom' - - algoliasearch - - lucide-react - - react-router - - supports-color - - waku + - tailwindcss function-bind@1.1.2: {} @@ -33795,7 +34187,7 @@ snapshots: mdast-util-to-hast: 13.1.0 parse5: 7.3.0 unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 web-namespaces: 2.0.1 zwitch: 2.0.4 @@ -33821,20 +34213,6 @@ snapshots: transitivePeerDependencies: - supports-color - hast-util-to-html@9.0.3: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.2 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 - html-void-elements: 3.0.0 - mdast-util-to-hast: 13.1.0 - property-information: 6.4.1 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.3 - zwitch: 2.0.4 - hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -34651,6 +35029,10 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-pointer@0.6.2: + dependencies: + foreach: 2.0.6 + json-schema-deref-sync@0.13.0: dependencies: clone: 2.1.2 @@ -35071,6 +35453,10 @@ snapshots: dependencies: react: 19.2.3 + lucide-react@1.7.0(react@19.2.3): + dependencies: + react: 19.2.3 + luxon@3.7.2: {} lz-string@1.5.0: {} @@ -35255,7 +35641,7 @@ snapshots: mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.1 + mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -35266,7 +35652,7 @@ snapshots: devlop: 1.1.0 longest-streak: 3.1.0 mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.1 + mdast-util-to-markdown: 2.1.2 unist-util-remove-position: 5.0.0 transitivePeerDependencies: - supports-color @@ -35337,18 +35723,6 @@ snapshots: unist-util-visit: 5.0.0 vfile: 6.0.3 - mdast-util-to-markdown@2.1.1: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.2 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.0 - micromark-util-decode-string: 2.0.0 - unist-util-visit: 5.0.0 - zwitch: 2.0.4 - mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -35358,7 +35732,7 @@ snapshots: mdast-util-to-string: 4.0.0 micromark-util-classify-character: 2.0.0 micromark-util-decode-string: 2.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 zwitch: 2.0.4 mdast-util-to-string@4.0.0: @@ -36023,10 +36397,16 @@ snapshots: dependencies: motion-utils: 12.23.6 + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + motion-utils@11.18.1: {} motion-utils@12.23.6: {} + motion-utils@12.36.0: {} + motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: framer-motion: 11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -36036,6 +36416,15 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + motion@12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + framer-motion: 12.38.0(@emotion/is-prop-valid@0.8.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + mrmime@2.0.1: {} ms@2.0.0: {} @@ -36723,16 +37112,8 @@ snapshots: dependencies: mimic-fn: 4.0.0 - oniguruma-parser@0.12.0: {} - oniguruma-parser@0.12.1: {} - oniguruma-to-es@4.3.1: - dependencies: - oniguruma-parser: 0.12.0 - regex: 6.0.1 - regex-recursion: 6.0.2 - oniguruma-to-es@4.3.4: dependencies: oniguruma-parser: 0.12.1 @@ -36761,6 +37142,12 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-sampler@1.7.2: + dependencies: + '@types/json-schema': 7.0.15 + fast-xml-parser: 5.5.10 + json-pointer: 0.6.2 + openapi-types@12.1.3: {} ora@3.4.0: @@ -36833,6 +37220,60 @@ snapshots: '@oxc-parser/binding-win32-arm64-msvc': 0.102.0 '@oxc-parser/binding-win32-x64-msvc': 0.102.0 + oxc-parser@0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1): + dependencies: + '@oxc-project/types': 0.124.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.124.0 + '@oxc-parser/binding-android-arm64': 0.124.0 + '@oxc-parser/binding-darwin-arm64': 0.124.0 + '@oxc-parser/binding-darwin-x64': 0.124.0 + '@oxc-parser/binding-freebsd-x64': 0.124.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.124.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.124.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.124.0 + '@oxc-parser/binding-linux-arm64-musl': 0.124.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.124.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.124.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.124.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.124.0 + '@oxc-parser/binding-linux-x64-gnu': 0.124.0 + '@oxc-parser/binding-linux-x64-musl': 0.124.0 + '@oxc-parser/binding-openharmony-arm64': 0.124.0 + '@oxc-parser/binding-wasm32-wasi': 0.124.0(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + '@oxc-parser/binding-win32-arm64-msvc': 0.124.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.124.0 + '@oxc-parser/binding-win32-x64-msvc': 0.124.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1): + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.19.1 + '@oxc-resolver/binding-android-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-x64': 11.19.1 + '@oxc-resolver/binding-freebsd-x64': 11.19.1 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-arm64-musl': 11.19.1 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1 + '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-musl': 11.19.1 + '@oxc-resolver/binding-openharmony-arm64': 11.19.1 + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1) + '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 + '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 + '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + oxc-transform@0.102.0: optionalDependencies: '@oxc-transform/binding-android-arm64': 0.102.0 @@ -36917,6 +37358,8 @@ snapshots: package-manager-detector@1.2.0: {} + package-manager-detector@1.6.0: {} + pako@0.2.9: {} parent-module@1.0.1: @@ -37010,6 +37453,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.4.0: {} + path-is-absolute@1.0.1: {} path-key@2.0.1: {} @@ -37040,6 +37485,8 @@ snapshots: path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} path-type@6.0.0: {} @@ -37086,6 +37533,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pify@4.0.1: {} pino-abstract-transport@1.2.0: @@ -37707,6 +38156,10 @@ snapshots: dependencies: react: 19.2.3 + react-hook-form@7.72.1(react@19.2.3): + dependencies: + react: 19.2.3 + react-in-viewport@1.0.0-beta.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: hoist-non-react-statics: 3.3.2 @@ -37739,7 +38192,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-medium-image-zoom@5.4.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-medium-image-zoom@5.4.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -37872,34 +38325,37 @@ snapshots: react-refresh@0.17.0: {} - react-remove-scroll-bar@2.3.6(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 - react-style-singleton: 2.2.1(@types/react@19.2.7)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll@2.5.4(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3) react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll@2.5.4(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 - react-remove-scroll-bar: 2.3.6(@types/react@19.2.7)(react@19.2.3) - react-style-singleton: 2.2.1(@types/react@19.2.7)(react@19.2.3) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.7)(react@19.2.3) tslib: 2.8.1 - use-callback-ref: 1.3.1(@types/react@19.2.7)(react@19.2.3) - use-sidecar: 1.1.2(@types/react@19.2.7)(react@19.2.3) + use-callback-ref: 1.3.3(@types/react@19.2.7)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.3) optionalDependencies: '@types/react': 19.2.7 - react-remove-scroll@2.7.1(@types/react@19.2.7)(react@19.2.3): + react-remove-scroll@2.7.2(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 react-remove-scroll-bar: 2.3.8(@types/react@19.2.7)(react@19.2.3) @@ -37964,15 +38420,6 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-transition-group: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react-style-singleton@2.2.1(@types/react@19.2.7)(react@19.2.3): - dependencies: - get-nonce: 1.0.1 - invariant: 2.2.4 - react: 19.2.3 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.7 - react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.3): dependencies: get-nonce: 1.0.1 @@ -38247,7 +38694,7 @@ snapshots: rehype-stringify@10.0.1: dependencies: '@types/hast': 3.0.4 - hast-util-to-html: 9.0.3 + hast-util-to-html: 9.0.5 unified: 11.0.5 rehype@13.0.2: @@ -38320,7 +38767,7 @@ snapshots: remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-to-markdown: 2.1.1 + mdast-util-to-markdown: 2.1.2 unified: 11.0.5 remark@15.0.1: @@ -38482,7 +38929,7 @@ snapshots: robust-predicates@3.0.2: {} - rolldown-plugin-dts@0.15.9(rolldown@1.0.0-beta.43)(typescript@5.9.3): + rolldown-plugin-dts@0.15.9(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(rolldown@1.0.0-beta.43)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.5 @@ -38490,7 +38937,7 @@ snapshots: ast-kit: 2.1.2 birpc: 2.5.0 debug: 4.4.1 - dts-resolver: 2.1.2 + dts-resolver: 2.1.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)) get-tsconfig: 4.10.1 rolldown: 1.0.0-beta.43 optionalDependencies: @@ -38937,25 +39384,14 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - shiki@3.19.0: - dependencies: - '@shikijs/core': 3.19.0 - '@shikijs/engine-javascript': 3.19.0 - '@shikijs/engine-oniguruma': 3.19.0 - '@shikijs/langs': 3.19.0 - '@shikijs/themes': 3.19.0 - '@shikijs/types': 3.19.0 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - shiki@3.3.0: + shiki@4.0.2: dependencies: - '@shikijs/core': 3.3.0 - '@shikijs/engine-javascript': 3.3.0 - '@shikijs/engine-oniguruma': 3.3.0 - '@shikijs/langs': 3.3.0 - '@shikijs/themes': 3.3.0 - '@shikijs/types': 3.3.0 + '@shikijs/core': 4.0.2 + '@shikijs/engine-javascript': 4.0.2 + '@shikijs/engine-oniguruma': 4.0.2 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 + '@shikijs/types': 4.0.2 '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -39303,6 +39739,8 @@ snapshots: strnum@2.1.2: {} + strnum@2.2.3: {} + structured-clone-es@1.0.0: {} structured-headers@0.4.1: {} @@ -39446,6 +39884,8 @@ snapshots: tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} + tailwindcss-animate@1.0.7(tailwindcss@4.1.12): dependencies: tailwindcss: 4.1.12 @@ -39616,6 +40056,8 @@ snapshots: tinyexec@1.0.2: {} + tinyexec@1.1.1: {} + tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.2) @@ -39626,6 +40068,11 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@0.8.4: {} tinypool@1.0.2: {} @@ -39704,7 +40151,7 @@ snapshots: optionalDependencies: typescript: 5.9.3 - tsdown@0.14.2(typescript@5.9.3): + tsdown@0.14.2(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -39714,7 +40161,7 @@ snapshots: empathic: 2.0.0 hookable: 5.5.3 rolldown: 1.0.0-beta.43 - rolldown-plugin-dts: 0.15.9(rolldown@1.0.0-beta.43)(typescript@5.9.3) + rolldown-plugin-dts: 0.15.9(oxc-resolver@11.19.1(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1))(rolldown@1.0.0-beta.43)(typescript@5.9.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.15 @@ -40012,7 +40459,7 @@ snapshots: extend: 3.0.2 is-plain-obj: 4.1.0 trough: 2.2.0 - vfile: 6.0.1 + vfile: 6.0.3 unifont@0.4.1: dependencies: @@ -40079,7 +40526,7 @@ snapshots: unist-util-remove-position@5.0.0: dependencies: '@types/unist': 3.0.2 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 unist-util-stringify-position@4.0.0: dependencies: @@ -40100,6 +40547,12 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + universalify@0.1.2: {} universalify@1.0.0: {} @@ -40241,10 +40694,6 @@ snapshots: uqr@0.1.2: {} - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - url-join@4.0.0: {} url-metadata@5.4.1: @@ -40262,13 +40711,6 @@ snapshots: urlpattern-polyfill@10.1.0: {} - use-callback-ref@1.3.1(@types/react@19.2.7)(react@19.2.3): - dependencies: - react: 19.2.3 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.7 - use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): dependencies: react: 19.2.3 @@ -40280,14 +40722,6 @@ snapshots: dependencies: react: 19.2.3 - use-sidecar@1.1.2(@types/react@19.2.7)(react@19.2.3): - dependencies: - detect-node-es: 1.1.0 - react: 19.2.3 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 19.2.7 - use-sidecar@1.1.3(@types/react@19.2.7)(react@19.2.3): dependencies: detect-node-es: 1.1.0 @@ -41010,6 +41444,10 @@ snapshots: simple-plist: 1.3.1 uuid: 7.0.3 + xml-js@1.6.11: + dependencies: + sax: 1.4.3 + xml-name-validator@5.0.0: {} xml2js@0.6.0: @@ -41199,4 +41637,6 @@ snapshots: zod@4.1.13: {} + zod@4.3.6: {} + zwitch@2.0.4: {} From d3ac5f0bd34a011165a1951125a6ed4f8950fe24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 8 Apr 2026 13:49:29 +0200 Subject: [PATCH 15/18] fix --- packages/db/src/services/reports.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/db/src/services/reports.service.ts b/packages/db/src/services/reports.service.ts index b96794b3e..86daddf69 100644 --- a/packages/db/src/services/reports.service.ts +++ b/packages/db/src/services/reports.service.ts @@ -125,6 +125,7 @@ export async function getReportById(id: string) { } import { AggregateChartEngine, ChartEngine } from '../engine'; +import { getDashboardById } from './dashboard.service'; import { getChartStartEndDate } from './date.service'; import { funnelService } from './funnel.service'; import { getSettingsForProject } from './organization.service'; @@ -134,6 +135,10 @@ export async function listReportsCore(input: { dashboardId: string; organizationId: string; }) { + const dashboard = await getDashboardById(input.dashboardId, input.projectId); + if (!dashboard) { + return []; + } const reports = await getReportsByDashboardId(input.dashboardId); return reports.map((r) => ({ id: r.id, From 5d1e96067884794a48a56bd25538c4fc99ece050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 8 Apr 2026 14:00:53 +0200 Subject: [PATCH 16/18] fix test --- .github/workflows/docker-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 57786a3cf..e06c8f6bf 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -106,6 +106,7 @@ jobs: run: pnpm migrate:deploy env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + DATABASE_URL_DIRECT: postgresql://postgres:postgres@localhost:5432/postgres?schema=public - name: Run tests run: pnpm test From 40bd7e86058441e0b23203482fb4fb391ce93eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 8 Apr 2026 14:18:55 +0200 Subject: [PATCH 17/18] fix --- packages/db/src/services/reports.service.ts | 12 +++++----- test/clickhouse-schema.sql | 25 ++++++++++++++++----- test/fixtures.ts | 3 +++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/db/src/services/reports.service.ts b/packages/db/src/services/reports.service.ts index 86daddf69..2f5d6d8ca 100644 --- a/packages/db/src/services/reports.service.ts +++ b/packages/db/src/services/reports.service.ts @@ -161,16 +161,16 @@ export async function getReportDataCore(input: { reportId: string; organizationId: string; }) { - const report = await getReportById(input.reportId); + const rawReport = await db.report.findUnique({ + where: { id: input.reportId, projectId: input.projectId }, + include: { layout: true }, + }); - if (!report) { + if (!rawReport) { throw new Error(`Report not found: ${input.reportId}`); } - if (report.projectId !== input.projectId) { - throw new Error(`Report does not belong to this project: ${input.reportId}`); - } - + const report = transformReport(rawReport); const { timezone } = await getSettingsForProject(input.projectId); const { startDate, endDate } = getChartStartEndDate(report, timezone); const chartInput = { ...report, startDate, endDate, timezone }; diff --git a/test/clickhouse-schema.sql b/test/clickhouse-schema.sql index c3426b259..ee0ac454a 100644 --- a/test/clickhouse-schema.sql +++ b/test/clickhouse-schema.sql @@ -114,12 +114,23 @@ CREATE TABLE IF NOT EXISTS openpanel.distinct_event_names_mv ( project_id String, name LowCardinality(String), - count UInt64 + created_at DateTime64(3), + event_count UInt64 ) ENGINE = AggregatingMergeTree -ORDER BY (project_id, name) +ORDER BY (project_id, name, created_at) SETTINGS index_granularity = 8192; +CREATE MATERIALIZED VIEW IF NOT EXISTS openpanel.distinct_event_names_mv_trigger +TO openpanel.distinct_event_names_mv +AS SELECT + project_id, + name, + max(created_at) AS created_at, + count() AS event_count +FROM openpanel.events +GROUP BY project_id, name; + CREATE TABLE IF NOT EXISTS openpanel.event_property_values_mv ( project_id String, @@ -156,11 +167,13 @@ CREATE TABLE IF NOT EXISTS openpanel.groups ( id String, project_id String, - group_id String, type String, + name String, properties Map(String, String), - created_at DateTime64(3) + created_at DateTime, + version UInt64, + deleted UInt8 DEFAULT 0 ) -ENGINE = ReplacingMergeTree(created_at) -ORDER BY (project_id, type, group_id) +ENGINE = ReplacingMergeTree(version, deleted) +ORDER BY (project_id, id) SETTINGS index_granularity = 8192; diff --git a/test/fixtures.ts b/test/fixtures.ts index c67afaed3..2fa87c33f 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -384,6 +384,9 @@ async function deleteFixtures(client: ChClient, projectId: string) { client.command({ query: `DELETE FROM openpanel.sessions WHERE project_id = '${projectId}'`, }), + client.command({ + query: `ALTER TABLE openpanel.distinct_event_names_mv DELETE WHERE project_id = '${projectId}'`, + }), ]); } From de1c1d5fa9acdcece6e59c8c2cefa7c8c68eaf1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Wed, 8 Apr 2026 14:27:01 +0200 Subject: [PATCH 18/18] fix test 2 --- .github/workflows/docker-build.yml | 2 + test/clickhouse-schema.sql | 179 ----------------------------- test/fixtures.ts | 29 ----- test/global-setup.ts | 2 - 4 files changed, 2 insertions(+), 210 deletions(-) delete mode 100644 test/clickhouse-schema.sql diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index e06c8f6bf..666b5171f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -107,6 +107,8 @@ jobs: env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public DATABASE_URL_DIRECT: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + CLICKHOUSE_URL: http://localhost:8123/openpanel + SELF_HOSTED: "true" - name: Run tests run: pnpm test diff --git a/test/clickhouse-schema.sql b/test/clickhouse-schema.sql deleted file mode 100644 index ee0ac454a..000000000 --- a/test/clickhouse-schema.sql +++ /dev/null @@ -1,179 +0,0 @@ --- Minimal ClickHouse schema for MCP integration tests. --- Creates only the tables that MCP tools query against. --- Run against a fresh ClickHouse instance before executing the integration test suite. - -CREATE DATABASE IF NOT EXISTS openpanel; - -CREATE TABLE IF NOT EXISTS openpanel.events -( - id UUID DEFAULT generateUUIDv4(), - name LowCardinality(String), - sdk_name LowCardinality(String), - sdk_version LowCardinality(String), - device_id String, - profile_id String, - project_id String, - session_id String, - groups Array(String) DEFAULT [], - path String, - origin String, - referrer String, - referrer_name String, - referrer_type LowCardinality(String), - revenue UInt64, - duration UInt64, - properties Map(String, String), - created_at DateTime64(3), - country LowCardinality(FixedString(2)), - city String, - region LowCardinality(String), - longitude Nullable(Float32), - latitude Nullable(Float32), - os LowCardinality(String), - os_version LowCardinality(String), - browser LowCardinality(String), - browser_version LowCardinality(String), - device LowCardinality(String), - brand LowCardinality(String), - model LowCardinality(String), - imported_at Nullable(DateTime) -) -ENGINE = MergeTree -PARTITION BY toYYYYMM(created_at) -ORDER BY (project_id, toDate(created_at), created_at, name) -SETTINGS index_granularity = 8192; - -CREATE TABLE IF NOT EXISTS openpanel.profiles -( - id String, - is_external Bool, - first_name String, - last_name String, - email String, - avatar String, - properties Map(String, String), - project_id String, - groups Array(String) DEFAULT [], - created_at DateTime64(3) -) -ENGINE = ReplacingMergeTree(created_at) -PARTITION BY toYYYYMM(created_at) -ORDER BY (project_id, id) -SETTINGS index_granularity = 8192; - -CREATE TABLE IF NOT EXISTS openpanel.sessions -( - id String, - project_id String, - profile_id String, - device_id String, - created_at DateTime64(3), - ended_at DateTime64(3), - is_bounce Bool, - entry_origin LowCardinality(String), - entry_path String, - exit_origin LowCardinality(String), - exit_path String, - screen_view_count Int32, - revenue Float64, - event_count Int32, - duration UInt32, - country LowCardinality(FixedString(2)), - region LowCardinality(String), - city String, - longitude Nullable(Float32), - latitude Nullable(Float32), - device LowCardinality(String), - brand LowCardinality(String), - model LowCardinality(String), - browser LowCardinality(String), - browser_version LowCardinality(String), - os LowCardinality(String), - os_version LowCardinality(String), - utm_medium String, - utm_source String, - utm_campaign String, - utm_content String, - utm_term String, - referrer String, - referrer_name String, - referrer_type LowCardinality(String), - sign Int8, - version UInt64, - properties Map(String, String) -) -ENGINE = VersionedCollapsingMergeTree(sign, version) -PARTITION BY toYYYYMM(created_at) -ORDER BY (project_id, id, toDate(created_at), profile_id) -SETTINGS index_granularity = 8192; - --- Materialized view tables (simplified as regular tables for testing) --- The real ones are populated by triggers on events; these just need to exist. - -CREATE TABLE IF NOT EXISTS openpanel.distinct_event_names_mv -( - project_id String, - name LowCardinality(String), - created_at DateTime64(3), - event_count UInt64 -) -ENGINE = AggregatingMergeTree -ORDER BY (project_id, name, created_at) -SETTINGS index_granularity = 8192; - -CREATE MATERIALIZED VIEW IF NOT EXISTS openpanel.distinct_event_names_mv_trigger -TO openpanel.distinct_event_names_mv -AS SELECT - project_id, - name, - max(created_at) AS created_at, - count() AS event_count -FROM openpanel.events -GROUP BY project_id, name; - -CREATE TABLE IF NOT EXISTS openpanel.event_property_values_mv -( - project_id String, - name LowCardinality(String), - property_key String, - property_value String, - created_at DateTime64(3) -) -ENGINE = MergeTree -ORDER BY (project_id, name, property_key) -SETTINGS index_granularity = 8192; - -CREATE TABLE IF NOT EXISTS openpanel.dau_mv -( - project_id String, - profile_id AggregateFunction(uniq, String), - date Date -) -ENGINE = AggregatingMergeTree -ORDER BY (project_id, date) -SETTINGS index_granularity = 8192; - -CREATE TABLE IF NOT EXISTS openpanel.cohort_events_mv -( - project_id String, - profile_id String, - week Date -) -ENGINE = AggregatingMergeTree -ORDER BY (project_id, week, profile_id) -SETTINGS index_granularity = 8192; - -CREATE TABLE IF NOT EXISTS openpanel.groups -( - id String, - project_id String, - type String, - name String, - properties Map(String, String), - created_at DateTime, - version UInt64, - deleted UInt8 DEFAULT 0 -) -ENGINE = ReplacingMergeTree(version, deleted) -ORDER BY (project_id, id) -SETTINGS index_granularity = 8192; diff --git a/test/fixtures.ts b/test/fixtures.ts index 2fa87c33f..f85d901d6 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -22,9 +22,6 @@ * different project IDs (ClickHouse's MergeTree ordering includes project_id). */ -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { createClient } from '../packages/db/src/clickhouse/client'; import { PrismaClient } from '../packages/db/src/generated/prisma/client'; @@ -428,32 +425,6 @@ export async function teardownPostgresFixtures( } } -// --------------------------------------------------------------------------- -// ClickHouse schema bootstrap -// --------------------------------------------------------------------------- - -const __fixturesDir = dirname(fileURLToPath(import.meta.url)); - -export async function ensureSchema(): Promise { - const client = createClient({ - url: process.env.CLICKHOUSE_URL ?? 'http://localhost:8123', - }); - const sql = readFileSync( - join(__fixturesDir, 'clickhouse-schema.sql'), - 'utf8' - ); - const statements = sql - .split('\n') - .filter((line) => !line.trimStart().startsWith('--')) - .join('\n') - .split(';') - .map((s) => s.trim()) - .filter((s) => s.length > 0); - await Promise.all( - statements.map((statement) => client.command({ query: statement })) - ); - await client.close(); -} export async function setupFixtures(projectId: string): Promise { diff --git a/test/global-setup.ts b/test/global-setup.ts index b75146b9d..595d7bf79 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,5 +1,4 @@ import { - ensureSchema, setupFixtures, setupPostgresFixtures, teardownFixtures, @@ -20,7 +19,6 @@ function setEnvDefaults() { export async function setup() { setEnvDefaults(); - await ensureSchema(); await setupPostgresFixtures(TEST_PROJECT_ID, TEST_ORG_ID); await setupFixtures(TEST_PROJECT_ID); }