Expose visit summary totals for the Statistics dashboard.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f42e6fe2a4
commit
6b8073f998
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user