diff --git a/.gitignore b/.gitignore
index 80730f00f..d8102a53e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,5 +50,9 @@ yarn-error.log*
test.rest
test-webchat
+# Cursor
+.cursor/skills/
+CLAUDE.md
+
.pnpm-store/
.plans/
diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json
index 4b2eaa8a2..d063179b4 100644
--- a/apps/builder/messages/en.json
+++ b/apps/builder/messages/en.json
@@ -74,10 +74,12 @@
"upgradeToPro": "Upgrade to Pro",
"uploadFile": "Upload File",
"view": "View",
+ "viewAnalytics": "View Analytics",
"duplicate": "Duplicate",
"getDraftLink": "Get Draft Link",
"getPublishedLink": "Get Published Link",
"analytics": "Analytics",
+ "deleteAnalytics": "Delete Analytics",
"flowVersions": "Flow Versions",
"revertToPublished": "Revert to Published",
"search": "Search",
@@ -198,7 +200,11 @@
"blockedConversationsHelp": "Conversations that your account blocked.",
"blockedContacts": "Blocked contacts",
"blockedContactsHelp": "Contacts don't want to receive messages from your account",
- "newContactsByCountry": "New contacts by country"
+ "newContactsByCountry": "New contacts by country",
+ "date": "Date",
+ "total": "Total",
+ "sessionsThroughTheRef": "Sessions through the Ref \"{ref}\" per day",
+ "sessionsThroughTheMagicLink": "Sessions through the Magic Link \"{ref}\" per day"
},
"autoAssignConversation": {
"rule": {
diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json
index 0eb0fe687..a06154566 100644
--- a/apps/builder/messages/vi.json
+++ b/apps/builder/messages/vi.json
@@ -74,10 +74,12 @@
"upgradeToPro": "Nâng cấp lên Pro",
"uploadFile": "Tải lên tệp",
"view": "Xem",
+ "viewAnalytics": "Xem phân tích",
"duplicate": "Nhân bản",
"getDraftLink": "Lấy liên kết bản nháp",
"getPublishedLink": "Lấy liên kết đã xuất bản",
"analytics": "Phân tích",
+ "deleteAnalytics": "Xóa phân tích",
"flowVersions": "Phiên bản luồng",
"revertToPublished": "Khôi phục về phiên bản đã xuất bản",
"search": "Tìm kiếm",
@@ -198,7 +200,11 @@
"blockedConversationsHelp": "Cuộc trò chuyện mà tài khoản của bạn đã chặn.",
"blockedContacts": "Liên hệ bị chặn",
"blockedContactsHelp": "Khách hàng không muốn nhận tin nhắn từ tài khoản của bạn",
- "newContactsByCountry": "Liên hệ mới theo quốc gia"
+ "newContactsByCountry": "Liên hệ mới theo quốc gia",
+ "date": "Ngày",
+ "total": "Tổng",
+ "sessionsThroughTheRef": "Phiên qua Ref '{ref}' mỗi ngày",
+ "sessionsThroughTheMagicLink": "Phiên qua Magic Link '{ref}' mỗi ngày"
},
"autoAssignConversation": {
"rule": {
diff --git a/apps/builder/src/app/space/[workspaceId]/magic-links/[magicLinkId]/analytics/magic-link-analytics-client.tsx b/apps/builder/src/app/space/[workspaceId]/magic-links/[magicLinkId]/analytics/magic-link-analytics-client.tsx
new file mode 100644
index 000000000..4f744e11d
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/magic-links/[magicLinkId]/analytics/magic-link-analytics-client.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import { MagicLinkAnalytics } from "@chatbotx.io/analytics-nextjs/components/magic-link-analytics"
+
+export function MagicLinkAnalyticsClient({
+ workspaceId,
+ linkId,
+ linkName,
+}: {
+ workspaceId: string
+ linkId: string
+ linkName: string
+}) {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+ return (
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/magic-links/[magicLinkId]/analytics/page.tsx b/apps/builder/src/app/space/[workspaceId]/magic-links/[magicLinkId]/analytics/page.tsx
new file mode 100644
index 000000000..572b08e24
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/magic-links/[magicLinkId]/analytics/page.tsx
@@ -0,0 +1,47 @@
+import { db } from "@chatbotx.io/database/client"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { notFound, redirect } from "next/navigation"
+import { z } from "zod"
+import { getCurrentUserAndTargetWorkspace } from "@/lib/auth/utils"
+import { MagicLinkAnalyticsClient } from "./magic-link-analytics-client"
+
+const paramsSchema = z.object({
+ workspaceId: zodBigintAsString(),
+ magicLinkId: zodBigintAsString(),
+})
+
+type Props = {
+ params: Promise<{ workspaceId: string; magicLinkId: string }>
+}
+
+export default async function MagicLinkAnalyticsPage({ params }: Props) {
+ const { data } = await paramsSchema.safeParse(await params)
+ if (!data) {
+ return notFound()
+ }
+
+ const result = await getCurrentUserAndTargetWorkspace(data.workspaceId)
+ if (!result) {
+ return redirect("/")
+ }
+
+ const magicLink = await db.query.magicLinkModel.findFirst({
+ where: {
+ id: data.magicLinkId,
+ workspaceId: data.workspaceId,
+ },
+ })
+ if (!magicLink) {
+ return notFound()
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/reflinks/[reflinkId]/analytics/page.tsx b/apps/builder/src/app/space/[workspaceId]/reflinks/[reflinkId]/analytics/page.tsx
new file mode 100644
index 000000000..10700b41d
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/reflinks/[reflinkId]/analytics/page.tsx
@@ -0,0 +1,47 @@
+import { db } from "@chatbotx.io/database/client"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { notFound, redirect } from "next/navigation"
+import { z } from "zod"
+import { getCurrentUserAndTargetWorkspace } from "@/lib/auth/utils"
+import { ReflinkAnalyticsClient } from "./reflink-analytics-client"
+
+const paramsSchema = z.object({
+ workspaceId: zodBigintAsString(),
+ reflinkId: zodBigintAsString(),
+})
+
+type Props = {
+ params: Promise<{ workspaceId: string; reflinkId: string }>
+}
+
+export default async function ReflinkAnalyticsPage({ params }: Props) {
+ const { data } = await paramsSchema.safeParse(await params)
+ if (!data) {
+ return notFound()
+ }
+
+ const result = await getCurrentUserAndTargetWorkspace(data.workspaceId)
+ if (!result) {
+ return redirect("/")
+ }
+
+ const reflink = await db.query.reflinkModel.findFirst({
+ where: {
+ id: data.reflinkId,
+ workspaceId: data.workspaceId,
+ },
+ })
+ if (!reflink) {
+ return notFound()
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/reflinks/[reflinkId]/analytics/reflink-analytics-client.tsx b/apps/builder/src/app/space/[workspaceId]/reflinks/[reflinkId]/analytics/reflink-analytics-client.tsx
new file mode 100644
index 000000000..06e67861d
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/reflinks/[reflinkId]/analytics/reflink-analytics-client.tsx
@@ -0,0 +1,20 @@
+"use client"
+
+import { ReflinkAnalytics } from "@chatbotx.io/analytics-nextjs/components/reflink-analytics"
+
+export function ReflinkAnalyticsClient({
+ workspaceId,
+ linkId,
+ linkName,
+}: {
+ workspaceId: string
+ linkId: string
+ linkName: string
+}) {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+ return (
+
+ )
+}
diff --git a/apps/builder/src/features/flows/actions/duplicate-flow.action.ts b/apps/builder/src/features/flows/actions/duplicate-flow.action.ts
index f5f18bcbf..8623ea251 100644
--- a/apps/builder/src/features/flows/actions/duplicate-flow.action.ts
+++ b/apps/builder/src/features/flows/actions/duplicate-flow.action.ts
@@ -52,6 +52,12 @@ export const duplicateFlow = async (ctx: {
flowId: newFlowId,
workspaceId: flow.workspaceId,
})
+ await tx.insert(flowAnalyticsSessionModel).values({
+ flowId: newFlowId,
+ workspaceId: flow.workspaceId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
await tx.insert(flowVersionModel).values({
...draftVersion,
diff --git a/apps/builder/src/features/magic-links/magic-links-table.tsx b/apps/builder/src/features/magic-links/magic-links-table.tsx
index 96a3c4cb8..04d36d835 100644
--- a/apps/builder/src/features/magic-links/magic-links-table.tsx
+++ b/apps/builder/src/features/magic-links/magic-links-table.tsx
@@ -25,6 +25,7 @@ import {
import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
import type { ColumnDef, Row } from "@tanstack/react-table"
import {
+ ChartLineIcon,
LinkIcon,
MoreHorizontalIcon,
PencilIcon,
@@ -186,6 +187,17 @@ export const MagicLinksTable = ({
{t("actions.qrCode")}
+ {
+ router.push(
+ `/space/${workspaceId}/magic-links/${row.original.id}/analytics`,
+ )
+ }}
+ >
+
+ {t("actions.viewAnalytics")}
+
+
setRowAction({ row, variant: "update" })}
>
@@ -207,7 +219,7 @@ export const MagicLinksTable = ({
enableHiding: false,
},
],
- [copy, t],
+ [copy, t, router.push, workspaceId],
)
const { table } = useDataTable({
diff --git a/apps/builder/src/features/reflinks/api/authenticated.ts b/apps/builder/src/features/reflinks/api/authenticated.ts
new file mode 100644
index 000000000..4235989b6
--- /dev/null
+++ b/apps/builder/src/features/reflinks/api/authenticated.ts
@@ -0,0 +1,25 @@
+import { workspaceAuthorizedMidddleware } from "@/middlewares/auth"
+import { authorizedAPI } from "@/orpc"
+import { findReflink } from "../queries"
+import { getReflinkRequest } from "../schemas/query"
+import { reflinkResponse } from "../schemas/resource"
+
+export const refLinkAuthenticatedAPI = {
+ getRefLinksAuthenticatedAPI: authorizedAPI
+ .route({
+ method: "GET",
+ path: "/workspaces/{workspaceId}/ref-links/{id}",
+ summary: "Get a specific ref link",
+ tags: ["Ref Links"],
+ })
+ .input(getReflinkRequest)
+ .use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
+ .output(reflinkResponse)
+ .handler(
+ async ({ input }) =>
+ await findReflink({
+ workspaceId: input.workspaceId,
+ id: input.id,
+ }),
+ ),
+}
diff --git a/apps/builder/src/features/reflinks/api/index.ts b/apps/builder/src/features/reflinks/api/index.ts
new file mode 100644
index 000000000..71b09cac6
--- /dev/null
+++ b/apps/builder/src/features/reflinks/api/index.ts
@@ -0,0 +1,7 @@
+import { refLinkAuthenticatedAPI } from "./authenticated"
+import refLinksWorkspaceTokenAPIs from "./workspace-token"
+
+export const refLinksAPI = {
+ ...refLinkAuthenticatedAPI,
+ ...refLinksWorkspaceTokenAPIs,
+}
diff --git a/apps/builder/src/features/reflinks/api/workspace-token.ts b/apps/builder/src/features/reflinks/api/workspace-token.ts
new file mode 100644
index 000000000..aed4f96eb
--- /dev/null
+++ b/apps/builder/src/features/reflinks/api/workspace-token.ts
@@ -0,0 +1,30 @@
+import { notFoundException } from "@chatbotx.io/business/errors"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { z } from "zod"
+import { workspaceTokenAuthAPI } from "@/orpc"
+import { findReflink } from "../queries"
+import { reflinkResource } from "../schemas/resource"
+
+export const refLinksWorkspaceTokenAPIs = {
+ getRefLinkWorkspaceTokenAPI: workspaceTokenAuthAPI
+ .route({
+ method: "GET",
+ path: "/v1/ref-links/{id}",
+ summary: "Get a specific ref link",
+ tags: ["Ref Links"],
+ })
+ .input(z.object({ id: zodBigintAsString() }))
+ .output(reflinkResource)
+ .handler(async ({ context, input }) => {
+ const reflink = await findReflink({
+ workspaceId: context.workspace.id,
+ id: input.id,
+ })
+ if (!reflink) {
+ throw notFoundException("Ref link not found")
+ }
+ return reflink
+ }),
+}
+
+export default refLinksWorkspaceTokenAPIs
diff --git a/apps/builder/src/features/reflinks/queries/index.ts b/apps/builder/src/features/reflinks/queries/index.ts
index 8e3a6e1cf..5dcbc4766 100644
--- a/apps/builder/src/features/reflinks/queries/index.ts
+++ b/apps/builder/src/features/reflinks/queries/index.ts
@@ -6,6 +6,7 @@ import {
} from "@chatbotx.io/database/utils"
import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils"
import type {
+ GetReflinkRequest,
ListReflinksRequest,
ListReflinksResponse,
} from "../schemas/query"
@@ -43,10 +44,9 @@ export async function listReflinks(
return { data, pageCount }
}
-export async function findReflink(where: {
- workspaceId: string
- id: string
-}): Promise {
+export async function findReflink(
+ where: GetReflinkRequest,
+): Promise {
return await db.query.reflinkModel.findFirst({
where: { ...where, type: "refLink" },
})
diff --git a/apps/builder/src/features/reflinks/reflinks-table.tsx b/apps/builder/src/features/reflinks/reflinks-table.tsx
index 60f008220..a1ffa087e 100644
--- a/apps/builder/src/features/reflinks/reflinks-table.tsx
+++ b/apps/builder/src/features/reflinks/reflinks-table.tsx
@@ -26,6 +26,7 @@ import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
import type { DataTableRowAction } from "@chatbotx.io/ui/types/data-table"
import type { ColumnDef } from "@tanstack/react-table"
import {
+ ChartLineIcon,
LinkIcon,
MoreHorizontalIcon,
PencilIcon,
@@ -156,6 +157,17 @@ export function ReflinksTable({ workspaceId, promises }: ReflinksTableProps) {
{t("actions.copyUrl")}
+ {
+ router.push(
+ `/space/${workspaceId}/reflinks/${row.original.id}/analytics`,
+ )
+ }}
+ >
+
+ {t("actions.viewAnalytics")}
+
+
setRowAction({ row, variant: "update" })}
>
@@ -177,7 +189,7 @@ export function ReflinksTable({ workspaceId, promises }: ReflinksTableProps) {
enableHiding: false,
},
],
- [t],
+ [t, router.push, workspaceId],
)
const { table } = useDataTable({
diff --git a/apps/builder/src/features/reflinks/schemas/query.ts b/apps/builder/src/features/reflinks/schemas/query.ts
index ea7913d69..636adbce1 100644
--- a/apps/builder/src/features/reflinks/schemas/query.ts
+++ b/apps/builder/src/features/reflinks/schemas/query.ts
@@ -39,3 +39,9 @@ export const listReflinksResponse = z.object({
pageCount: z.number(),
})
export type ListReflinksResponse = z.infer
+
+export const getReflinkRequest = z.object({
+ workspaceId: z.string(),
+ id: z.string(),
+})
+export type GetReflinkRequest = z.infer
diff --git a/apps/builder/src/features/reflinks/schemas/resource.ts b/apps/builder/src/features/reflinks/schemas/resource.ts
index cec82eebd..02ec40fb0 100644
--- a/apps/builder/src/features/reflinks/schemas/resource.ts
+++ b/apps/builder/src/features/reflinks/schemas/resource.ts
@@ -10,3 +10,6 @@ export const reflinkResource = createSelectSchema(reflinkModel, {
type: reflinkTypes,
})
export type ReflinkResource = z.infer
+
+export const reflinkResponse = reflinkResource.optional()
+export type ReflinkResponse = z.infer
diff --git a/packages/analytics-nextjs/src/components/charts/magic-link-contacts-table.tsx b/packages/analytics-nextjs/src/components/charts/magic-link-contacts-table.tsx
new file mode 100644
index 000000000..c578fcc89
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/charts/magic-link-contacts-table.tsx
@@ -0,0 +1,126 @@
+"use client"
+
+import type { FlowNodeContactData } from "@chatbotx.io/analytics"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationNext,
+ PaginationPrevious,
+} from "@chatbotx.io/ui/components/ui/pagination"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@chatbotx.io/ui/components/ui/table"
+import { format } from "date-fns"
+import { useTranslations } from "next-intl"
+import { useAnalysisStore } from "../../provider/analysis-store-context"
+
+function getFullName(contact: FlowNodeContactData): string {
+ if (contact.firstName || contact.lastName) {
+ return [contact.firstName, contact.lastName].filter(Boolean).join(" ")
+ }
+ return contact.sourceId ?? "-"
+}
+
+function getInitial(contact: FlowNodeContactData): string {
+ return contact.firstName?.[0]?.toUpperCase() ?? "?"
+}
+
+export function MagicLinkContactsTable() {
+ const t = useTranslations()
+ const {
+ magicLinkContacts: contacts,
+ magicLinkContactsPage: page,
+ magicLinkContactsPageCount: pageCount,
+ setMagicLinkContactsPage,
+ loading,
+ } = useAnalysisStore((state) => state)
+
+ return (
+
+
+
+
+
+
+ {t("fields.name.label")}
+ {t("analytics.date")}
+ {t("fields.source.label")}
+
+
+
+ {contacts.length > 0 ? (
+ contacts.map((contact) => (
+
+
+
+
+ {getInitial(contact)}
+
+
+
+ {getFullName(contact)}
+
+
+ {format(new Date(contact.occurredAt), "MMM d, yyyy")}
+
+ {contact.sourceId ?? "-"}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+ {pageCount > 1 && (
+
+
+
+ setMagicLinkContactsPage(page - 1)}
+ />
+
+
+
+ {page} / {pageCount}
+
+
+
+ = pageCount || loading}
+ className={
+ page >= pageCount || loading
+ ? "pointer-events-none opacity-50"
+ : "cursor-pointer"
+ }
+ onClick={() => setMagicLinkContactsPage(page + 1)}
+ />
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/analytics-nextjs/src/components/charts/magic-link-stats-chart.tsx b/packages/analytics-nextjs/src/components/charts/magic-link-stats-chart.tsx
new file mode 100644
index 000000000..f312c275b
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/charts/magic-link-stats-chart.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import AreaChart from "@chatbotx.io/ui/components/charts/area-chart"
+import { format } from "date-fns"
+import { useTranslations } from "next-intl"
+import { useAnalysisStore } from "../../provider/analysis-store-context"
+
+export function MagicLinkStatsChart() {
+ const t = useTranslations()
+
+ const magicLinkStats = useAnalysisStore((state) => state.magicLinkStats)
+ const linkName = useAnalysisStore(
+ (state) => state.defaultSearchParams.linkName ?? "",
+ )
+
+ return (
+ ({
+ label: format(new Date(row.dateReport), "MMM d"),
+ value: row.count,
+ }))}
+ title={t("analytics.sessionsThroughTheMagicLink", { ref: linkName })}
+ valueLabel={t("analytics.total")}
+ />
+ )
+}
diff --git a/packages/analytics-nextjs/src/components/charts/magic-link-stats-table.tsx b/packages/analytics-nextjs/src/components/charts/magic-link-stats-table.tsx
new file mode 100644
index 000000000..0b9834933
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/charts/magic-link-stats-table.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@chatbotx.io/ui/components/ui/table"
+import { format } from "date-fns"
+import { useTranslations } from "next-intl"
+import { useAnalysisStore } from "../../provider/analysis-store-context"
+
+export function MagicLinkStatsTable() {
+ const t = useTranslations("analytics")
+ const magicLinkStats = useAnalysisStore((state) => state.magicLinkStats)
+
+ return (
+
+
+
+
+ {t("date")}
+ {t("total")}
+
+
+
+ {magicLinkStats.length > 0 ? (
+ magicLinkStats.map((row) => (
+
+
+ {format(new Date(row.dateReport), "MMM d, yyyy")}
+
+ {row.count}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+ )
+}
diff --git a/packages/analytics-nextjs/src/components/charts/reflink-contacts-table.tsx b/packages/analytics-nextjs/src/components/charts/reflink-contacts-table.tsx
new file mode 100644
index 000000000..641876623
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/charts/reflink-contacts-table.tsx
@@ -0,0 +1,126 @@
+"use client"
+
+import type { FlowNodeContactData } from "@chatbotx.io/analytics"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationNext,
+ PaginationPrevious,
+} from "@chatbotx.io/ui/components/ui/pagination"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@chatbotx.io/ui/components/ui/table"
+import { format } from "date-fns"
+import { useTranslations } from "next-intl"
+import { useAnalysisStore } from "../../provider/analysis-store-context"
+
+function getFullName(contact: FlowNodeContactData): string {
+ if (contact.firstName || contact.lastName) {
+ return [contact.firstName, contact.lastName].filter(Boolean).join(" ")
+ }
+ return contact.sourceId ?? "-"
+}
+
+function getInitial(contact: FlowNodeContactData): string {
+ return contact.firstName?.[0]?.toUpperCase() ?? "?"
+}
+
+export function ReflinkContactsTable() {
+ const t = useTranslations()
+ const {
+ reflinkContacts: contacts,
+ reflinkContactsPage: page,
+ reflinkContactsPageCount: pageCount,
+ setReflinkContactsPage,
+ loading,
+ } = useAnalysisStore((state) => state)
+
+ return (
+
+
+
+
+
+
+ {t("fields.name.label")}
+ {t("analytics.date")}
+ {t("fields.source.label")}
+
+
+
+ {contacts.length > 0 ? (
+ contacts.map((contact) => (
+
+
+
+
+ {getInitial(contact)}
+
+
+
+ {getFullName(contact)}
+
+
+ {format(new Date(contact.occurredAt), "MMM d, yyyy")}
+
+ {contact.sourceId ?? "-"}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+
+ {pageCount > 1 && (
+
+
+
+ setReflinkContactsPage(page - 1)}
+ />
+
+
+
+ {page} / {pageCount}
+
+
+
+ = pageCount || loading}
+ className={
+ page >= pageCount || loading
+ ? "pointer-events-none opacity-50"
+ : "cursor-pointer"
+ }
+ onClick={() => setReflinkContactsPage(page + 1)}
+ />
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/analytics-nextjs/src/components/charts/reflink-stats-chart.tsx b/packages/analytics-nextjs/src/components/charts/reflink-stats-chart.tsx
new file mode 100644
index 000000000..faff5f4bd
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/charts/reflink-stats-chart.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import AreaChart from "@chatbotx.io/ui/components/charts/area-chart"
+import { format } from "date-fns"
+import { useTranslations } from "next-intl"
+import { useAnalysisStore } from "../../provider/analysis-store-context"
+
+export function ReflinkStatsChart() {
+ const t = useTranslations()
+
+ const refLinkStats = useAnalysisStore((state) => state.refLinkStats)
+ const linkName = useAnalysisStore(
+ (state) => state.defaultSearchParams.linkName ?? "",
+ )
+
+ return (
+ ({
+ label: format(new Date(row.dateReport), "MMM d"),
+ value: row.count,
+ }))}
+ title={t("analytics.sessionsThroughTheRef", { ref: linkName })}
+ valueLabel={t("analytics.total")}
+ />
+ )
+}
diff --git a/packages/analytics-nextjs/src/components/charts/reflink-stats-table.tsx b/packages/analytics-nextjs/src/components/charts/reflink-stats-table.tsx
new file mode 100644
index 000000000..3383e210c
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/charts/reflink-stats-table.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@chatbotx.io/ui/components/ui/table"
+import { format } from "date-fns"
+import { useTranslations } from "next-intl"
+import { useAnalysisStore } from "../../provider/analysis-store-context"
+
+export function ReflinkStatsTable() {
+ const t = useTranslations("analytics")
+ const refLinkStats = useAnalysisStore((state) => state.refLinkStats)
+
+ return (
+
+
+
+
+ {t("date")}
+ {t("total")}
+
+
+
+ {refLinkStats.length > 0 ? (
+ refLinkStats.map((row) => (
+
+
+ {format(new Date(row.dateReport), "MMM d, yyyy")}
+
+ {row.count}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+ )
+}
diff --git a/packages/analytics-nextjs/src/components/magic-link-analytics.tsx b/packages/analytics-nextjs/src/components/magic-link-analytics.tsx
new file mode 100644
index 000000000..a27f2fa48
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/magic-link-analytics.tsx
@@ -0,0 +1,26 @@
+import { AnalysisStoreProvider } from "../provider/analysis-store-context"
+import { MagicLinkContactsTable } from "./charts/magic-link-contacts-table"
+import { MagicLinkStatsChart } from "./charts/magic-link-stats-chart"
+import { MagicLinkStatsTable } from "./charts/magic-link-stats-table"
+import AnalysisFilterForm from "./filter-form"
+
+export function MagicLinkAnalytics({
+ defaultSearchParams,
+}: {
+ defaultSearchParams: { [x: string]: string }
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/analytics-nextjs/src/components/reflink-analytics.tsx b/packages/analytics-nextjs/src/components/reflink-analytics.tsx
new file mode 100644
index 000000000..4f978919b
--- /dev/null
+++ b/packages/analytics-nextjs/src/components/reflink-analytics.tsx
@@ -0,0 +1,26 @@
+import { AnalysisStoreProvider } from "../provider/analysis-store-context"
+import { ReflinkContactsTable } from "./charts/reflink-contacts-table"
+import { ReflinkStatsChart } from "./charts/reflink-stats-chart"
+import { ReflinkStatsTable } from "./charts/reflink-stats-table"
+import AnalysisFilterForm from "./filter-form"
+
+export function ReflinkAnalytics({
+ defaultSearchParams,
+}: {
+ defaultSearchParams: { [x: string]: string }
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/analytics-nextjs/src/provider/analysis-store-context.tsx b/packages/analytics-nextjs/src/provider/analysis-store-context.tsx
index 8d8c62246..625e5b512 100644
--- a/packages/analytics-nextjs/src/provider/analysis-store-context.tsx
+++ b/packages/analytics-nextjs/src/provider/analysis-store-context.tsx
@@ -17,6 +17,7 @@ export const AnalysisStoreContext = createContext(
)
export type AnalysisStoreProviderProps = {
+ type?: "dashboard" | "reflinks" | "magic-links"
defaultSearchParams: { [x: string]: string }
children: ReactNode
autoInitialize?: boolean
@@ -25,11 +26,12 @@ export type AnalysisStoreProviderProps = {
export const AnalysisStoreProvider = ({
children,
autoInitialize = true,
+ type = "dashboard",
defaultSearchParams,
}: AnalysisStoreProviderProps) => {
const storeRef = useRef(null)
if (!storeRef.current) {
- storeRef.current = createAnalysisStore({ defaultSearchParams })
+ storeRef.current = createAnalysisStore({ type, defaultSearchParams })
}
useEffect(() => {
diff --git a/packages/analytics-nextjs/src/provider/analysis-store.ts b/packages/analytics-nextjs/src/provider/analysis-store.ts
index 9dd42fd3f..329350ddf 100644
--- a/packages/analytics-nextjs/src/provider/analysis-store.ts
+++ b/packages/analytics-nextjs/src/provider/analysis-store.ts
@@ -23,15 +23,22 @@ import type {
GetMessagesStatsResponseSchema,
GetUniqueConversationsByAdminResponse,
HumanAgentStats,
+ ListFlowNodeContactsResponse,
MessagesByAdminStats,
MessagesBySenderStats,
+ RefLinkTimeseriesRow,
UniqueConversationsByAdminStats,
} from "@chatbotx.io/analytics"
import { endOfToday, startOfToday, subDays } from "date-fns"
import ky, { HTTPError } from "ky"
import { createStore } from "zustand/vanilla"
+const REFLINK_CONTACTS_PER_PAGE = 10
+
+export type AnalysisDashboardType = "dashboard" | "reflinks" | "magic-links"
+
export type AnalysisState = {
+ type: AnalysisDashboardType
loading: boolean
errors: Map
@@ -63,6 +70,18 @@ export type AnalysisState = {
botMessagesWithResponse: BotMessageStats[]
botMessagesNoResponse: BotMessageStats[]
humanAgentStats: HumanAgentStats[]
+
+ // reflink stats
+ refLinkStats: RefLinkTimeseriesRow[]
+ reflinkContacts: ListFlowNodeContactsResponse["data"]
+ reflinkContactsPage: number
+ reflinkContactsPageCount: number
+
+ // magic-link stats
+ magicLinkStats: RefLinkTimeseriesRow[]
+ magicLinkContacts: ListFlowNodeContactsResponse["data"]
+ magicLinkContactsPage: number
+ magicLinkContactsPageCount: number
}
export type AnalysisActions = {
@@ -94,12 +113,21 @@ export type AnalysisActions = {
getBotMessagesWithResponse: () => Promise
getBotMessagesNoResponse: () => Promise
getHumanAgentStats: () => Promise
+
+ getRefLinkStats: () => Promise
+ getReflinkContacts: () => Promise
+ setReflinkContactsPage: (page: number) => Promise
+
+ getMagicLinkStats: () => Promise
+ getMagicLinkContacts: () => Promise
+ setMagicLinkContactsPage: (page: number) => Promise
}
export type AnalysisStore = AnalysisState & AnalysisActions
export const createAnalysisStore = (props: Partial) =>
createStore((set, get) => ({
+ type: "dashboard",
loading: false,
errors: new Map(),
@@ -134,6 +162,18 @@ export const createAnalysisStore = (props: Partial) =>
botMessagesNoResponse: [],
humanAgentStats: [],
+ // Default reflink stats
+ refLinkStats: [],
+ reflinkContacts: [],
+ reflinkContactsPage: 1,
+ reflinkContactsPageCount: 0,
+
+ // Default magic-link stats
+ magicLinkStats: [],
+ magicLinkContacts: [],
+ magicLinkContactsPage: 1,
+ magicLinkContactsPageCount: 0,
+
initialize: async () => {
const { loadAnalysisData } = get()
await loadAnalysisData()
@@ -154,6 +194,24 @@ export const createAnalysisStore = (props: Partial) =>
},
loadAnalysisData: async () => {
+ const { type } = get()
+
+ if (type === "reflinks") {
+ const { getRefLinkStats, getReflinkContacts } = get()
+ set({ loading: true, errors: new Map() })
+ await Promise.all([getRefLinkStats(), getReflinkContacts()])
+ set({ loading: false })
+ return
+ }
+
+ if (type === "magic-links") {
+ const { getMagicLinkStats, getMagicLinkContacts } = get()
+ set({ loading: true, errors: new Map() })
+ await Promise.all([getMagicLinkStats(), getMagicLinkContacts()])
+ set({ loading: false })
+ return
+ }
+
const {
getContactCounts,
getNewContactCounts,
@@ -684,4 +742,108 @@ export const createAnalysisStore = (props: Partial) =>
get().handleError("getHumanAgentStats", error)
}
},
+
+ getRefLinkStats: async () => {
+ const { defaultSearchParams, from, to } = get()
+
+ try {
+ const { data: refLinkStats } = await ky
+ .get("/api/analytics/ref-links-stats", {
+ searchParams: {
+ ...defaultSearchParams,
+ startDate: from.toISOString(),
+ endDate: to.toISOString(),
+ },
+ })
+ .json<{ data: RefLinkTimeseriesRow[] }>()
+
+ set({ refLinkStats })
+ } catch (error: unknown) {
+ get().handleError("getRefLinkStats", error)
+ }
+ },
+
+ getReflinkContacts: async () => {
+ const { defaultSearchParams, reflinkContactsPage, from, to } = get()
+
+ try {
+ const result = await ky
+ .get("/api/analytics/ref-links-contacts", {
+ searchParams: {
+ ...defaultSearchParams,
+ page: reflinkContactsPage,
+ perPage: REFLINK_CONTACTS_PER_PAGE,
+ startDate: from.toISOString(),
+ endDate: to.toISOString(),
+ },
+ })
+ .json()
+
+ set({
+ reflinkContacts: result.data,
+ reflinkContactsPageCount: result.pageCount,
+ })
+ } catch (error: unknown) {
+ get().handleError("getReflinkContacts", error)
+ }
+ },
+
+ setReflinkContactsPage: async (page: number) => {
+ set({ reflinkContactsPage: page })
+
+ const { getReflinkContacts } = get()
+ await getReflinkContacts()
+ },
+
+ getMagicLinkStats: async () => {
+ const { defaultSearchParams, from, to } = get()
+
+ try {
+ const { data: magicLinkStats } = await ky
+ .get("/api/analytics/magic-links-stats", {
+ searchParams: {
+ ...defaultSearchParams,
+ startDate: from.toISOString(),
+ endDate: to.toISOString(),
+ },
+ })
+ .json<{ data: RefLinkTimeseriesRow[] }>()
+
+ set({ magicLinkStats })
+ } catch (error: unknown) {
+ get().handleError("getMagicLinkStats", error)
+ }
+ },
+
+ getMagicLinkContacts: async () => {
+ const { defaultSearchParams, magicLinkContactsPage, from, to } = get()
+
+ try {
+ const result = await ky
+ .get("/api/analytics/magic-links-contacts", {
+ searchParams: {
+ ...defaultSearchParams,
+ page: magicLinkContactsPage,
+ perPage: REFLINK_CONTACTS_PER_PAGE,
+ startDate: from.toISOString(),
+ endDate: to.toISOString(),
+ },
+ })
+ .json()
+
+ set({
+ magicLinkContacts: result.data,
+ magicLinkContactsPageCount: result.pageCount,
+ })
+ } catch (error: unknown) {
+ get().handleError("getMagicLinkContacts", error)
+ }
+ },
+
+ setMagicLinkContactsPage: async (page: number) => {
+ set({ magicLinkContactsPage: page })
+
+ const { getMagicLinkContacts } = get()
+ await getMagicLinkContacts()
+ },
}))
diff --git a/packages/analytics-nextjs/src/routes/index.ts b/packages/analytics-nextjs/src/routes/index.ts
index 61561a87c..821bfeb8d 100644
--- a/packages/analytics-nextjs/src/routes/index.ts
+++ b/packages/analytics-nextjs/src/routes/index.ts
@@ -4,7 +4,9 @@ import { analyticsContactRoutes } from "./contact"
import { analyticsConversationRoutes } from "./conversation"
import { analyticsFlowRoutes } from "./flow"
import { analyticsMacRoutes } from "./mac"
+import { analyticsMagicLinkRoutes } from "./magic-link"
import { analyticsMessageRoutes } from "./message"
+import { analyticsReflinkRoutes } from "./reflink"
import { analyticsSequenceRoutes } from "./sequence"
export const analyticsRoutes = os.router({
@@ -15,4 +17,6 @@ export const analyticsRoutes = os.router({
...analyticsSequenceRoutes,
...analyticsFlowRoutes,
...analyticsMacRoutes,
+ ...analyticsReflinkRoutes,
+ ...analyticsMagicLinkRoutes,
})
diff --git a/packages/analytics-nextjs/src/routes/magic-link.ts b/packages/analytics-nextjs/src/routes/magic-link.ts
new file mode 100644
index 000000000..c7cbe714f
--- /dev/null
+++ b/packages/analytics-nextjs/src/routes/magic-link.ts
@@ -0,0 +1,49 @@
+import {
+ listFlowNodeContactsResponse,
+ magicLinkAnalyticsService,
+ magicLinkContactStatsSchema,
+ magicLinkStatsSchema,
+ refLinkTimeseriesRow,
+} from "@chatbotx.io/analytics"
+import { os } from "@orpc/server"
+import { z } from "zod"
+import { logger } from "../lib/log"
+
+export const analyticsMagicLinkRoutes = os.router({
+ magicLinkStats: os
+ .route({
+ method: "GET",
+ path: "/analytics/magic-links-stats",
+ summary: "Get magic link stats",
+ tags: ["Analytics"],
+ })
+ .input(magicLinkStatsSchema)
+ .output(z.object({ data: z.array(refLinkTimeseriesRow) }))
+ .handler(async ({ input }) => {
+ try {
+ const data =
+ await magicLinkAnalyticsService.getMagicLinkStatsByDateRange(input)
+ return { data }
+ } catch (error) {
+ logger.error({ err: error }, "[analytics:magicLinkStats] failed")
+ throw error
+ }
+ }),
+ magicLinkContacts: os
+ .route({
+ method: "GET",
+ path: "/analytics/magic-links-contacts",
+ summary: "Get magic link contacts",
+ tags: ["Analytics"],
+ })
+ .input(magicLinkContactStatsSchema)
+ .output(listFlowNodeContactsResponse)
+ .handler(async ({ input }) => {
+ try {
+ return await magicLinkAnalyticsService.getMagicLinkContactStats(input)
+ } catch (error) {
+ logger.error({ err: error }, "[analytics:magicLinkContacts] failed")
+ throw error
+ }
+ }),
+})
diff --git a/packages/analytics-nextjs/src/routes/reflink.ts b/packages/analytics-nextjs/src/routes/reflink.ts
new file mode 100644
index 000000000..19cf6ee3b
--- /dev/null
+++ b/packages/analytics-nextjs/src/routes/reflink.ts
@@ -0,0 +1,49 @@
+import {
+ listFlowNodeContactsResponse,
+ magicLinkContactStatsSchema,
+ magicLinkStatsSchema,
+ refLinkAnalyticsService,
+ refLinkTimeseriesRow,
+} from "@chatbotx.io/analytics"
+import { os } from "@orpc/server"
+import { z } from "zod"
+import { logger } from "../lib/log"
+
+export const analyticsReflinkRoutes = os.router({
+ refLinkStats: os
+ .route({
+ method: "GET",
+ path: "/analytics/ref-links-stats",
+ summary: "Get ref link stats",
+ tags: ["Analytics"],
+ })
+ .input(magicLinkStatsSchema)
+ .output(z.object({ data: z.array(refLinkTimeseriesRow) }))
+ .handler(async ({ input }) => {
+ try {
+ const data =
+ await refLinkAnalyticsService.getRefLinkStatsByDateRange(input)
+ return { data }
+ } catch (error) {
+ logger.error({ err: error }, "[analytics:refLinkStats] failed")
+ throw error
+ }
+ }),
+ refLinkContacts: os
+ .route({
+ method: "GET",
+ path: "/analytics/ref-links-contacts",
+ summary: "Get ref link contacts",
+ tags: ["Analytics"],
+ })
+ .input(magicLinkContactStatsSchema)
+ .output(listFlowNodeContactsResponse)
+ .handler(async ({ input }) => {
+ try {
+ return await refLinkAnalyticsService.getRefLinkContactStats(input)
+ } catch (error) {
+ logger.error({ err: error }, "[analytics:refLinkContacts] failed")
+ throw error
+ }
+ }),
+})
diff --git a/packages/analytics/__tests__/magic-link-analytics.service.test.ts b/packages/analytics/__tests__/magic-link-analytics.service.test.ts
new file mode 100644
index 000000000..f63ff93e8
--- /dev/null
+++ b/packages/analytics/__tests__/magic-link-analytics.service.test.ts
@@ -0,0 +1,240 @@
+import { beforeEach, describe, expect, test, vi } from "vitest"
+
+// ── db mock ──────────────────────────────────────────────────────────────────
+
+const capturedInsertValues: unknown[] = []
+
+const builder: Record = {}
+builder.values = vi.fn((payload: unknown) => {
+ if (Array.isArray(payload)) {
+ capturedInsertValues.push(...payload)
+ } else {
+ capturedInsertValues.push(payload)
+ }
+ return builder
+})
+builder.onConflictDoNothing = vi.fn(() => builder)
+
+const db = {
+ insert: vi.fn(() => builder),
+}
+
+vi.mock("@chatbotx.io/database/client", () => ({ db }))
+
+// ── schema mock ───────────────────────────────────────────────────────────────
+
+const magicLinkStatModel = { workspaceId: "ws_col", linkId: "link_col" }
+
+vi.mock("@chatbotx.io/database/schema", () => ({ magicLinkStatModel }))
+
+// ── repository mock ───────────────────────────────────────────────────────────
+
+const magicLinkStatsRepository = {
+ getStatsByDateRange: vi.fn(),
+ getContactStats: vi.fn(),
+ getContactCount: vi.fn(),
+}
+
+vi.mock("../src/repositories/postgres/magic-link-stats.repository", () => ({
+ magicLinkStatsRepository,
+}))
+
+// ── listLinkContactStats mock ─────────────────────────────────────────────────
+
+const listLinkContactStats = vi.fn()
+
+vi.mock("../src/services/link-contact-stats", () => ({ listLinkContactStats }))
+
+// ── subject ───────────────────────────────────────────────────────────────────
+
+const { MagicLinkAnalyticsService } = await import(
+ "../src/services/magic-link-analytics.service"
+)
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+function makePayload(
+ overrides: {
+ magicLinkId?: string | null
+ clickType?: string
+ workspaceId?: string
+ contactId?: string
+ contactInboxId?: string
+ } = {},
+) {
+ // null means "omit magicLinkId from action" to test the absent-field filter
+ const hasMagicLinkId =
+ !("magicLinkId" in overrides) || overrides.magicLinkId != null
+ return {
+ context: {
+ workspaceId: overrides.workspaceId ?? "ws-1",
+ contactId: overrides.contactId ?? "c-1",
+ contactInboxId: overrides.contactInboxId ?? "ci-1",
+ },
+ action: {
+ flowId: "flow-1",
+ clickType: overrides.clickType ?? "magic_link",
+ ...(hasMagicLinkId
+ ? { magicLinkId: overrides.magicLinkId ?? "ml-1" }
+ : {}),
+ },
+ occurredAt: new Date("2026-06-01T10:00:00.000Z"),
+ }
+}
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ capturedInsertValues.length = 0
+ vi.clearAllMocks()
+})
+
+describe("MagicLinkAnalyticsService — onClicked filtering", () => {
+ test("inserts one record for a valid magic_link payload", async () => {
+ const svc = new MagicLinkAnalyticsService()
+ await svc.onClicked([makePayload()])
+
+ expect(db.insert).toHaveBeenCalledTimes(1)
+ expect(capturedInsertValues).toHaveLength(1)
+
+ const row = capturedInsertValues[0] as Record
+ expect(row.workspaceId).toBe("ws-1")
+ expect(row.linkId).toBe("ml-1")
+ expect(row.contactId).toBe("c-1")
+ expect(row.contactInboxId).toBe("ci-1")
+ })
+
+ test("skips payloads without a magicLinkId", async () => {
+ const svc = new MagicLinkAnalyticsService()
+ await svc.onClicked([makePayload({ magicLinkId: null })])
+
+ expect(db.insert).not.toHaveBeenCalled()
+ })
+
+ test("skips payloads with a non-magic_link clickType", async () => {
+ const svc = new MagicLinkAnalyticsService()
+ await svc.onClicked([
+ makePayload({ clickType: "button" }),
+ makePayload({ clickType: "quick_reply" }),
+ ])
+
+ expect(db.insert).not.toHaveBeenCalled()
+ })
+
+ test("only inserts the valid payloads from a mixed batch", async () => {
+ const svc = new MagicLinkAnalyticsService()
+ await svc.onClicked([
+ makePayload({ clickType: "button" }),
+ makePayload({ magicLinkId: "ml-valid", contactInboxId: "ci-2" }),
+ makePayload({ magicLinkId: null }),
+ ])
+
+ expect(capturedInsertValues).toHaveLength(1)
+ const row = capturedInsertValues[0] as Record
+ expect(row.linkId).toBe("ml-valid")
+ expect(row.contactInboxId).toBe("ci-2")
+ })
+
+ test("inserts multiple valid payloads in one batch", async () => {
+ const svc = new MagicLinkAnalyticsService()
+ await svc.onClicked([
+ makePayload({ magicLinkId: "ml-a", contactInboxId: "ci-a" }),
+ makePayload({ magicLinkId: "ml-b", contactInboxId: "ci-b" }),
+ ])
+
+ expect(capturedInsertValues).toHaveLength(2)
+ })
+
+ test("is a no-op for an empty payload list", async () => {
+ const svc = new MagicLinkAnalyticsService()
+ await svc.onClicked([])
+
+ expect(db.insert).not.toHaveBeenCalled()
+ })
+})
+
+describe("MagicLinkAnalyticsService — getMagicLinkStatsByDateRange", () => {
+ test("delegates to the repository with the correct params", async () => {
+ magicLinkStatsRepository.getStatsByDateRange.mockResolvedValueOnce([])
+ const svc = new MagicLinkAnalyticsService()
+
+ await svc.getMagicLinkStatsByDateRange({
+ workspaceId: "ws-1",
+ linkId: "ml-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-07T23:59:59.999Z",
+ timezone: "Asia/Ho_Chi_Minh",
+ })
+
+ expect(magicLinkStatsRepository.getStatsByDateRange).toHaveBeenCalledWith({
+ workspaceId: "ws-1",
+ linkId: "ml-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-07T23:59:59.999Z",
+ timezone: "Asia/Ho_Chi_Minh",
+ })
+ })
+
+ test("sorts rows by dateReport ascending", async () => {
+ magicLinkStatsRepository.getStatsByDateRange.mockResolvedValueOnce([
+ { dateReport: "2026-06-03", count: 3 },
+ { dateReport: "2026-06-01", count: 1 },
+ { dateReport: "2026-06-02", count: 2 },
+ ])
+ const svc = new MagicLinkAnalyticsService()
+
+ const rows = await svc.getMagicLinkStatsByDateRange({
+ workspaceId: "ws-1",
+ linkId: "ml-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-03T23:59:59.999Z",
+ timezone: "UTC",
+ })
+
+ expect(rows.map((r) => r.dateReport)).toEqual([
+ "2026-06-01",
+ "2026-06-02",
+ "2026-06-03",
+ ])
+ })
+
+ test("returns an empty array when the repository returns nothing", async () => {
+ magicLinkStatsRepository.getStatsByDateRange.mockResolvedValueOnce([])
+ const svc = new MagicLinkAnalyticsService()
+
+ const rows = await svc.getMagicLinkStatsByDateRange({
+ workspaceId: "ws-1",
+ linkId: "ml-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-07T23:59:59.999Z",
+ timezone: "UTC",
+ })
+
+ expect(rows).toEqual([])
+ })
+})
+
+describe("MagicLinkAnalyticsService — getMagicLinkContactStats", () => {
+ test("delegates to listLinkContactStats with the input params", async () => {
+ listLinkContactStats.mockResolvedValueOnce({
+ data: [],
+ total: 0,
+ page: 1,
+ pageCount: 0,
+ })
+
+ const svc = new MagicLinkAnalyticsService()
+ const input = {
+ workspaceId: "ws-1",
+ linkId: "ml-1",
+ page: 1,
+ perPage: 10,
+ }
+
+ await svc.getMagicLinkContactStats(input)
+
+ expect(listLinkContactStats).toHaveBeenCalledWith(
+ expect.objectContaining({ params: input }),
+ )
+ })
+})
diff --git a/packages/analytics/__tests__/ref-link-analytics.service.test.ts b/packages/analytics/__tests__/ref-link-analytics.service.test.ts
new file mode 100644
index 000000000..bf4aa35ee
--- /dev/null
+++ b/packages/analytics/__tests__/ref-link-analytics.service.test.ts
@@ -0,0 +1,245 @@
+import { beforeEach, describe, expect, test, vi } from "vitest"
+
+// ── db mock ──────────────────────────────────────────────────────────────────
+
+const capturedInsertValues: unknown[] = []
+
+const builder: Record = {}
+builder.values = vi.fn((payload: unknown) => {
+ if (Array.isArray(payload)) {
+ capturedInsertValues.push(...payload)
+ } else {
+ capturedInsertValues.push(payload)
+ }
+ return builder
+})
+builder.onConflictDoNothing = vi.fn(() => builder)
+
+const db = {
+ insert: vi.fn(() => builder),
+}
+
+vi.mock("@chatbotx.io/database/client", () => ({ db }))
+
+// ── schema mock ───────────────────────────────────────────────────────────────
+
+const refLinkStatModel = { workspaceId: "ws_col", linkId: "link_col" }
+
+vi.mock("@chatbotx.io/database/schema", () => ({ refLinkStatModel }))
+
+// ── repository mock ───────────────────────────────────────────────────────────
+
+const refLinkStatsRepository = {
+ getStatsByDateRange: vi.fn(),
+ getContactStats: vi.fn(),
+ getContactCount: vi.fn(),
+}
+
+vi.mock("../src/repositories/postgres/ref-link-stats.repository", () => ({
+ refLinkStatsRepository,
+}))
+
+// ── listLinkContactStats mock ─────────────────────────────────────────────────
+
+const listLinkContactStats = vi.fn()
+
+vi.mock("../src/services/link-contact-stats", () => ({ listLinkContactStats }))
+
+// ── subject ───────────────────────────────────────────────────────────────────
+
+const { RefLinkAnalyticsService } = await import(
+ "../src/services/ref-link-analytics.service"
+)
+
+// ── helpers ───────────────────────────────────────────────────────────────────
+
+function makePayload(
+ overrides: {
+ refId?: string | null
+ workspaceId?: string
+ contactId?: string
+ contactInboxId?: string
+ } = {},
+) {
+ const refId =
+ overrides.refId === undefined ? "ref-1" : (overrides.refId ?? undefined)
+ return {
+ context: {
+ workspaceId: overrides.workspaceId ?? "ws-1",
+ contactId: overrides.contactId ?? "c-1",
+ contactInboxId: overrides.contactInboxId ?? "ci-1",
+ },
+ action: {
+ ...(refId === undefined ? {} : { refId }),
+ refType: "entryPoint" as const,
+ },
+ occurredAt: new Date("2026-06-01T10:00:00.000Z"),
+ }
+}
+
+// ── tests ─────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ capturedInsertValues.length = 0
+ vi.clearAllMocks()
+})
+
+describe("RefLinkAnalyticsService — handler filtering", () => {
+ test("inserts one record for a valid reflink payload", async () => {
+ const svc = new RefLinkAnalyticsService()
+ await svc.handler([makePayload()])
+
+ expect(db.insert).toHaveBeenCalledTimes(1)
+ expect(capturedInsertValues).toHaveLength(1)
+
+ const row = capturedInsertValues[0] as Record
+ expect(row.workspaceId).toBe("ws-1")
+ expect(row.linkId).toBe("ref-1")
+ expect(row.contactId).toBe("c-1")
+ expect(row.contactInboxId).toBe("ci-1")
+ })
+
+ test("skips payloads without a refId", async () => {
+ const svc = new RefLinkAnalyticsService()
+ await svc.handler([makePayload({ refId: null })])
+
+ expect(db.insert).not.toHaveBeenCalled()
+ })
+
+ test("only inserts the valid payloads from a mixed batch", async () => {
+ const svc = new RefLinkAnalyticsService()
+ await svc.handler([
+ makePayload({ refId: null }),
+ makePayload({ refId: "ref-valid", contactInboxId: "ci-2" }),
+ ])
+
+ expect(capturedInsertValues).toHaveLength(1)
+ const row = capturedInsertValues[0] as Record
+ expect(row.linkId).toBe("ref-valid")
+ expect(row.contactInboxId).toBe("ci-2")
+ })
+
+ test("inserts multiple valid payloads in one batch", async () => {
+ const svc = new RefLinkAnalyticsService()
+ await svc.handler([
+ makePayload({ refId: "ref-a", contactInboxId: "ci-a" }),
+ makePayload({ refId: "ref-b", contactInboxId: "ci-b" }),
+ ])
+
+ expect(capturedInsertValues).toHaveLength(2)
+ })
+
+ test("is a no-op for an empty payload list", async () => {
+ const svc = new RefLinkAnalyticsService()
+ await svc.handler([])
+
+ expect(db.insert).not.toHaveBeenCalled()
+ })
+})
+
+describe("RefLinkAnalyticsService — getRefLinkStatsByDateRange", () => {
+ test("delegates to the repository with the correct params", async () => {
+ refLinkStatsRepository.getStatsByDateRange.mockResolvedValueOnce([])
+ const svc = new RefLinkAnalyticsService()
+
+ await svc.getRefLinkStatsByDateRange({
+ workspaceId: "ws-1",
+ linkId: "ref-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-07T23:59:59.999Z",
+ timezone: "Asia/Ho_Chi_Minh",
+ })
+
+ expect(refLinkStatsRepository.getStatsByDateRange).toHaveBeenCalledWith({
+ workspaceId: "ws-1",
+ linkId: "ref-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-07T23:59:59.999Z",
+ timezone: "Asia/Ho_Chi_Minh",
+ })
+ })
+
+ test("sorts rows by dateReport ascending", async () => {
+ refLinkStatsRepository.getStatsByDateRange.mockResolvedValueOnce([
+ { dateReport: "2026-06-03", count: 5 },
+ { dateReport: "2026-06-01", count: 1 },
+ { dateReport: "2026-06-02", count: 3 },
+ ])
+ const svc = new RefLinkAnalyticsService()
+
+ const rows = await svc.getRefLinkStatsByDateRange({
+ workspaceId: "ws-1",
+ linkId: "ref-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-03T23:59:59.999Z",
+ timezone: "UTC",
+ })
+
+ expect(rows.map((r) => r.dateReport)).toEqual([
+ "2026-06-01",
+ "2026-06-02",
+ "2026-06-03",
+ ])
+ })
+
+ test("returns an empty array when the repository returns nothing", async () => {
+ refLinkStatsRepository.getStatsByDateRange.mockResolvedValueOnce([])
+ const svc = new RefLinkAnalyticsService()
+
+ const rows = await svc.getRefLinkStatsByDateRange({
+ workspaceId: "ws-1",
+ linkId: "ref-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-07T23:59:59.999Z",
+ timezone: "UTC",
+ })
+
+ expect(rows).toEqual([])
+ })
+
+ test("preserves already-sorted rows unchanged", async () => {
+ const sorted = [
+ { dateReport: "2026-06-01", count: 1 },
+ { dateReport: "2026-06-02", count: 2 },
+ ]
+ refLinkStatsRepository.getStatsByDateRange.mockResolvedValueOnce([
+ ...sorted,
+ ])
+ const svc = new RefLinkAnalyticsService()
+
+ const rows = await svc.getRefLinkStatsByDateRange({
+ workspaceId: "ws-1",
+ linkId: "ref-1",
+ startDate: "2026-06-01T00:00:00.000Z",
+ endDate: "2026-06-02T23:59:59.999Z",
+ timezone: "UTC",
+ })
+
+ expect(rows).toEqual(sorted)
+ })
+})
+
+describe("RefLinkAnalyticsService — getRefLinkContactStats", () => {
+ test("delegates to listLinkContactStats with the input params", async () => {
+ listLinkContactStats.mockResolvedValueOnce({
+ data: [],
+ total: 0,
+ page: 1,
+ pageCount: 0,
+ })
+
+ const svc = new RefLinkAnalyticsService()
+ const input = {
+ workspaceId: "ws-1",
+ linkId: "ref-1",
+ page: 1,
+ perPage: 10,
+ }
+
+ await svc.getRefLinkContactStats(input)
+
+ expect(listLinkContactStats).toHaveBeenCalledWith(
+ expect.objectContaining({ params: input }),
+ )
+ })
+})
diff --git a/packages/analytics/src/repositories/postgres/flow-stats.repository.ts b/packages/analytics/src/repositories/postgres/flow-stats.repository.ts
index 3387306dc..c31e554d8 100644
--- a/packages/analytics/src/repositories/postgres/flow-stats.repository.ts
+++ b/packages/analytics/src/repositories/postgres/flow-stats.repository.ts
@@ -1,4 +1,4 @@
-import { and, count, db, eq, sql } from "@chatbotx.io/database/client"
+import { and, count, db, eq, inArray, sql } from "@chatbotx.io/database/client"
import {
conversationModel,
flowAnalyticsSessionModel,
@@ -15,7 +15,6 @@ import type { ContactEventData } from "../../schemas/common"
import type {
FlowNodeEventType,
FlowNodeStatItem,
- FlowNodeStats,
FlowNodeStatsResponse,
FlowStatsRequest,
RemoveFlowStatsRequest,
@@ -23,109 +22,94 @@ import type {
import { BaseRepository } from "./base.repository"
export class FlowStatsRepository extends BaseRepository {
- async getNodeStats(input: {
+ /**
+ * Aggregate per-node counts (delivered / failed / clicked) for every node in
+ * one grouped query instead of per-node round-trips.
+ */
+ private async getNodeEventCounts(input: {
workspaceId: string
- flowId: string
analyticsId: string
- nodeId: string
- }): Promise {
- const { workspaceId, analyticsId, nodeId } = input
+ nodeIds: string[]
+ }): Promise<{
+ deliveredByNode: Map
+ failedByNode: Map
+ clickedByNode: Map
+ }> {
+ const { workspaceId, analyticsId, nodeIds } = input
const t = flowNodeStatModel
- const [statsResult, uniqueDeliveredResult, clickedResult, seenResult] =
- await Promise.all([
- db
- .select({
- eventType: t.eventType,
- total: count(),
- })
- .from(t)
- .where(
- and(
- eq(t.workspaceId, workspaceId),
- eq(t.analyticsId, analyticsId),
- eq(t.nodeId, nodeId),
- sql`${t.eventType} IN ('message:delivered', 'message:failed')`,
- ),
- )
- .groupBy(t.eventType),
- db
- .select({
- count: count(),
- })
- .from(t)
- .where(
- and(
- eq(t.workspaceId, workspaceId),
- eq(t.analyticsId, analyticsId),
- eq(t.nodeId, nodeId),
- eq(t.eventType, messageEventTypeSchema.enum["message:delivered"]),
- ),
- ),
- db
- .select({
- count: count(),
- })
- .from(t)
- .where(
- and(
- eq(t.workspaceId, workspaceId),
- eq(t.analyticsId, analyticsId),
- eq(t.nodeId, nodeId),
- eq(t.eventType, "flow:clicked"),
- ),
- ),
- db
- .select({
- count: count(),
- })
- .from(t)
- .innerJoin(
- conversationModel,
- eq(conversationModel.contactId, t.contactId),
- )
- .where(
- and(
- eq(t.workspaceId, workspaceId),
- eq(t.analyticsId, analyticsId),
- eq(t.nodeId, nodeId),
- eq(t.eventType, messageEventTypeSchema.enum["message:delivered"]),
- sql`${conversationModel.contactLastReadAt} >= ${t.occurredAt}`,
- ),
- ),
- ])
+ const rows = await db
+ .select({ nodeId: t.nodeId, eventType: t.eventType, total: count() })
+ .from(t)
+ .where(
+ and(
+ eq(t.workspaceId, workspaceId),
+ eq(t.analyticsId, analyticsId),
+ inArray(t.nodeId, nodeIds),
+ sql`${t.eventType} IN ('message:delivered', 'message:failed', 'flow:clicked')`,
+ ),
+ )
+ .groupBy(t.nodeId, t.eventType)
- let delivered = 0
- let failed = 0
+ const deliveredByNode = new Map()
+ const failedByNode = new Map()
+ const clickedByNode = new Map()
- for (const row of statsResult) {
+ for (const row of rows) {
+ const total = Number(row.total)
switch (row.eventType) {
case "message:delivered":
- delivered = Number(row.total)
+ deliveredByNode.set(row.nodeId, total)
break
case "message:failed":
- failed = Number(row.total)
+ failedByNode.set(row.nodeId, total)
+ break
+ case "flow:clicked":
+ clickedByNode.set(row.nodeId, total)
break
default:
break
}
}
- const seen = Number(seenResult[0]?.count ?? 0)
+ return { deliveredByNode, failedByNode, clickedByNode }
+ }
- const clicked = Number(clickedResult[0]?.count ?? 0)
- const uniqueDelivered = Number(uniqueDeliveredResult[0]?.count ?? 0)
+ /**
+ * Per-node "seen" counts (delivered messages whose contact read the
+ * conversation afterwards) for every node in one grouped query.
+ */
+ private async getNodeSeenCounts(input: {
+ workspaceId: string
+ analyticsId: string
+ nodeIds: string[]
+ }): Promise