diff --git a/database/migrations/069_website_content_views.sql b/database/migrations/069_website_content_views.sql new file mode 100644 index 0000000..a5f5216 --- /dev/null +++ b/database/migrations/069_website_content_views.sql @@ -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); diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 9e46bd0..f0e9983 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -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 diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 1427e6c..68aa3b8 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -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": [ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8dc52cc..f498ef6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/app.module.ts b/src/app.module.ts index fa04d9d..dd425ef 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 2e0b0f3..f8e7b59 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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]; diff --git a/src/website-analytics/dto/record-view.dto.ts b/src/website-analytics/dto/record-view.dto.ts new file mode 100644 index 0000000..cb5b188 --- /dev/null +++ b/src/website-analytics/dto/record-view.dto.ts @@ -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; +} diff --git a/src/website-analytics/dto/view-activity-query.dto.ts b/src/website-analytics/dto/view-activity-query.dto.ts new file mode 100644 index 0000000..5847db9 --- /dev/null +++ b/src/website-analytics/dto/view-activity-query.dto.ts @@ -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; +} diff --git a/src/website-analytics/public-website-analytics.controller.ts b/src/website-analytics/public-website-analytics.controller.ts new file mode 100644 index 0000000..3d878b5 --- /dev/null +++ b/src/website-analytics/public-website-analytics.controller.ts @@ -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); + } +} diff --git a/src/website-analytics/website-analytics.controller.ts b/src/website-analytics/website-analytics.controller.ts new file mode 100644 index 0000000..01fac62 --- /dev/null +++ b/src/website-analytics/website-analytics.controller.ts @@ -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, + ); + } +} diff --git a/src/website-analytics/website-analytics.module.ts b/src/website-analytics/website-analytics.module.ts new file mode 100644 index 0000000..143f33f --- /dev/null +++ b/src/website-analytics/website-analytics.module.ts @@ -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 {} diff --git a/src/website-analytics/website-analytics.service.ts b/src/website-analytics/website-analytics.service.ts new file mode 100644 index 0000000..925229f --- /dev/null +++ b/src/website-analytics/website-analytics.service.ts @@ -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 { + 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})`); + } + } +} diff --git a/src/website-analytics/website-analytics.types.ts b/src/website-analytics/website-analytics.types.ts new file mode 100644 index 0000000..e039bda --- /dev/null +++ b/src/website-analytics/website-analytics.types.ts @@ -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 +> = { + website_views_30d: 'website', + product_views_30d: 'product', + portfolio_views_30d: 'portfolio', + blog_views_30d: 'blog', +}; diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 9e46bd0..f0e9983 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -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 diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 1427e6c..68aa3b8 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -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": [