diff --git a/apps/backend/src/app/api/latest/internal/platform-analytics/route.tsx b/apps/backend/src/app/api/latest/internal/platform-analytics/route.tsx index 84e396afd..bdae98f5d 100644 --- a/apps/backend/src/app/api/latest/internal/platform-analytics/route.tsx +++ b/apps/backend/src/app/api/latest/internal/platform-analytics/route.tsx @@ -66,7 +66,8 @@ function monthlyRecurringCents(product: unknown, priceId: string | null, quantit const usd = (price as Record).USD; const amount = usd == null ? NaN : Number(usd); if (!Number.isFinite(amount)) return 0; - return Math.round((amount * 100 * (quantity || 1)) / intervalMonths); + if (!Number.isFinite(quantity) || quantity < 0) return 0; + return Math.round((amount * 100 * quantity) / intervalMonths); } const KpiSchema = yupObject({ @@ -218,10 +219,13 @@ export const GET = createSmartRouteHandler({ return await result.json(); }; + const internalProjectId = INTERNAL_PROJECT_ID; const userScope = `branch_id = {branchId:String} AND sync_is_deleted = 0`; - const baseParams = { branchId }; - const windowParams = { branchId, since: sinceParam, until: untilParam }; - const twoWindowParams = { branchId, priorSince: priorSinceParam, mid: midParam, until: untilParam }; + const customerUserScope = `${userScope} AND project_id != {internalProjectId:String}`; + const customerEventScope = `project_id != {internalProjectId:String}`; + const baseParams = { branchId, internalProjectId }; + const windowParams = { branchId, internalProjectId, since: sinceParam, until: untilParam }; + const twoWindowParams = { branchId, internalProjectId, priorSince: priorSinceParam, mid: midParam, until: untilParam }; let ch: { dauSeries: Array<{ day: string, c: string | number }>, @@ -258,6 +262,7 @@ export const GET = createSmartRouteHandler({ SELECT toDate(event_at) AS day, uniqExact(assumeNotNull(user_id)) AS c FROM analytics_internal.events WHERE event_type = '$token-refresh' AND user_id IS NOT NULL + AND ${customerEventScope} AND event_at >= {since:DateTime} AND event_at < {until:DateTime} GROUP BY day ORDER BY day ASC `, windowParams), @@ -268,6 +273,7 @@ export const GET = createSmartRouteHandler({ uniqExactIf(assumeNotNull(user_id), event_type = '$page-view') AS visitors FROM analytics_internal.events WHERE event_type IN ('$page-view', '$click') + AND ${customerEventScope} AND event_at >= {since:DateTime} AND event_at < {until:DateTime} GROUP BY day ORDER BY day ASC `, windowParams), @@ -275,7 +281,7 @@ export const GET = createSmartRouteHandler({ chQuery<{ day: string, c: string | number }>(` SELECT toDate(signed_up_at, 'UTC') AS day, count() AS c FROM analytics_internal.users FINAL - WHERE ${userScope} AND is_anonymous = 0 + WHERE ${customerUserScope} AND is_anonymous = 0 AND signed_up_at >= {since:DateTime} AND signed_up_at < {until:DateTime} GROUP BY day ORDER BY day ASC `, windowParams), @@ -288,6 +294,7 @@ export const GET = createSmartRouteHandler({ uniqExactIf(project_id, event_at < {mid:DateTime}) AS projPrev FROM analytics_internal.events WHERE event_type = '$token-refresh' AND user_id IS NOT NULL + AND ${customerEventScope} AND event_at >= {priorSince:DateTime} AND event_at < {until:DateTime} `, twoWindowParams), // User stock counts: total, verified, anonymous (now + as-of window start). @@ -299,8 +306,8 @@ export const GET = createSmartRouteHandler({ countIf(is_anonymous = 0 AND signed_up_at < {mid:DateTime} AND ${verifiedSubquery}) AS verifiedPrev, countIf(is_anonymous = 1) AS anonymous FROM analytics_internal.users FINAL - WHERE ${userScope} - `, { branchId, mid: midParam }), + WHERE ${customerUserScope} + `, { branchId, internalProjectId, mid: midParam }), // Users by country (for the globe) over the window. chQuery<{ country_code: string, c: string | number }>(` SELECT country_code, count() AS c FROM ( @@ -308,6 +315,7 @@ export const GET = createSmartRouteHandler({ SELECT user_id, event_at, CAST(data.ip_info.country_code, 'Nullable(String)') AS cc FROM analytics_internal.events WHERE event_type = '$token-refresh' AND user_id IS NOT NULL + AND ${customerEventScope} AND event_at >= {since:DateTime} AND event_at < {until:DateTime} ) WHERE cc IS NOT NULL GROUP BY user_id ) WHERE country_code IS NOT NULL GROUP BY country_code ORDER BY c DESC @@ -316,7 +324,8 @@ export const GET = createSmartRouteHandler({ chQuery<{ clicks: string | number, dead: string | number }>(` SELECT count() AS clicks, sum(is_dead) AS dead FROM analytics_internal.clickmap_events - WHERE event_at >= {since:DateTime} AND event_at < {until:DateTime} + WHERE ${customerEventScope} + AND event_at >= {since:DateTime} AND event_at < {until:DateTime} `, windowParams), // New / retained / reactivated split across all projects. chQuery<{ day: string, total_count: string, new_count: string, retained_count: string, reactivated_count: string }>(` @@ -332,6 +341,7 @@ export const GET = createSmartRouteHandler({ SELECT DISTINCT toDate(event_at) AS day, assumeNotNull(user_id) AS entity_id FROM analytics_internal.events WHERE event_type = '$token-refresh' AND user_id IS NOT NULL + AND ${customerEventScope} AND event_at >= {since:DateTime} AND event_at < {until:DateTime} AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0 ) @@ -340,6 +350,7 @@ export const GET = createSmartRouteHandler({ SELECT assumeNotNull(user_id) AS entity_id, toDate(min(event_at)) AS first_date FROM analytics_internal.events WHERE event_type = '$token-refresh' AND user_id IS NOT NULL + AND ${customerEventScope} AND event_at < {until:DateTime} AND coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0 GROUP BY entity_id @@ -350,13 +361,13 @@ export const GET = createSmartRouteHandler({ chQuery(` SELECT project_id AS projectId, count() AS c FROM analytics_internal.users FINAL - WHERE ${userScope} AND is_anonymous = 0 GROUP BY project_id + WHERE ${customerUserScope} AND is_anonymous = 0 GROUP BY project_id `, baseParams), // Per-project verified users. chQuery(` SELECT project_id AS projectId, count() AS c FROM analytics_internal.users FINAL - WHERE ${userScope} AND is_anonymous = 0 AND ${verifiedSubquery} GROUP BY project_id + WHERE ${customerUserScope} AND is_anonymous = 0 AND ${verifiedSubquery} GROUP BY project_id `, baseParams), // Per-project signups, current vs prior window. chQuery<{ projectId: string, cur: string | number, prev: string | number }>(` @@ -364,7 +375,7 @@ export const GET = createSmartRouteHandler({ countIf(signed_up_at >= {mid:DateTime}) AS cur, countIf(signed_up_at < {mid:DateTime}) AS prev FROM analytics_internal.users FINAL - WHERE ${userScope} AND is_anonymous = 0 + WHERE ${customerUserScope} AND is_anonymous = 0 AND signed_up_at >= {priorSince:DateTime} AND signed_up_at < {until:DateTime} GROUP BY project_id `, twoWindowParams), @@ -375,6 +386,7 @@ export const GET = createSmartRouteHandler({ uniqExactIf(assumeNotNull(user_id), event_at < {mid:DateTime}) AS prev FROM analytics_internal.events WHERE event_type = '$token-refresh' AND user_id IS NOT NULL + AND ${customerEventScope} AND event_at >= {priorSince:DateTime} AND event_at < {until:DateTime} GROUP BY project_id `, twoWindowParams), @@ -383,14 +395,15 @@ export const GET = createSmartRouteHandler({ SELECT project_id AS projectId, toDate(event_at) AS day, uniqExact(assumeNotNull(user_id)) AS c FROM analytics_internal.events WHERE event_type = '$token-refresh' AND user_id IS NOT NULL + AND ${customerEventScope} AND event_at >= {since:DateTime} AND event_at < {until:DateTime} GROUP BY project_id, day `, windowParams), // Feature adoption signals (per project) from synced CH tables. - chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.teams FINAL WHERE ${userScope} GROUP BY project_id`, baseParams), - chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.connected_accounts FINAL WHERE ${userScope} GROUP BY project_id`, baseParams), - chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.email_outboxes FINAL WHERE ${userScope} GROUP BY project_id`, baseParams), - chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.events WHERE event_type = '$page-view' GROUP BY project_id`, {}), + chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.teams FINAL WHERE ${customerUserScope} GROUP BY project_id`, baseParams), + chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.connected_accounts FINAL WHERE ${customerUserScope} GROUP BY project_id`, baseParams), + chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.email_outboxes FINAL WHERE ${customerUserScope} GROUP BY project_id`, baseParams), + chQuery(`SELECT project_id AS projectId, count() AS c FROM analytics_internal.events WHERE event_type = '$page-view' AND branch_id = {branchId:String} AND ${customerEventScope} GROUP BY project_id`, baseParams), ]); ch = { dauSeries, pvSeries, signupSeries, mauProjects, userCounts, country, deadClicks, split, @@ -519,8 +532,7 @@ export const GET = createSmartRouteHandler({ // ---- KPIs ---- const mp = ch.mauProjects[0] ?? { mauCur: 0, mauPrev: 0, projCur: 0, projPrev: 0 }; const uc = ch.userCounts[0] ?? { total: 0, totalPrev: 0, verified: 0, verifiedPrev: 0, anonymous: 0 }; - const half = Math.max(1, WINDOW_DAYS); - const dauAvgCur = Math.round(series.reduce((s, p) => s + p.active_users, 0) / half); + const dauAvgCur = Math.round(series.reduce((s, p) => s + p.active_users, 0) / Math.max(1, WINDOW_DAYS)); const mauCur = num(mp.mauCur); const mauPrev = num(mp.mauPrev); const stick = (dau: number, mau: number) => mau > 0 ? Number(((dau / mau) * 100).toFixed(1)) : 0; @@ -531,7 +543,7 @@ export const GET = createSmartRouteHandler({ // MRR (true recurring, normalized to monthly cents). let mrrCents = 0; for (const s of pg.subscriptions) { - mrrCents += monthlyRecurringCents(s.product, s.priceId, num(s.quantity) || 1); + mrrCents += monthlyRecurringCents(s.product, s.priceId, num(s.quantity)); } const kpis = { @@ -551,10 +563,11 @@ export const GET = createSmartRouteHandler({ }; // ---- Breakdowns ---- - const usersByCountry: Record = {}; + const usersByCountryMap = new Map(); for (const r of ch.country) { - if (r.country_code) usersByCountry[r.country_code.toUpperCase()] = num(r.c); + if (r.country_code) usersByCountryMap.set(r.country_code.toUpperCase(), num(r.c)); } + const usersByCountry = Object.fromEntries(usersByCountryMap); const nonAnon = num(uc.total); const verified = num(uc.verified); const breakdowns = { diff --git a/apps/backend/src/lib/platform-admin.test.ts b/apps/backend/src/lib/platform-admin.test.ts new file mode 100644 index 000000000..fa3e2eafd --- /dev/null +++ b/apps/backend/src/lib/platform-admin.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { ensurePlatformAdmin, isPlatformAdmin } from "./platform-admin"; +import * as projects from "./projects"; + +vi.mock("./projects", () => ({ + listManagedProjectIds: vi.fn(), +})); + +const mockListManagedProjectIds = vi.mocked(projects.listManagedProjectIds); + +// Minimal stub satisfying the UsersCrud["Admin"]["Read"] shape required by the functions. +// The actual user object is only forwarded to listManagedProjectIds, which is mocked. +const fakeUser = { id: "user-1" } as Parameters[0]; + +describe("isPlatformAdmin", () => { + it("returns true when user manages the internal project", async () => { + mockListManagedProjectIds.mockResolvedValue(["internal", "other-project"]); + await expect(isPlatformAdmin(fakeUser)).resolves.toBe(true); + expect(mockListManagedProjectIds).toHaveBeenCalledWith(fakeUser); + }); + + it("returns false when user does not manage the internal project", async () => { + mockListManagedProjectIds.mockResolvedValue(["some-project", "another-project"]); + await expect(isPlatformAdmin(fakeUser)).resolves.toBe(false); + }); + + it("returns false when user manages no projects", async () => { + mockListManagedProjectIds.mockResolvedValue([]); + await expect(isPlatformAdmin(fakeUser)).resolves.toBe(false); + }); +}); + +describe("ensurePlatformAdmin", () => { + it("resolves without throwing for platform admins", async () => { + mockListManagedProjectIds.mockResolvedValue(["internal"]); + await expect(ensurePlatformAdmin(fakeUser)).resolves.toBeUndefined(); + }); + + it("throws a 403 StatusError for non-platform-admins", async () => { + mockListManagedProjectIds.mockResolvedValue(["customer-project"]); + await expect(ensurePlatformAdmin(fakeUser)).rejects.toMatchObject({ + statusCode: 403, + message: "You do not have access to platform analytics.", + }); + }); + + it("throws a 403 StatusError when user manages no projects at all", async () => { + mockListManagedProjectIds.mockResolvedValue([]); + await expect(ensurePlatformAdmin(fakeUser)).rejects.toMatchObject({ + statusCode: 403, + }); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/platform-analytics/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/platform-analytics/page-client.tsx index 5b098fb48..cef2dc0e0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/platform-analytics/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/platform-analytics/page-client.tsx @@ -326,7 +326,7 @@ function Dashboard({
- +
@@ -586,6 +586,7 @@ function FeatureAdoption({ features, totalProjects }: { features: Array<{ featur const meta = FEATURE_META.get(feature.feature); const Icon = meta?.icon ?? ChartLineUpIcon; const pct = Math.round((feature.projects_using / denominator) * 100); + const pctClamped = Math.max(0, Math.min(100, pct)); return (
@@ -596,7 +597,7 @@ function FeatureAdoption({ features, totalProjects }: { features: Array<{ featur {formatNumber(feature.projects_using)} ({pct}%)
-
+
);