diff --git a/apps/web/app/api/customers/[id]/activity/route.ts b/apps/web/app/api/customers/[id]/activity/route.ts index 7e7deef5346..bc7f46301f5 100644 --- a/apps/web/app/api/customers/[id]/activity/route.ts +++ b/apps/web/app/api/customers/[id]/activity/route.ts @@ -7,7 +7,7 @@ import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/customers/[id]/activity - get a customer's activity -export const GET = withWorkspace(async ({ workspace, params, session }) => { +export const GET = withWorkspace(async ({ workspace, params }) => { const { id: customerId } = params; const customer = await getCustomerOrThrow({ diff --git a/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts b/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts index 0dfd98e3a41..c141d63da4e 100644 --- a/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts +++ b/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts @@ -33,6 +33,7 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { const events = await getCustomerEvents({ customerId: customer.id, linkIds: links.map((link) => link.id), + hideMetadata: true, // don't expose metadata to partners }); if (events.length === 0) { diff --git a/apps/web/lib/analytics/get-customer-events.ts b/apps/web/lib/analytics/get-customer-events.ts index 3ac23ddbb0f..e96ab21aa27 100644 --- a/apps/web/lib/analytics/get-customer-events.ts +++ b/apps/web/lib/analytics/get-customer-events.ts @@ -14,9 +14,11 @@ import { saleEventResponseSchema } from "../zod/schemas/sales"; export const getCustomerEvents = async ({ customerId, linkIds, + hideMetadata = false, }: { customerId: string; linkIds?: string[]; + hideMetadata?: boolean; }) => { const pipe = tb.buildPipe({ pipe: "v2_customer_events", @@ -60,6 +62,7 @@ export const getCustomerEvents = async ({ ? { eventId: evt.event_id, eventName: evt.event_name, + metadata: hideMetadata ? null : evt.metadata, ...(evt.event === "sale" ? { sale: { diff --git a/apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts b/apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts index 094d6559157..ec3ee8bac88 100644 --- a/apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts +++ b/apps/web/lib/analytics/verify-analytics-allowed-hostnames.ts @@ -5,7 +5,7 @@ export const verifyAnalyticsAllowedHostnames = ({ allowedHostnames: string[]; req: Request; }) => { - if (allowedHostnames.length > 0) { + if (allowedHostnames && allowedHostnames.length > 0) { const source = req.headers.get("referer") || req.headers.get("origin"); const sourceUrl = source ? new URL(source) : null; const hostname = sourceUrl?.hostname.replace(/^www\./, ""); diff --git a/apps/web/lib/zod/schemas/leads.ts b/apps/web/lib/zod/schemas/leads.ts index 71fe681731d..0f38f699b37 100644 --- a/apps/web/lib/zod/schemas/leads.ts +++ b/apps/web/lib/zod/schemas/leads.ts @@ -129,6 +129,12 @@ export const leadEventResponseSchema = z timestamp: z.coerce.string(), eventId: z.string(), eventName: z.string(), + metadata: z + .string() + .nullish() + .transform((val) => (val === "" ? null : val)) + .default(null) + .openapi({ type: "string" }), // nested objects click: clickEventSchema, link: linkEventSchema, diff --git a/apps/web/lib/zod/schemas/sales.ts b/apps/web/lib/zod/schemas/sales.ts index bf1b4bcfd66..49e34962d3c 100644 --- a/apps/web/lib/zod/schemas/sales.ts +++ b/apps/web/lib/zod/schemas/sales.ts @@ -160,6 +160,12 @@ export const saleEventResponseSchema = z payment_processor: z .string() .describe("Deprecated. Use `sale.paymentProcessor` instead."), + metadata: z + .string() + .nullish() + .transform((val) => (val === "" ? null : val)) + .default(null) + .openapi({ type: "string" }), }) .merge(commonDeprecatedEventFields) .openapi({ ref: "SaleEvent" }); diff --git a/apps/web/ui/analytics/events/metadata-viewer.tsx b/apps/web/ui/analytics/events/metadata-viewer.tsx new file mode 100644 index 00000000000..84742913799 --- /dev/null +++ b/apps/web/ui/analytics/events/metadata-viewer.tsx @@ -0,0 +1,102 @@ +import { Button, Tooltip, useCopyToClipboard } from "@dub/ui"; +import { cn, truncate } from "@dub/utils"; +import { Check, Copy } from "lucide-react"; +import { Fragment } from "react"; + +// Display the event metadata +export function MetadataViewer({ + metadata, +}: { + metadata: Record; +}) { + const [copied, copyToClipboard] = useCopyToClipboard(); + + const displayEntries = Object.entries(metadata) + .map(([key, value]) => { + if (typeof value === "object" && value !== null) { + // Only show nested properties if the parent object has exactly one property + if (Object.keys(metadata).length === 1) { + const nestedEntries = Object.entries(value).map( + ([nestedKey, nestedValue]) => { + const displayValue = + typeof nestedValue === "object" && nestedValue !== null + ? truncate(JSON.stringify(nestedValue), 20) + : truncate(String(nestedValue), 20); + return `${key}.${nestedKey}: ${displayValue}`; + }, + ); + // else show the parent object properties + return nestedEntries; + } + return [`${key}: ${truncate(JSON.stringify(value), 20)}`]; + } + return [`${key}: ${truncate(String(value), 20)}`]; + }) + .flat(); + + const hasMoreItems = displayEntries.length > 3; + const visibleEntries = hasMoreItems + ? displayEntries.slice(0, 3) + : displayEntries; + + return ( +
+ {visibleEntries.map((entry, i) => ( + + + {entry} + + + ))} + + +
+
+
+                  {JSON.stringify(metadata, null, 2)}
+                
+
+
+
+ } + className="h-9" + text={copied ? "Copied metadata" : "Copy metadata"} + onClick={() => copyToClipboard(JSON.stringify(metadata, null, 2))} + /> + + } + align="start" + > + + + + ); +} diff --git a/apps/web/ui/customers/customer-activity-list.tsx b/apps/web/ui/customers/customer-activity-list.tsx index 0434803ab6d..b0de54c8069 100644 --- a/apps/web/ui/customers/customer-activity-list.tsx +++ b/apps/web/ui/customers/customer-activity-list.tsx @@ -4,6 +4,7 @@ import { CursorRays, MoneyBill2, UserCheck } from "@dub/ui/icons"; import { formatDateTimeSmart, getApexDomain, getPrettyUrl } from "@dub/utils"; import Link from "next/link"; import { useParams } from "next/navigation"; +import { MetadataViewer } from "../analytics/events/metadata-viewer"; const activityData = { click: { @@ -80,8 +81,46 @@ const activityData = { ); }, }, - lead: { icon: UserCheck, content: (event) => event.eventName || "New lead" }, - sale: { icon: MoneyBill2, content: (event) => event.eventName || "New sale" }, + + lead: { + icon: UserCheck, + content: (event) => { + let metadata = null; + + try { + metadata = event.metadata ? JSON.parse(event.metadata) : null; + } catch (e) { + // + } + + return ( +
+ {event.eventName || "New lead"} + {metadata && } +
+ ); + }, + }, + + sale: { + icon: MoneyBill2, + content: (event) => { + let metadata = null; + + try { + metadata = event.metadata ? JSON.parse(event.metadata) : null; + } catch (e) { + // + } + + return ( +
+ {event.eventName || "New sale"} + {metadata && } +
+ ); + }, + }, }; export function CustomerActivityList({ diff --git a/packages/tinybird/pipes/v2_events.pipe b/packages/tinybird/pipes/v2_events.pipe index 00f9808f94c..f5cbd568e57 100644 --- a/packages/tinybird/pipes/v2_events.pipe +++ b/packages/tinybird/pipes/v2_events.pipe @@ -2,8 +2,6 @@ DESCRIPTION > Top countries -TOKEN "v2_events_endpoint_read_6623" READ - TAGS "Dub Endpoints" NODE workspace_links @@ -11,16 +9,23 @@ SQL > % SELECT link_id, domain, key - from dub_links_metadata_latest FINAL + FROM + {% if defined(isMegaFolder) and Boolean(isMegaFolder) == 1 %} dub_links_metadata_latest + {% else %} dub_regular_links_metadata_latest + {% end %} FINAL WHERE deleted == 0 {% if defined(workspaceId) %} AND workspace_id = {{ workspaceId }} {% end %} {% if defined(programId) %} AND program_id = {{ programId }} {% end %} {% if defined(partnerId) %} AND partner_id = {{ partnerId }} {% end %} {% if defined(tenantId) %} AND tenant_id = {{ tenantId }} {% end %} - {% if defined(folderId) %} AND folder_id = {{ folderId }} {% end %} + {% if defined(folderIds) %} AND folder_id IN {{ Array(folderIds, 'String') }} + {% elif defined(folderId) %} AND folder_id = {{ folderId }} + {% end %} {% if defined(domain) %} AND domain IN {{ Array(domain, 'String') }} {% end %} - {% if defined(tagIds) %} AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != [] {% end %} + {% if defined(tagIds) %} + AND arrayIntersect(tag_ids, {{ Array(tagIds, 'String') }}) != [] + {% end %} {% if defined(root) %} {% if Boolean(root) == 1 %} AND key = '_root' {% else %} AND key != '_root' {% end %} {% end %} @@ -54,21 +59,11 @@ SQL > {% if defined(os) %} AND os = {{ os }} {% end %} {% if defined(referer) %} AND referer = {{ referer }} {% end %} {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %} - {% if defined(utm_source) %} - AND url LIKE concat('%utm_source=', {{ String(utm_source) }}, '%') - {% end %} - {% if defined(utm_medium) %} - AND url LIKE concat('%utm_medium=', {{ String(utm_medium) }}, '%') - {% end %} - {% if defined(utm_campaign) %} - AND url LIKE concat('%utm_campaign=', {{ String(utm_campaign) }}, '%') - {% end %} - {% if defined(utm_term) %} - AND url LIKE concat('%utm_term=', {{ String(utm_term) }}, '%') - {% end %} - {% if defined(utm_content) %} - AND url LIKE concat('%utm_content=', {{ String(utm_content) }}, '%') - {% end %} + {% if defined(utm_source) %} AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%') {% end %} + {% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%') {% end %} + {% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%') {% end %} + {% if defined(utm_term) %} AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%') {% end %} + {% if defined(utm_content) %} AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') {% end %} {% if defined(url) %} AND url = {{ url }} {% end %} ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %} LIMIT {{ Int32(limit, 100) }} @@ -85,10 +80,10 @@ SQL > splitByString('?', referer_url)[1] as referer_url_processed, CONCAT(country, '-', region) as region_processed, 'lead' as event - FROM dub_lead_events_mv + FROM dub_lead_events_mv_new WHERE - timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }} - AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }} + timestamp >= {{ DateTime(start, '2024-01-01 00:00:00') }} + AND timestamp < {{ DateTime(end, '2025-12-31 00:00:00') }} {% if defined(linkId) %} AND link_id = {{ String(linkId) }} {% elif defined(workspaceId) or defined(partnerId) or defined(programId) %} AND link_id IN (SELECT link_id FROM workspace_links) @@ -104,11 +99,22 @@ SQL > {% if defined(os) %} AND os = {{ os }} {% end %} {% if defined(referer) %} AND referer = {{ referer }} {% end %} {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %} - {% if defined(utm_source) %} AND url LIKE concat('%utm_source=', {{ String(utm_source) }}, '%') {% end %} - {% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', {{ String(utm_medium) }}, '%') {% end %} - {% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', {{ String(utm_campaign) }}, '%') {% end %} - {% if defined(utm_term) %} AND url LIKE concat('%utm_term=', {{ String(utm_term) }}, '%') {% end %} - {% if defined(utm_content) %} AND url LIKE concat('%utm_content=', {{ String(utm_content) }}, '%') {% end %} + {% if defined(utm_source) %} + AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%') + {% end %} + {% if defined(utm_medium) %} + AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%') + {% end %} + {% if defined(utm_campaign) %} + AND url + LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%') + {% end %} + {% if defined(utm_term) %} + AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%') + {% end %} + {% if defined(utm_content) %} + AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') + {% end %} {% if defined(url) %} AND url = {{ url }} {% end %} ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %} LIMIT {{ Int32(limit, 100) }} @@ -126,7 +132,7 @@ SQL > CONCAT(country, '-', region) as region_processed, splitByString('?', referer_url)[1] as referer_url_processed, 'sale' as event - FROM dub_sale_events_mv + FROM dub_sale_events_mv_new WHERE timestamp >= {{ DateTime(start, '2024-06-01 00:00:00') }} AND timestamp < {{ DateTime(end, '2024-06-07 00:00:00') }} @@ -145,11 +151,11 @@ SQL > {% if defined(os) %} AND os = {{ os }} {% end %} {% if defined(referer) %} AND referer = {{ referer }} {% end %} {% if defined(refererUrl) %} AND splitByString('?', referer_url)[1] = {{ refererUrl }} {% end %} - {% if defined(utm_source) %} AND url LIKE concat('%utm_source=', {{ String(utm_source) }}, '%') {% end %} - {% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', {{ String(utm_medium) }}, '%') {% end %} - {% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', {{ String(utm_campaign) }}, '%') {% end %} - {% if defined(utm_term) %} AND url LIKE concat('%utm_term=', {{ String(utm_term) }}, '%') {% end %} - {% if defined(utm_content) %} AND url LIKE concat('%utm_content=', {{ String(utm_content) }}, '%') {% end %} + {% if defined(utm_source) %} AND url LIKE concat('%utm_source=', encodeURLFormComponent({{ String(utm_source) }}), '%') {% end %} + {% if defined(utm_medium) %} AND url LIKE concat('%utm_medium=', encodeURLFormComponent({{ String(utm_medium) }}), '%') {% end %} + {% if defined(utm_campaign) %} AND url LIKE concat('%utm_campaign=', encodeURLFormComponent({{ String(utm_campaign) }}), '%') {% end %} + {% if defined(utm_term) %} AND url LIKE concat('%utm_term=', encodeURLFormComponent({{ String(utm_term) }}), '%') {% end %} + {% if defined(utm_content) %} AND url LIKE concat('%utm_content=', encodeURLFormComponent({{ String(utm_content) }}), '%') {% end %} {% if defined(url) %} AND url = {{ url }} {% end %} ORDER BY timestamp {% if order == 'asc' %} ASC {% else %} DESC {% end %} LIMIT {{ Int32(limit, 100) }}