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:
co-authored by
Cursor
parent
b8f60c5141
commit
3f266a2dcb
@@ -0,0 +1,18 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'website_view_kind') THEN
|
||||
CREATE TYPE website_view_kind AS ENUM ('website', 'product', 'portfolio', 'blog');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS website_content_views (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
view_kind website_view_kind NOT NULL,
|
||||
entity_id BIGINT,
|
||||
page_path TEXT,
|
||||
viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_website_content_views_business_kind_time
|
||||
ON website_content_views (business_id, view_kind, viewed_at DESC);
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -116,6 +116,7 @@ model Business {
|
||||
website_category_groups website_category_groups[]
|
||||
website_image_slots website_image_slots[]
|
||||
website_sliders website_sliders[]
|
||||
websiteContentViews WebsiteContentView[]
|
||||
|
||||
@@map("businesses")
|
||||
}
|
||||
@@ -1636,3 +1637,25 @@ enum UserProductCondition {
|
||||
|
||||
@@map("user_product_condition")
|
||||
}
|
||||
|
||||
enum WebsiteViewKind {
|
||||
website
|
||||
product
|
||||
portfolio
|
||||
blog
|
||||
|
||||
@@map("website_view_kind")
|
||||
}
|
||||
|
||||
model WebsiteContentView {
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
viewKind WebsiteViewKind @map("view_kind")
|
||||
entityId BigInt? @map("entity_id")
|
||||
pagePath String? @map("page_path")
|
||||
viewedAt DateTime @default(now()) @map("viewed_at") @db.Timestamptz(6)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([businessId, viewKind, viewedAt(sort: Desc)], map: "idx_website_content_views_business_kind_time")
|
||||
@@map("website_content_views")
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user