From 6b8073f99809610eafb4ca20c5656226e798e237 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 1 Sep 2026 11:10:52 +0330 Subject: [PATCH] Expose visit summary totals for the Statistics dashboard. Co-authored-by: Cursor --- src/common/daily-activity.ts | 15 +++ .../website-analytics.controller.ts | 8 ++ .../website-analytics.service.ts | 93 ++++++++++++++++++- .../website-analytics.types.ts | 35 +++++++ 4 files changed, 150 insertions(+), 1 deletion(-) diff --git a/src/common/daily-activity.ts b/src/common/daily-activity.ts index 2bd1803..1932512 100644 --- a/src/common/daily-activity.ts +++ b/src/common/daily-activity.ts @@ -19,6 +19,21 @@ export function startOfLocalDay(offsetDays = 0): Date { return date; } +/** Start of the current local week. `weekStartsOn`: 0 Sun … 6 Sat. Default Saturday (Iran). */ +export function startOfLocalWeek(weekStartsOn = 6): Date { + const today = startOfLocalDay(0); + const diff = (today.getDay() - weekStartsOn + 7) % 7; + return startOfLocalDay(diff); +} + +/** First day of the current local month at 00:00:00. */ +export function startOfLocalMonth(): Date { + const date = new Date(); + date.setDate(1); + date.setHours(0, 0, 0, 0); + return date; +} + function pad2(n: number) { return String(n).padStart(2, '0'); } diff --git a/src/website-analytics/website-analytics.controller.ts b/src/website-analytics/website-analytics.controller.ts index 01fac62..fa91131 100644 --- a/src/website-analytics/website-analytics.controller.ts +++ b/src/website-analytics/website-analytics.controller.ts @@ -10,6 +10,14 @@ import { WebsiteAnalyticsService } from './website-analytics.service'; export class WebsiteAnalyticsController { constructor(private readonly service: WebsiteAnalyticsService) {} + @Get('views/summary') + viewSummary( + @Param('businessId') businessId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.viewSummary(businessId, user); + } + @Get('views/activity') viewActivity( @Param('businessId') businessId: string, diff --git a/src/website-analytics/website-analytics.service.ts b/src/website-analytics/website-analytics.service.ts index 5d228e5..eee6f9f 100644 --- a/src/website-analytics/website-analytics.service.ts +++ b/src/website-analytics/website-analytics.service.ts @@ -8,7 +8,12 @@ import { import { Cron, CronExpression } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; -import { buildDailyActivitySeries, startOfLocalDay } from '../common/daily-activity'; +import { + buildDailyActivitySeries, + startOfLocalDay, + startOfLocalMonth, + startOfLocalWeek, +} from '../common/daily-activity'; import { PermissionsService } from '../auth/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; @@ -16,6 +21,10 @@ import type { RecordWebsiteViewDto } from './dto/record-view.dto'; import { normalizeWebsitePageKind, pageKindRequiresEntity, + VIEW_SUMMARY_GROUP_IDS, + VIEW_SUMMARY_GROUP_KINDS, + type ViewPeriodCounts, + type ViewSummaryGroupId, type WebsitePageKind, } from './website-analytics.types'; @@ -139,6 +148,88 @@ export class WebsiteAnalyticsService { }; } + async viewSummary(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertPermission(businessId, actor.id, 'business.read'); + + const today = startOfLocalDay(0); + const weekStart = startOfLocalWeek(); + const monthStart = startOfLocalMonth(); + const periodFrom = weekStart < monthStart ? weekStart : monthStart; + + const [periodRows, totalRows] = await Promise.all([ + this.prisma.$queryRaw<{ page_kind: string; today: number; week: number; month: number }[]>( + Prisma.sql` + SELECT page_kind::text AS page_kind, + COUNT(*) FILTER (WHERE viewed_at >= ${today})::int AS today, + COUNT(*) FILTER (WHERE viewed_at >= ${weekStart})::int AS week, + COUNT(*) FILTER (WHERE viewed_at >= ${monthStart})::int AS month + FROM website_page_views + WHERE business_id = ${businessId} + AND viewed_at >= ${periodFrom} + GROUP BY page_kind + `, + ), + this.prisma.$queryRaw<{ page_kind: string; total: bigint }[]>( + Prisma.sql` + SELECT page_kind::text AS page_kind, + SUM(total_count)::bigint AS total + FROM website_page_view_counters + WHERE business_id = ${businessId} + GROUP BY page_kind + `, + ), + ]); + + const empty: ViewPeriodCounts = { today: 0, week: 0, month: 0, total: 0 }; + const byKind = new Map(); + + for (const row of periodRows) { + byKind.set(row.page_kind, { + today: Number(row.today) || 0, + week: Number(row.week) || 0, + month: Number(row.month) || 0, + total: 0, + }); + } + for (const row of totalRows) { + const prev = byKind.get(row.page_kind) ?? { ...empty }; + prev.total = Number(row.total) || 0; + byKind.set(row.page_kind, prev); + } + + const rows = {} as Record; + for (const id of VIEW_SUMMARY_GROUP_IDS) { + const kinds = VIEW_SUMMARY_GROUP_KINDS[id]; + if (!kinds) { + rows[id] = [...byKind.values()].reduce( + (acc, item) => ({ + today: acc.today + item.today, + week: acc.week + item.week, + month: acc.month + item.month, + total: acc.total + item.total, + }), + { ...empty }, + ); + continue; + } + rows[id] = kinds.reduce( + (acc, kind) => { + const item = byKind.get(kind) ?? empty; + return { + today: acc.today + item.today, + week: acc.week + item.week, + month: acc.month + item.month, + total: acc.total + item.total, + }; + }, + { ...empty }, + ); + } + + return { rows }; + } + /** Delete event rows older than ~6 months. Lifetime counters are kept. */ @Cron(CronExpression.EVERY_DAY_AT_3AM) async purgeOldPageViews() { diff --git a/src/website-analytics/website-analytics.types.ts b/src/website-analytics/website-analytics.types.ts index 4fecdcc..c5d858f 100644 --- a/src/website-analytics/website-analytics.types.ts +++ b/src/website-analytics/website-analytics.types.ts @@ -57,3 +57,38 @@ export const HOME_CHART_VIEW_KIND: Partial = { + all: null, + home: ['home'], + products: [ + 'product_list', + 'product_detail', + 'store_item_list', + 'store_item_detail', + ], + blog: ['blog_list', 'blog_detail'], + portfolio: ['portfolio_list', 'portfolio_detail'], + videos: ['video_list', 'video_detail'], +}; + +export type ViewPeriodCounts = { + today: number; + week: number; + month: number; + total: number; +};