diff --git a/apps/web/app/(app)/organization/[organizationId]/OrganizationTabs.tsx b/apps/web/app/(app)/organization/[organizationId]/OrganizationTabs.tsx new file mode 100644 index 0000000000..a6fed3b4d5 --- /dev/null +++ b/apps/web/app/(app)/organization/[organizationId]/OrganizationTabs.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { TabSelect } from "@/components/TabSelect"; +import { PageHeading } from "@/components/Typography"; +import { LoadingContent } from "@/components/LoadingContent"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useOrganization } from "@/hooks/useOrganization"; + +interface OrganizationTabsProps { + organizationId: string; +} + +export function OrganizationTabs({ organizationId }: OrganizationTabsProps) { + const pathname = usePathname(); + const { + data: organization, + isLoading, + error, + } = useOrganization(organizationId); + + const tabs = [ + { + id: "members", + label: "Members", + href: `/organization/${organizationId}`, + }, + { + id: "stats", + label: "Analytics", + href: `/organization/${organizationId}/stats`, + }, + ]; + + // Determine selected tab based on pathname + const selected = pathname.includes("/stats") ? "stats" : "members"; + + return ( +
+ } + > + {organization?.name && ( + {organization.name} + )} + +
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/organization/[organizationId]/page.tsx b/apps/web/app/(app)/organization/[organizationId]/page.tsx index 39d72122dc..22af255eb1 100644 --- a/apps/web/app/(app)/organization/[organizationId]/page.tsx +++ b/apps/web/app/(app)/organization/[organizationId]/page.tsx @@ -1,5 +1,5 @@ import { Members } from "@/app/(app)/organization/[organizationId]/Members"; -import { PageHeader } from "@/components/PageHeader"; +import { OrganizationTabs } from "@/app/(app)/organization/[organizationId]/OrganizationTabs"; import { PageWrapper } from "@/components/PageWrapper"; export default async function MembersPage({ @@ -11,9 +11,9 @@ export default async function MembersPage({ return ( - + -
+
diff --git a/apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx b/apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx new file mode 100644 index 0000000000..8bb7520167 --- /dev/null +++ b/apps/web/app/(app)/organization/[organizationId]/stats/OrgStats.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import type { DateRange } from "react-day-picker"; +import { subDays } from "date-fns/subDays"; +import { Mail, Sparkles, Users } from "lucide-react"; +import { LoadingContent } from "@/components/LoadingContent"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { DatePickerWithRange } from "@/components/DatePickerWithRange"; +import { useOrgStatsTotals } from "@/hooks/useOrgStatsTotals"; +import { useOrgStatsEmailBuckets } from "@/hooks/useOrgStatsEmailBuckets"; +import { useOrgStatsRulesBuckets } from "@/hooks/useOrgStatsRulesBuckets"; + +const selectOptions = [ + { label: "Last week", value: "7" }, + { label: "Last month", value: "30" }, + { label: "Last 3 months", value: "90" }, + { label: "All time", value: "0" }, +]; +const defaultSelected = selectOptions[1]; + +export function OrgStats({ organizationId }: { organizationId: string }) { + const [dateDropdown, setDateDropdown] = useState( + defaultSelected.label, + ); + + const now = useMemo(() => new Date(), []); + const [dateRange, setDateRange] = useState({ + from: subDays(now, Number.parseInt(defaultSelected.value)), + to: now, + }); + + const onSetDateDropdown = useCallback( + (option: { label: string; value: string }) => { + setDateDropdown(option.label); + }, + [], + ); + + const options = useMemo( + () => ({ + fromDate: dateRange?.from?.getTime(), + toDate: dateRange?.to?.getTime(), + }), + [dateRange], + ); + + const { + data: totalsData, + isLoading: totalsLoading, + error: totalsError, + } = useOrgStatsTotals(organizationId, options); + + const { + data: emailBucketsData, + isLoading: emailBucketsLoading, + error: emailBucketsError, + } = useOrgStatsEmailBuckets(organizationId, options); + + const { + data: rulesBucketsData, + isLoading: rulesBucketsLoading, + error: rulesBucketsError, + } = useOrgStatsRulesBuckets(organizationId, options); + + return ( +
+
+ +
+ +
+ + + + +
+ } + > + {totalsData && ( +
+ } + /> + } + /> + } + /> +
+ )} + + +
+ } + > + {emailBucketsData && ( + + )} + + + } + > + {rulesBucketsData && ( + + )} + +
+
+
+ ); +} + +function StatCard({ + title, + value, + icon, +}: { + title: string; + value: string; + icon: React.ReactNode; +}) { + return ( + + + {title} + {icon} + + +
{value}
+
+
+ ); +} + +function BucketChart({ + title, + description, + data, + emptyMessage, + unit = "emails", +}: { + title: string; + description: string; + data: { label: string; userCount: number }[]; + emptyMessage: string; + unit?: string; +}) { + const hasData = data.some((bucket) => bucket.userCount > 0); + const maxValue = Math.max(...data.map((d) => d.userCount), 1); + + return ( + + + {title} +

{description}

+
+ + {!hasData ? ( +
+

+ {emptyMessage} +

+
+ ) : ( +
+ {data.map((bucket) => ( +
+
+ + {bucket.label} {unit} + + + {bucket.userCount}{" "} + {bucket.userCount === 1 ? "user" : "users"} + +
+
+
+
+
+ ))} +
+ )} + + + ); +} diff --git a/apps/web/app/(app)/organization/[organizationId]/stats/page.tsx b/apps/web/app/(app)/organization/[organizationId]/stats/page.tsx new file mode 100644 index 0000000000..421cd166c4 --- /dev/null +++ b/apps/web/app/(app)/organization/[organizationId]/stats/page.tsx @@ -0,0 +1,21 @@ +import { PageWrapper } from "@/components/PageWrapper"; +import { OrgStats } from "@/app/(app)/organization/[organizationId]/stats/OrgStats"; +import { OrganizationTabs } from "@/app/(app)/organization/[organizationId]/OrganizationTabs"; + +export default async function OrgStatsPage({ + params, +}: { + params: Promise<{ organizationId: string }>; +}) { + const { organizationId } = await params; + + return ( + + + +
+ +
+
+ ); +} diff --git a/apps/web/app/api/organizations/[organizationId]/route.ts b/apps/web/app/api/organizations/[organizationId]/route.ts new file mode 100644 index 0000000000..3214375d88 --- /dev/null +++ b/apps/web/app/api/organizations/[organizationId]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withAuth } from "@/utils/middleware"; +import { fetchAndCheckIsAdmin } from "@/utils/organizations/access"; + +export type OrganizationResponse = Awaited>; + +export const GET = withAuth( + "organizations/get", + async (request, { params }) => { + const { userId } = request.auth; + const { organizationId } = await params; + + await fetchAndCheckIsAdmin({ organizationId, userId }); + + const result = await getOrganization({ organizationId }); + + return NextResponse.json(result); + }, +); + +async function getOrganization({ organizationId }: { organizationId: string }) { + const organization = await prisma.organization.findUnique({ + where: { id: organizationId }, + select: { id: true, name: true }, + }); + + return organization; +} diff --git a/apps/web/app/api/organizations/[organizationId]/stats/email-buckets/route.ts b/apps/web/app/api/organizations/[organizationId]/stats/email-buckets/route.ts new file mode 100644 index 0000000000..883f8b4522 --- /dev/null +++ b/apps/web/app/api/organizations/[organizationId]/stats/email-buckets/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withAuth } from "@/utils/middleware"; +import { fetchAndCheckIsAdmin } from "@/utils/organizations/access"; +import { Prisma } from "@/generated/prisma/client"; +import { type OrgStatsParams, orgStatsParams } from "../types"; + +const EMAIL_BUCKETS = [ + { min: 500, label: "500+" }, + { min: 200, max: 499, label: "200-499" }, + { min: 100, max: 199, label: "100-199" }, + { min: 50, max: 99, label: "50-99" }, + { min: 0, max: 49, label: "<50" }, +]; + +export type OrgEmailBucketsResponse = Awaited< + ReturnType +>; + +export const GET = withAuth( + "organizations/stats/email-buckets", + async (request, { params }) => { + const { userId } = request.auth; + const { organizationId } = await params; + + await fetchAndCheckIsAdmin({ organizationId, userId }); + + const { searchParams } = new URL(request.url); + const queryParams = orgStatsParams.parse({ + fromDate: searchParams.get("fromDate"), + toDate: searchParams.get("toDate"), + }); + + const result = await getEmailVolumeBuckets({ + organizationId, + fromDate: queryParams.fromDate ?? undefined, + toDate: queryParams.toDate ?? undefined, + }); + + return NextResponse.json(result); + }, +); + +async function getEmailVolumeBuckets({ + organizationId, + fromDate, + toDate, +}: OrgStatsParams & { organizationId: string }) { + // Get email count per member using raw SQL for efficiency + type MemberEmailCount = { emailAccountId: string; email_count: bigint }; + + // Build date conditions + const dateConditions: Prisma.Sql[] = []; + if (fromDate) { + dateConditions.push(Prisma.sql`em.date >= ${new Date(fromDate)}`); + } + if (toDate) { + dateConditions.push(Prisma.sql`em.date <= ${new Date(toDate)}`); + } + const dateClause = + dateConditions.length > 0 + ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` + : Prisma.sql``; + + const memberCounts = await prisma.$queryRaw` + SELECT em."emailAccountId", COUNT(*) as email_count + FROM "EmailMessage" em + JOIN "Member" m ON m."emailAccountId" = em."emailAccountId" + WHERE m."organizationId" = ${organizationId} AND em.sent = false${dateClause} + GROUP BY em."emailAccountId" + `; + + // Bucket the results in JavaScript for flexibility + const bucketCounts = EMAIL_BUCKETS.map((bucket) => ({ + label: bucket.label, + userCount: 0, + })); + + for (const member of memberCounts) { + const count = Number(member.email_count); + for (let i = 0; i < EMAIL_BUCKETS.length; i++) { + const bucket = EMAIL_BUCKETS[i]; + if ( + count >= bucket.min && + (bucket.max === undefined || count <= bucket.max) + ) { + bucketCounts[i].userCount++; + break; + } + } + } + + return bucketCounts; +} diff --git a/apps/web/app/api/organizations/[organizationId]/stats/rules-buckets/route.ts b/apps/web/app/api/organizations/[organizationId]/stats/rules-buckets/route.ts new file mode 100644 index 0000000000..8191fb1570 --- /dev/null +++ b/apps/web/app/api/organizations/[organizationId]/stats/rules-buckets/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withAuth } from "@/utils/middleware"; +import { fetchAndCheckIsAdmin } from "@/utils/organizations/access"; +import { Prisma } from "@/generated/prisma/client"; +import { type OrgStatsParams, orgStatsParams } from "../types"; + +const RULES_BUCKETS = [ + { min: 500, label: "500+" }, + { min: 200, max: 499, label: "200-499" }, + { min: 100, max: 199, label: "100-199" }, + { min: 50, max: 99, label: "50-99" }, + { min: 0, max: 49, label: "<50" }, +]; + +export type OrgRulesBucketsResponse = Awaited< + ReturnType +>; + +export const GET = withAuth( + "organizations/stats/rules-buckets", + async (request, { params }) => { + const { userId } = request.auth; + const { organizationId } = await params; + + await fetchAndCheckIsAdmin({ organizationId, userId }); + + const { searchParams } = new URL(request.url); + const queryParams = orgStatsParams.parse({ + fromDate: searchParams.get("fromDate"), + toDate: searchParams.get("toDate"), + }); + + const result = await getExecutedRulesBuckets({ + organizationId, + fromDate: queryParams.fromDate ?? undefined, + toDate: queryParams.toDate ?? undefined, + }); + + return NextResponse.json(result); + }, +); + +async function getExecutedRulesBuckets({ + organizationId, + fromDate, + toDate, +}: OrgStatsParams & { organizationId: string }) { + // Get executed rules count per member + type MemberRulesCount = { emailAccountId: string; rules_count: bigint }; + + // Build date conditions + const dateConditions: Prisma.Sql[] = []; + if (fromDate) { + dateConditions.push(Prisma.sql`er."createdAt" >= ${new Date(fromDate)}`); + } + if (toDate) { + dateConditions.push(Prisma.sql`er."createdAt" <= ${new Date(toDate)}`); + } + const dateClause = + dateConditions.length > 0 + ? Prisma.sql` AND ${Prisma.join(dateConditions, " AND ")}` + : Prisma.sql``; + + const memberCounts = await prisma.$queryRaw` + SELECT er."emailAccountId", COUNT(*) as rules_count + FROM "ExecutedRule" er + JOIN "Member" m ON m."emailAccountId" = er."emailAccountId" + WHERE m."organizationId" = ${organizationId}${dateClause} + GROUP BY er."emailAccountId" + `; + + // Bucket the results + const bucketCounts = RULES_BUCKETS.map((bucket) => ({ + label: bucket.label, + userCount: 0, + })); + + for (const member of memberCounts) { + const count = Number(member.rules_count); + for (let i = 0; i < RULES_BUCKETS.length; i++) { + const bucket = RULES_BUCKETS[i]; + if ( + count >= bucket.min && + (bucket.max === undefined || count <= bucket.max) + ) { + bucketCounts[i].userCount++; + break; + } + } + } + + return bucketCounts; +} diff --git a/apps/web/app/api/organizations/[organizationId]/stats/totals/route.ts b/apps/web/app/api/organizations/[organizationId]/stats/totals/route.ts new file mode 100644 index 0000000000..f2cdfe7ef0 --- /dev/null +++ b/apps/web/app/api/organizations/[organizationId]/stats/totals/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withAuth } from "@/utils/middleware"; +import { fetchAndCheckIsAdmin } from "@/utils/organizations/access"; +import { Prisma } from "@/generated/prisma/client"; +import { type OrgStatsParams, orgStatsParams } from "../types"; + +export type OrgTotalsResponse = Awaited>; + +export const GET = withAuth( + "organizations/stats/totals", + async (request, { params }) => { + const { userId } = request.auth; + const { organizationId } = await params; + + await fetchAndCheckIsAdmin({ organizationId, userId }); + + const { searchParams } = new URL(request.url); + const queryParams = orgStatsParams.parse({ + fromDate: searchParams.get("fromDate"), + toDate: searchParams.get("toDate"), + }); + + const result = await getTotals({ + organizationId, + fromDate: queryParams.fromDate ?? undefined, + toDate: queryParams.toDate ?? undefined, + }); + + return NextResponse.json(result); + }, +); + +async function getTotals({ + organizationId, + fromDate, + toDate, +}: OrgStatsParams & { organizationId: string }) { + type TotalsResult = { + total_emails: bigint; + total_rules: bigint; + active_members: bigint; + }; + + // Build date conditions for emails + const emailDateConditions: Prisma.Sql[] = []; + if (fromDate) { + emailDateConditions.push(Prisma.sql`em.date >= ${new Date(fromDate)}`); + } + if (toDate) { + emailDateConditions.push(Prisma.sql`em.date <= ${new Date(toDate)}`); + } + const emailDateClause = + emailDateConditions.length > 0 + ? Prisma.sql` AND ${Prisma.join(emailDateConditions, " AND ")}` + : Prisma.sql``; + + // Build date conditions for rules + const rulesDateConditions: Prisma.Sql[] = []; + if (fromDate) { + rulesDateConditions.push( + Prisma.sql`er."createdAt" >= ${new Date(fromDate)}`, + ); + } + if (toDate) { + rulesDateConditions.push(Prisma.sql`er."createdAt" <= ${new Date(toDate)}`); + } + const rulesDateClause = + rulesDateConditions.length > 0 + ? Prisma.sql` AND ${Prisma.join(rulesDateConditions, " AND ")}` + : Prisma.sql``; + + const result = await prisma.$queryRaw` + SELECT + ( + SELECT COUNT(*) + FROM "EmailMessage" em + JOIN "Member" m ON m."emailAccountId" = em."emailAccountId" + WHERE m."organizationId" = ${organizationId} AND em.sent = false${emailDateClause} + ) as total_emails, + ( + SELECT COUNT(*) + FROM "ExecutedRule" er + JOIN "Member" m ON m."emailAccountId" = er."emailAccountId" + WHERE m."organizationId" = ${organizationId}${rulesDateClause} + ) as total_rules, + ( + SELECT COUNT(DISTINCT m."emailAccountId") + FROM "Member" m + WHERE m."organizationId" = ${organizationId} + ) as active_members + `; + + return { + totalEmails: Number(result[0]?.total_emails ?? 0), + totalRules: Number(result[0]?.total_rules ?? 0), + activeMembers: Number(result[0]?.active_members ?? 0), + }; +} diff --git a/apps/web/app/api/organizations/[organizationId]/stats/types.ts b/apps/web/app/api/organizations/[organizationId]/stats/types.ts new file mode 100644 index 0000000000..dcb9448882 --- /dev/null +++ b/apps/web/app/api/organizations/[organizationId]/stats/types.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const orgStatsParams = z.object({ + fromDate: z.coerce.number().nullish(), + toDate: z.coerce.number().nullish(), +}); +export type OrgStatsParams = z.infer; diff --git a/apps/web/components/InviteMemberModal.tsx b/apps/web/components/InviteMemberModal.tsx index 1e67bb35f0..119fe20b8b 100644 --- a/apps/web/components/InviteMemberModal.tsx +++ b/apps/web/components/InviteMemberModal.tsx @@ -81,7 +81,7 @@ export function InviteMemberModal({ return ( - + diff --git a/apps/web/hooks/useOrgStatsEmailBuckets.ts b/apps/web/hooks/useOrgStatsEmailBuckets.ts new file mode 100644 index 0000000000..6b8863d0d3 --- /dev/null +++ b/apps/web/hooks/useOrgStatsEmailBuckets.ts @@ -0,0 +1,21 @@ +import useSWR from "swr"; +import type { OrgEmailBucketsResponse } from "@/app/api/organizations/[organizationId]/stats/email-buckets/route"; +import type { OrgStatsParams } from "@/app/api/organizations/[organizationId]/stats/types"; + +export function useOrgStatsEmailBuckets( + organizationId: string, + options?: OrgStatsParams, +) { + const params = new URLSearchParams(); + if (options?.fromDate) { + params.set("fromDate", options.fromDate.toString()); + } + if (options?.toDate) { + params.set("toDate", options.toDate.toString()); + } + const queryString = params.toString(); + + return useSWR( + `/api/organizations/${organizationId}/stats/email-buckets${queryString ? `?${queryString}` : ""}`, + ); +} diff --git a/apps/web/hooks/useOrgStatsRulesBuckets.ts b/apps/web/hooks/useOrgStatsRulesBuckets.ts new file mode 100644 index 0000000000..10eb95e860 --- /dev/null +++ b/apps/web/hooks/useOrgStatsRulesBuckets.ts @@ -0,0 +1,21 @@ +import useSWR from "swr"; +import type { OrgRulesBucketsResponse } from "@/app/api/organizations/[organizationId]/stats/rules-buckets/route"; +import type { OrgStatsParams } from "@/app/api/organizations/[organizationId]/stats/types"; + +export function useOrgStatsRulesBuckets( + organizationId: string, + options?: OrgStatsParams, +) { + const params = new URLSearchParams(); + if (options?.fromDate) { + params.set("fromDate", options.fromDate.toString()); + } + if (options?.toDate) { + params.set("toDate", options.toDate.toString()); + } + const queryString = params.toString(); + + return useSWR( + `/api/organizations/${organizationId}/stats/rules-buckets${queryString ? `?${queryString}` : ""}`, + ); +} diff --git a/apps/web/hooks/useOrgStatsTotals.ts b/apps/web/hooks/useOrgStatsTotals.ts new file mode 100644 index 0000000000..cfa2e296ec --- /dev/null +++ b/apps/web/hooks/useOrgStatsTotals.ts @@ -0,0 +1,21 @@ +import useSWR from "swr"; +import type { OrgTotalsResponse } from "@/app/api/organizations/[organizationId]/stats/totals/route"; +import type { OrgStatsParams } from "@/app/api/organizations/[organizationId]/stats/types"; + +export function useOrgStatsTotals( + organizationId: string, + options?: OrgStatsParams, +) { + const params = new URLSearchParams(); + if (options?.fromDate) { + params.set("fromDate", options.fromDate.toString()); + } + if (options?.toDate) { + params.set("toDate", options.toDate.toString()); + } + const queryString = params.toString(); + + return useSWR( + `/api/organizations/${organizationId}/stats/totals${queryString ? `?${queryString}` : ""}`, + ); +} diff --git a/apps/web/hooks/useOrganization.ts b/apps/web/hooks/useOrganization.ts new file mode 100644 index 0000000000..bfa8fe20c3 --- /dev/null +++ b/apps/web/hooks/useOrganization.ts @@ -0,0 +1,6 @@ +import useSWR from "swr"; +import type { OrganizationResponse } from "@/app/api/organizations/[organizationId]/route"; + +export function useOrganization(organizationId: string) { + return useSWR(`/api/organizations/${organizationId}`); +} diff --git a/version.txt b/version.txt index 3839f4a019..649e8a7fbb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.21.58 +v2.21.59 \ No newline at end of file