Add website view analytics for dashboard traffic charts.

Track storefront views via POST /tenants/{domain}/analytics/views, expose business activity API, and add product/website/portfolio/blog 30-day chart types for super-admin selection.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-26 01:12:44 +03:30
co-authored by Cursor
parent b8f60c5141
commit 3f266a2dcb
15 changed files with 414 additions and 0 deletions
+2
View File
@@ -36,6 +36,7 @@ import { BrandsModule } from './brands/brands.module';
import { WebsiteModule } from './website/website.module';
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
import { WebsiteDocsModule } from './website-docs/website-docs.module';
import { WebsiteAnalyticsModule } from './website-analytics/website-analytics.module';
import { InvoicesModule } from './invoices/invoices.module';
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
import { PublicSmsModule } from './public-sms/public-sms.module';
@@ -82,6 +83,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
UserProductsModule,
BrandsModule,
WebsiteModule,
WebsiteAnalyticsModule,
WebsiteDocsModule,
InvoicesModule,
PublicSmsModule,
@@ -179,6 +179,9 @@ export const HOME_CHART_IDS = [
'customers_joined_1y',
'blog_views_30d',
'products_added_1y',
'product_views_30d',
'website_views_30d',
'portfolio_views_30d',
] as const;
export type HomeChartId = (typeof HOME_CHART_IDS)[number];
@@ -0,0 +1,19 @@
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types';
export class RecordWebsiteViewDto {
@IsIn([...WEBSITE_VIEW_KINDS])
kind!: WebsiteViewKind;
/** Product / portfolio / blog id when kind is not `website`. */
@IsOptional()
@IsString()
@MinLength(1)
entityId?: string;
/** Page path for storefront visits (e.g. `/`, `/about`, `/products/12/slug`). */
@IsOptional()
@IsString()
@MaxLength(500)
path?: string;
}
@@ -0,0 +1,15 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types';
export class ViewActivityQueryDto {
@IsIn([...WEBSITE_VIEW_KINDS])
kind!: WebsiteViewKind;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(90)
days?: number;
}
@@ -0,0 +1,13 @@
import { Body, Controller, Param, Post } from '@nestjs/common';
import { RecordWebsiteViewDto } from './dto/record-view.dto';
import { WebsiteAnalyticsService } from './website-analytics.service';
@Controller('tenants/:host/analytics/views')
export class PublicWebsiteAnalyticsController {
constructor(private readonly service: WebsiteAnalyticsService) {}
@Post()
record(@Param('host') host: string, @Body() dto: RecordWebsiteViewDto) {
return this.service.recordPublicView(host, dto);
}
}
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ViewActivityQueryDto } from './dto/view-activity-query.dto';
import { WebsiteAnalyticsService } from './website-analytics.service';
@Controller('businesses/:businessId/analytics')
@UseGuards(JwtAuthGuard)
export class WebsiteAnalyticsController {
constructor(private readonly service: WebsiteAnalyticsService) {}
@Get('views/activity')
viewActivity(
@Param('businessId') businessId: string,
@Query() query: ViewActivityQueryDto,
@CurrentUser() user: AuthUser,
) {
return this.service.dailyViewActivity(
businessId,
query.kind,
query.days,
user,
);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TenantModule } from '../tenant/tenant.module';
import { PublicWebsiteAnalyticsController } from './public-website-analytics.controller';
import { WebsiteAnalyticsController } from './website-analytics.controller';
import { WebsiteAnalyticsService } from './website-analytics.service';
@Module({
imports: [AuthModule, TenantModule],
controllers: [PublicWebsiteAnalyticsController, WebsiteAnalyticsController],
providers: [WebsiteAnalyticsService],
})
export class WebsiteAnalyticsModule {}
@@ -0,0 +1,137 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { buildDailyActivitySeries, startOfLocalDay } from '../common/daily-activity';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import type { RecordWebsiteViewDto } from './dto/record-view.dto';
import type { WebsiteViewKind } from './website-analytics.types';
@Injectable()
export class WebsiteAnalyticsService {
constructor(
private readonly prisma: PrismaService,
private readonly tenant: TenantService,
private readonly permissions: PermissionsService,
) {}
async recordPublicView(host: string, dto: RecordWebsiteViewDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const entityId = await this.resolveEntityId(businessId, dto.kind, dto.entityId);
await this.prisma.websiteContentView.create({
data: {
businessId,
viewKind: dto.kind,
entityId,
pagePath: this.normalizePath(dto.path),
},
});
return { ok: true as const };
}
async dailyViewActivity(
businessIdRaw: string,
kind: WebsiteViewKind,
daysRaw: number | undefined,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'business.read');
const days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
const from = startOfLocalDay(days - 1);
const rows = await this.prisma.$queryRaw<{ day: Date; count: number }[]>(
Prisma.sql`
SELECT viewed_at::date AS day,
COUNT(*)::int AS count
FROM website_content_views
WHERE business_id = ${businessId}
AND view_kind = ${kind}::website_view_kind
AND viewed_at >= ${from}
GROUP BY 1
ORDER BY 1
`,
);
const primary = buildDailyActivitySeries(days, rows);
return {
days,
primary,
secondary: { days, total: 0, items: primary.items.map((item) => ({ ...item, count: 0 })) },
};
}
private normalizePath(path: string | undefined): string | null {
if (!path) return null;
const trimmed = path.trim();
if (!trimmed) return null;
return trimmed.startsWith('/') ? trimmed.slice(0, 500) : `/${trimmed}`.slice(0, 500);
}
private async resolveEntityId(
businessId: bigint,
kind: WebsiteViewKind,
entityIdRaw: string | undefined,
): Promise<bigint | null> {
if (kind === 'website') {
return null;
}
if (!entityIdRaw?.trim()) {
throw new BadRequestException(`entityId is required for ${kind} views`);
}
const entityId = BigInt(entityIdRaw);
if (kind === 'product') {
const product = await this.prisma.product.findFirst({
where: { id: entityId, businessId },
select: { id: true },
});
if (!product) throw new NotFoundException('Product not found');
return entityId;
}
if (kind === 'portfolio') {
const portfolio = await this.prisma.portfolios.findFirst({
where: { id: entityId, business_id: businessId },
select: { id: true },
});
if (!portfolio) throw new NotFoundException('Portfolio not found');
return entityId;
}
const blog = await this.prisma.blogs.findFirst({
where: { id: entityId, business_id: businessId },
select: { id: true },
});
if (!blog) throw new NotFoundException('Blog not found');
return entityId;
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(`Insufficient permissions (${permission})`);
}
}
}
@@ -0,0 +1,25 @@
export const WEBSITE_VIEW_KINDS = [
'website',
'product',
'portfolio',
'blog',
] as const;
export type WebsiteViewKind = (typeof WEBSITE_VIEW_KINDS)[number];
export function isWebsiteViewKind(value: unknown): value is WebsiteViewKind {
return (
typeof value === 'string' &&
(WEBSITE_VIEW_KINDS as readonly string[]).includes(value)
);
}
/** Maps home dashboard chart ids to analytics view kinds. */
export const HOME_CHART_VIEW_KIND: Partial<
Record<string, WebsiteViewKind>
> = {
website_views_30d: 'website',
product_views_30d: 'product',
portfolio_views_30d: 'portfolio',
blog_views_30d: 'blog',
};
+5
View File
@@ -54,6 +54,11 @@ Public marketplace listings owned by customers — not catalog `products`.
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
### Analytics (dashboard charts)
Record storefront views so the business home charts can show traffic:
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "website"|"product"|"portfolio"|"blog", "entityId"?: "...", "path"?: "/about" }`
- Call once per page view from the website (product detail, portfolio, blog post, or general pages with `kind: "website"`).
### Static images
Named slots the business dashboard can replace. Fetch once per page:
- `GET /tenants/{domain}/website/static-images` — all slots
+55
View File
@@ -430,6 +430,61 @@
}
}
},
"/tenants/{domain}/analytics/views": {
"post": {
"tags": ["Analytics"],
"summary": "Record a storefront page view",
"description": "Fire-and-forget view counter for business dashboard charts. Call once per page impression from the website (product detail, portfolio, blog post, or general site page). No auth required.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["kind"],
"properties": {
"kind": {
"type": "string",
"enum": ["website", "product", "portfolio", "blog"]
},
"entityId": {
"type": "string",
"description": "Required when kind is product, portfolio, or blog."
},
"path": {
"type": "string",
"description": "Optional page path for website visits (e.g. `/`, `/about`)."
}
}
}
}
}
},
"responses": {
"200": {
"description": "View recorded",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": { "type": "boolean", "enum": [true] }
}
}
}
}
},
"404": {
"description": "Unknown domain or entity"
}
}
}
},
"/tenants/{domain}/website/sliders": {
"get": {
"tags": [