Expose visit summary totals for the Statistics dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 11:10:52 +03:30
co-authored by Cursor
parent f42e6fe2a4
commit 6b8073f998
4 changed files with 150 additions and 1 deletions
+15
View File
@@ -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');
}
@@ -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,
@@ -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<string, ViewPeriodCounts>();
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<ViewSummaryGroupId, ViewPeriodCounts>;
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() {
@@ -57,3 +57,38 @@ export const HOME_CHART_VIEW_KIND: Partial<Record<string, WebsitePageKind | 'all
portfolio_views_30d: 'portfolio_detail',
blog_views_30d: 'blog_detail',
};
export const VIEW_SUMMARY_GROUP_IDS = [
'all',
'home',
'products',
'blog',
'portfolio',
'videos',
] as const;
export type ViewSummaryGroupId = (typeof VIEW_SUMMARY_GROUP_IDS)[number];
export const VIEW_SUMMARY_GROUP_KINDS: Record<
ViewSummaryGroupId,
readonly WebsitePageKind[] | null
> = {
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;
};