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;
|
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) {
|
function pad2(n: number) {
|
||||||
return String(n).padStart(2, '0');
|
return String(n).padStart(2, '0');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import { WebsiteAnalyticsService } from './website-analytics.service';
|
|||||||
export class WebsiteAnalyticsController {
|
export class WebsiteAnalyticsController {
|
||||||
constructor(private readonly service: WebsiteAnalyticsService) {}
|
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')
|
@Get('views/activity')
|
||||||
viewActivity(
|
viewActivity(
|
||||||
@Param('businessId') businessId: string,
|
@Param('businessId') businessId: string,
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import {
|
|||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
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 { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
@@ -16,6 +21,10 @@ import type { RecordWebsiteViewDto } from './dto/record-view.dto';
|
|||||||
import {
|
import {
|
||||||
normalizeWebsitePageKind,
|
normalizeWebsitePageKind,
|
||||||
pageKindRequiresEntity,
|
pageKindRequiresEntity,
|
||||||
|
VIEW_SUMMARY_GROUP_IDS,
|
||||||
|
VIEW_SUMMARY_GROUP_KINDS,
|
||||||
|
type ViewPeriodCounts,
|
||||||
|
type ViewSummaryGroupId,
|
||||||
type WebsitePageKind,
|
type WebsitePageKind,
|
||||||
} from './website-analytics.types';
|
} 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. */
|
/** Delete event rows older than ~6 months. Lifetime counters are kept. */
|
||||||
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
@Cron(CronExpression.EVERY_DAY_AT_3AM)
|
||||||
async purgeOldPageViews() {
|
async purgeOldPageViews() {
|
||||||
|
|||||||
@@ -57,3 +57,38 @@ export const HOME_CHART_VIEW_KIND: Partial<Record<string, WebsitePageKind | 'all
|
|||||||
portfolio_views_30d: 'portfolio_detail',
|
portfolio_views_30d: 'portfolio_detail',
|
||||||
blog_views_30d: 'blog_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