From 92601ae729ba5068d4976db57c9c51764ccb3096 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 26 Aug 2026 10:13:06 +0330 Subject: [PATCH] Add page-view analytics, product counters, SSL expiry, and business renewal. Auto-count storefront page views with 6-month event retention and lifetime counters, track product visit/cart/sale stats, persist SSL cert expiry with daily probes, and support owner titles plus annual contract renewal. Co-authored-by: Cursor --- .../070_website_page_view_counters.sql | 76 ++++++ .../071_product_analytics_columns.sql | 5 + .../migrations/072_domain_ssl_expires_at.sql | 8 + ...73_business_annual_renewal_user_titles.sql | 32 +++ docs/website-api/AI_PROMPT.md | 9 +- docs/website-api/openapi.json | 25 +- prisma/schema.prisma | 69 +++++- src/app.module.ts | 2 + src/blogs/blogs.controller.ts | 24 +- src/blogs/blogs.module.ts | 9 +- src/business-admin/business-admin.service.ts | 53 +++- src/business-admin/dto/create-business.dto.ts | 12 + src/business-admin/dto/update-business.dto.ts | 6 + src/cart/cart.service.ts | 12 + src/common/tls-probe.ts | 88 +++++-- src/domain-admin/domain-admin.service.ts | 90 +++++-- src/internal-ssl/internal-ssl.service.ts | 98 ++++++-- src/orders/orders.service.ts | 5 + src/portfolios/portfolios.controller.ts | 24 +- src/portfolios/portfolios.module.ts | 10 +- src/products/products.controller.ts | 22 +- src/products/products.module.ts | 11 +- src/products/products.service.ts | 28 +++ src/store/store-items.controller.ts | 36 ++- src/store/store.module.ts | 3 +- src/user-titles/user-titles.controller.ts | 14 ++ src/user-titles/user-titles.module.ts | 12 + src/user-titles/user-titles.service.ts | 29 +++ src/videos/videos.controller.ts | 18 +- src/videos/videos.module.ts | 3 +- src/website-analytics/dto/record-view.dto.ts | 21 +- .../dto/view-activity-query.dto.ts | 14 +- .../website-analytics.module.ts | 1 + .../website-analytics.service.ts | 226 +++++++++++++++--- .../website-analytics.types.ts | 66 +++-- src/website-docs/static/AI_PROMPT.md | 9 +- src/website-docs/static/openapi.json | 25 +- src/website/website-sliders.controller.ts | 13 +- src/website/website.module.ts | 3 +- 39 files changed, 1013 insertions(+), 198 deletions(-) create mode 100644 database/migrations/070_website_page_view_counters.sql create mode 100644 database/migrations/071_product_analytics_columns.sql create mode 100644 database/migrations/072_domain_ssl_expires_at.sql create mode 100644 database/migrations/073_business_annual_renewal_user_titles.sql create mode 100644 src/user-titles/user-titles.controller.ts create mode 100644 src/user-titles/user-titles.module.ts create mode 100644 src/user-titles/user-titles.service.ts diff --git a/database/migrations/070_website_page_view_counters.sql b/database/migrations/070_website_page_view_counters.sql new file mode 100644 index 0000000..781f188 --- /dev/null +++ b/database/migrations/070_website_page_view_counters.sql @@ -0,0 +1,76 @@ +-- Expand page-view kinds, rename events table, add lifetime counters. +-- Events retained max 6 months (purged by Nest cron). Counters kept forever. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'website_page_kind') THEN + CREATE TYPE website_page_kind AS ENUM ( + 'home', + 'portfolio_list', + 'portfolio_detail', + 'product_list', + 'product_detail', + 'store_item_list', + 'store_item_detail', + 'blog_list', + 'blog_detail', + 'video_list', + 'video_detail' + ); + END IF; +END $$; + +-- Recreate events table with new kind enum (table is empty / low volume today). +CREATE TABLE IF NOT EXISTS website_page_views ( + id BIGSERIAL PRIMARY KEY, + business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE, + page_kind website_page_kind NOT NULL, + entity_id BIGINT NOT NULL DEFAULT 0, + page_path TEXT, + viewed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_website_page_views_business_kind_time + ON website_page_views (business_id, page_kind, viewed_at DESC); + +-- Copy any legacy rows from website_content_views +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'website_content_views' + ) THEN + INSERT INTO website_page_views (business_id, page_kind, entity_id, page_path, viewed_at) + SELECT + business_id, + CASE view_kind::text + WHEN 'website' THEN 'home'::website_page_kind + WHEN 'product' THEN 'product_detail'::website_page_kind + WHEN 'portfolio' THEN 'portfolio_detail'::website_page_kind + WHEN 'blog' THEN 'blog_detail'::website_page_kind + ELSE 'home'::website_page_kind + END, + COALESCE(entity_id, 0), + page_path, + viewed_at + FROM website_content_views; + + DROP TABLE website_content_views; + END IF; +END $$; + +DROP TYPE IF EXISTS website_view_kind; + +CREATE TABLE IF NOT EXISTS website_page_view_counters ( + id BIGSERIAL PRIMARY KEY, + business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE, + page_kind website_page_kind NOT NULL, + entity_id BIGINT NOT NULL DEFAULT 0, + total_count BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uq_website_page_view_counters_biz_kind_entity + UNIQUE (business_id, page_kind, entity_id) +); + +CREATE INDEX IF NOT EXISTS idx_website_page_view_counters_business_kind + ON website_page_view_counters (business_id, page_kind); diff --git a/database/migrations/071_product_analytics_columns.sql b/database/migrations/071_product_analytics_columns.sql new file mode 100644 index 0000000..92edaf5 --- /dev/null +++ b/database/migrations/071_product_analytics_columns.sql @@ -0,0 +1,5 @@ +-- Lifetime product analysis counters (visits, cart adds, units sold). +ALTER TABLE products + ADD COLUMN IF NOT EXISTS visit_count BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS cart_add_count BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS sale_count BIGINT NOT NULL DEFAULT 0; diff --git a/database/migrations/072_domain_ssl_expires_at.sql b/database/migrations/072_domain_ssl_expires_at.sql new file mode 100644 index 0000000..d71bd9c --- /dev/null +++ b/database/migrations/072_domain_ssl_expires_at.sql @@ -0,0 +1,8 @@ +-- Store Let's Encrypt / TLS notAfter after a successful SSL issue. +-- List Valid/Invalid prefers this date; live probes run at most once per day. +ALTER TABLE domains + ADD COLUMN IF NOT EXISTS ssl_expires_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_domains_ssl_expires_at + ON domains (ssl_expires_at) + WHERE ssl_expires_at IS NOT NULL; diff --git a/database/migrations/073_business_annual_renewal_user_titles.sql b/database/migrations/073_business_annual_renewal_user_titles.sql new file mode 100644 index 0000000..a4d6a5a --- /dev/null +++ b/database/migrations/073_business_annual_renewal_user_titles.sql @@ -0,0 +1,32 @@ +-- Business annual renewal date + user honorific titles. + +CREATE TABLE IF NOT EXISTS user_titles ( + id BIGSERIAL PRIMARY KEY, + slug VARCHAR(32) NOT NULL, + name_en VARCHAR(64) NOT NULL, + name_fa VARCHAR(64) NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT user_titles_slug_unique UNIQUE (slug) +); + +CREATE INDEX IF NOT EXISTS idx_user_titles_sort + ON user_titles (sort_order, id); + +INSERT INTO user_titles (slug, name_en, name_fa, sort_order) +VALUES + ('mr', 'Mr', 'آقای', 10), + ('mrs', 'Mrs', 'خانم', 20), + ('ms', 'Ms', 'خانم', 30), + ('dr', 'Dr', 'دکتر', 40), + ('eng', 'Eng.', 'مهندس', 50) +ON CONFLICT (slug) DO NOTHING; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS title_id BIGINT REFERENCES user_titles (id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_users_title_id ON users (title_id); + +ALTER TABLE businesses + ADD COLUMN IF NOT EXISTS annual_renewal_at TIMESTAMPTZ; diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index f0e9983..5697501 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -55,9 +55,12 @@ Public marketplace listings owned by customers — not catalog `products`. 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"`). +Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those. + +Optional explicit record (e.g. custom home without sliders): +- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }` +- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted) +- Events kept ~6 months for charts; lifetime totals kept forever in counters. ### Static images Named slots the business dashboard can replace. Fetch once per page: diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 68aa3b8..8ad16b9 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -434,7 +434,7 @@ "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.", + "description": "Optional explicit view counter. Most page views are recorded automatically when public list/detail APIs are called (blogs, products, portfolios, videos, store-items, homepage sliders). Use this for custom pages. No auth required. Events retained ~6 months; lifetime counters kept forever.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -450,15 +450,32 @@ "properties": { "kind": { "type": "string", - "enum": ["website", "product", "portfolio", "blog"] + "enum": [ + "home", + "portfolio_list", + "portfolio_detail", + "product_list", + "product_detail", + "store_item_list", + "store_item_detail", + "blog_list", + "blog_detail", + "video_list", + "video_detail", + "website", + "product", + "portfolio", + "blog" + ], + "description": "Canonical kinds preferred. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail." }, "entityId": { "type": "string", - "description": "Required when kind is product, portfolio, or blog." + "description": "Required for *_detail kinds (product, portfolio, blog, video, store item)." }, "path": { "type": "string", - "description": "Optional page path for website visits (e.g. `/`, `/about`)." + "description": "Optional page path (e.g. `/`, `/blog/my-post`)." } } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f498ef6..e188a26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,8 @@ model User { firstNameEn String? @map("first_name_en") @db.VarChar(100) lastNameEn String? @map("last_name_en") @db.VarChar(100) oldId BigInt? @map("old_id") + titleId BigInt? @map("title_id") + title UserTitle? @relation(fields: [titleId], references: [id], onDelete: SetNull, onUpdate: NoAction) addresses Address[] authoredBlogs blogs[] @relation("BlogAuthor") businessCustomers BusinessCustomer[] @@ -53,6 +55,20 @@ model User { @@map("users") } +model UserTitle { + id BigInt @id @default(autoincrement()) + slug String @unique(map: "user_titles_slug_unique") @db.VarChar(32) + nameEn String @map("name_en") @db.VarChar(64) + nameFa String @map("name_fa") @db.VarChar(64) + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + users User[] + + @@index([sortOrder, id], map: "idx_user_titles_sort") + @@map("user_titles") +} + model Business { id BigInt @id @default(autoincrement()) name String @db.VarChar(255) @@ -72,6 +88,7 @@ model Business { logoDarkMediaId BigInt? @map("logo_dark_media_id") faviconMediaId BigInt? @map("favicon_media_id") oldBusinessId BigInt? @map("old_business_id") + annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6) addresses Address[] blogs blogs[] brands Brand[] @@ -116,7 +133,8 @@ model Business { website_category_groups website_category_groups[] website_image_slots website_image_slots[] website_sliders website_sliders[] - websiteContentViews WebsiteContentView[] + websitePageViews WebsitePageView[] + websitePageViewCounters WebsitePageViewCounter[] @@map("businesses") } @@ -161,6 +179,7 @@ model Domain { isVerified Boolean @default(false) @map("is_verified") verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6) sslEnabled Boolean @default(false) @map("ssl_enabled") + sslExpiresAt DateTime? @map("ssl_expires_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) expiresAt DateTime? @map("expires_at") @db.Timestamptz(6) @@ -436,6 +455,9 @@ model Product { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) brandId BigInt? @map("brand_id") + visitCount BigInt @default(0) @map("visit_count") + cartAddCount BigInt @default(0) @map("cart_add_count") + saleCount BigInt @default(0) @map("sale_count") expertReviews ExpertReview[] favorites Favorite[] orderItems OrderItem[] @@ -1638,24 +1660,47 @@ enum UserProductCondition { @@map("user_product_condition") } -enum WebsiteViewKind { - website - product - portfolio - blog +enum WebsitePageKind { + home + portfolio_list + portfolio_detail + product_list + product_detail + store_item_list + store_item_detail + blog_list + blog_detail + video_list + video_detail - @@map("website_view_kind") + @@map("website_page_kind") } -model WebsiteContentView { +/// Raw page-view events (retained ~6 months; purged by cron). +model WebsitePageView { id BigInt @id @default(autoincrement()) businessId BigInt @map("business_id") - viewKind WebsiteViewKind @map("view_kind") - entityId BigInt? @map("entity_id") + pageKind WebsitePageKind @map("page_kind") + entityId BigInt @default(0) @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") + @@index([businessId, pageKind, viewedAt(sort: Desc)], map: "idx_website_page_views_business_kind_time") + @@map("website_page_views") +} + +/// Lifetime totals per page kind (+ entity for detail pages). Never purged. +model WebsitePageViewCounter { + id BigInt @id @default(autoincrement()) + businessId BigInt @map("business_id") + pageKind WebsitePageKind @map("page_kind") + entityId BigInt @default(0) @map("entity_id") + totalCount BigInt @default(0) @map("total_count") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([businessId, pageKind, entityId], map: "uq_website_page_view_counters_biz_kind_entity") + @@index([businessId, pageKind], map: "idx_website_page_view_counters_business_kind") + @@map("website_page_view_counters") } diff --git a/src/app.module.ts b/src/app.module.ts index dd425ef..10b6cdb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { RedisModule } from './redis/redis.module'; import { AuthModule } from './auth/auth.module'; import { BusinessTeamModule } from './business-team/business-team.module'; import { BusinessAdminModule } from './business-admin/business-admin.module'; +import { UserTitlesModule } from './user-titles/user-titles.module'; import { UsersModule } from './users/users.module'; import { RolesModule } from './roles/roles.module'; import { TenantModule } from './tenant/tenant.module'; @@ -53,6 +54,7 @@ import { SitemapModule } from './sitemap/sitemap.module'; AuthModule, BusinessTeamModule, BusinessAdminModule, + UserTitlesModule, UsersModule, RolesModule, TenantModule, diff --git a/src/blogs/blogs.controller.ts b/src/blogs/blogs.controller.ts index 55590ee..42ccc81 100644 --- a/src/blogs/blogs.controller.ts +++ b/src/blogs/blogs.controller.ts @@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { BlogsService } from './blogs.service'; import { CreateBlogCommentDto, @@ -92,16 +93,23 @@ export class BlogsController { @Controller('tenants/:host/blogs') export class PublicBlogsController { - constructor(private readonly service: BlogsService) {} + constructor( + private readonly service: BlogsService, + private readonly analytics: WebsiteAnalyticsService, + ) {} @Get() - list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) { - return this.service.listPublic(host, query); + async list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) { + const result = await this.service.listPublic(host, query); + this.analytics.trackPublicPage(host, 'blog_list'); + return result; } @Get('by-id/:blogId') - getById(@Param('host') host: string, @Param('blogId') blogId: string) { - return this.service.getPublicById(host, blogId); + async getById(@Param('host') host: string, @Param('blogId') blogId: string) { + const result = await this.service.getPublicById(host, blogId); + this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id); + return result; } @Get(':blogId/comments') @@ -119,7 +127,9 @@ export class PublicBlogsController { } @Get(':slug') - getBySlug(@Param('host') host: string, @Param('slug') slug: string) { - return this.service.getPublicBySlug(host, slug); + async getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + const result = await this.service.getPublicBySlug(host, slug); + this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id); + return result; } } diff --git a/src/blogs/blogs.module.ts b/src/blogs/blogs.module.ts index 814c35e..2556082 100644 --- a/src/blogs/blogs.module.ts +++ b/src/blogs/blogs.module.ts @@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module'; import { BusinessSettingsModule } from '../business-settings/business-settings.module'; import { SitemapModule } from '../sitemap/sitemap.module'; import { TenantModule } from '../tenant/tenant.module'; +import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module'; import { BlogsController, PublicBlogsController } from './blogs.controller'; import { BlogsService } from './blogs.service'; @Module({ - imports: [AuthModule, BusinessSettingsModule, SitemapModule, TenantModule], + imports: [ + AuthModule, + BusinessSettingsModule, + SitemapModule, + TenantModule, + WebsiteAnalyticsModule, + ], controllers: [BlogsController, PublicBlogsController], providers: [BlogsService], }) diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index a85206c..a5b5ed1 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -59,6 +59,7 @@ type BusinessRow = { about: string | null; slug: string; createdAt: Date; + annualRenewalAt: Date | null; isActive: boolean; oldBusinessId: bigint | null; domainId: bigint | null; @@ -142,6 +143,7 @@ export class BusinessAdminService { b.about AS "about", b.slug AS "slug", b.created_at AS "createdAt", + b.annual_renewal_at AS "annualRenewalAt", b.is_active AS "isActive", b.old_business_id AS "oldBusinessId", dom.id AS "domainId", @@ -372,6 +374,9 @@ export class BusinessAdminService { nameFa: dto.nameFa.trim(), about: dto.about?.trim() ?? null, slug, + annualRenewalAt: dto.annualRenewalAt + ? new Date(dto.annualRenewalAt) + : null, oldBusinessId: dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined, settings: toPrismaJson({ @@ -455,6 +460,14 @@ export class BusinessAdminService { dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId), } : {}), + ...(dto.annualRenewalAt !== undefined + ? { + annualRenewalAt: + dto.annualRenewalAt === null + ? null + : new Date(dto.annualRenewalAt), + } + : {}), }, }); @@ -1075,6 +1088,7 @@ export class BusinessAdminService { tx: Prisma.TransactionClient, dto: Pick< CreateBusinessDto, + | 'ownerTitleId' | 'ownerFirstName' | 'ownerLastName' | 'ownerFirstNameEn' @@ -1087,26 +1101,45 @@ export class BusinessAdminService { where: { cellNumber: dto.ownerCellNumber }, }); + let titleId: bigint | null | undefined = undefined; + if (dto.ownerTitleId !== undefined) { + const title = await tx.userTitle.findFirst({ + where: { id: BigInt(dto.ownerTitleId), isActive: true }, + select: { id: true }, + }); + if (!title) { + throw new BadRequestException('Owner title not found'); + } + titleId = title.id; + } + + const passwordHash = await bcrypt.hash(dto.ownerPassword, 10); + const nameData = { + ...(titleId !== undefined ? { titleId } : {}), + firstName: dto.ownerFirstName.trim(), + lastName: dto.ownerLastName.trim(), + firstNameEn: dto.ownerFirstNameEn.trim(), + lastNameEn: dto.ownerLastNameEn.trim(), + passwordHash, + }; + // Users can own multiple businesses. If the cell number already exists, - // leave that user unchanged and only attach the new business as owner. + // refresh name + password from the create form, then attach as owner. if (existing) { if (!existing.isActive) { throw new BadRequestException('Owner user is inactive'); } - return existing; + return tx.user.update({ + where: { id: existing.id }, + data: nameData, + }); } - const passwordHash = await bcrypt.hash(dto.ownerPassword, 10); - return tx.user.create({ data: { cellNumber: dto.ownerCellNumber, - passwordHash, - firstName: dto.ownerFirstName.trim(), - lastName: dto.ownerLastName.trim(), - firstNameEn: dto.ownerFirstNameEn.trim(), - lastNameEn: dto.ownerLastNameEn.trim(), cellVerifiedAt: new Date(), + ...nameData, }, }); } @@ -1176,6 +1209,7 @@ export class BusinessAdminService { slug: string; isActive: boolean; oldBusinessId: bigint | null; + annualRenewalAt: Date | null; createdAt: Date; updatedAt: Date; categoryAssignments: { @@ -1217,6 +1251,7 @@ export class BusinessAdminService { isActive: business.isActive, oldBusinessId: business.oldBusinessId != null ? business.oldBusinessId.toString() : null, + annualRenewalAt: business.annualRenewalAt, createdAt: business.createdAt, updatedAt: business.updatedAt, categories: business.categoryAssignments.map((a) => ({ diff --git a/src/business-admin/dto/create-business.dto.ts b/src/business-admin/dto/create-business.dto.ts index 653d66e..db0be89 100644 --- a/src/business-admin/dto/create-business.dto.ts +++ b/src/business-admin/dto/create-business.dto.ts @@ -1,6 +1,7 @@ import { ArrayMinSize, IsArray, + IsDateString, IsInt, IsOptional, IsPositive, @@ -36,6 +37,17 @@ export class CreateBusinessDto { @IsInt({ each: true }) categoryIds!: number[]; + /** Annual service renewal date (ISO). */ + @IsOptional() + @IsDateString() + annualRenewalAt?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @IsPositive() + ownerTitleId?: number; + @IsString() @MinLength(2) ownerFirstName!: string; diff --git a/src/business-admin/dto/update-business.dto.ts b/src/business-admin/dto/update-business.dto.ts index 73fa7e2..a736818 100644 --- a/src/business-admin/dto/update-business.dto.ts +++ b/src/business-admin/dto/update-business.dto.ts @@ -1,6 +1,7 @@ import { ArrayMinSize, IsArray, + IsDateString, IsInt, IsOptional, IsPositive, @@ -53,4 +54,9 @@ export class UpdateBusinessDto { @IsInt() @IsPositive() oldBusinessId?: number | null; + + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsDateString() + annualRenewalAt?: string | null; } diff --git a/src/cart/cart.service.ts b/src/cart/cart.service.ts index 43d2cac..c3dbaec 100644 --- a/src/cart/cart.service.ts +++ b/src/cart/cart.service.ts @@ -92,6 +92,8 @@ export class CartService { data: { quantity: newQuantity }, }); + await this.bumpProductCartAdd(variant.storeItem.productId, quantity); + const refreshed = await this.loadCart(cart.id); return { message: 'Cart item quantity updated', @@ -107,6 +109,8 @@ export class CartService { }, }); + await this.bumpProductCartAdd(variant.storeItem.productId, quantity); + const refreshed = await this.loadCart(cart.id); return { message: 'Item added to cart', @@ -307,6 +311,14 @@ export class CartService { }); } + private async bumpProductCartAdd(productId: bigint, quantity: number) { + if (!Number.isFinite(quantity) || quantity <= 0) return; + await this.prisma.product.update({ + where: { id: productId }, + data: { cartAddCount: { increment: quantity } }, + }); + } + private async loadPurchasableVariant( businessId: bigint, storeItemVariantId: bigint, diff --git a/src/common/tls-probe.ts b/src/common/tls-probe.ts index c30de81..2aca672 100644 --- a/src/common/tls-probe.ts +++ b/src/common/tls-probe.ts @@ -2,8 +2,13 @@ import * as tls from 'tls'; const DEFAULT_TIMEOUT_MS = 4000; +export type TlsProbeResult = { + ok: boolean; + /** Certificate notAfter when the probe succeeded and a peer cert was present. */ + expiresAt: Date | null; +}; + function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean { - // detailed=true so SAN is present for checkServerIdentity const cert = socket.getPeerCertificate(true); if (!cert || Object.keys(cert).length === 0) { return false; @@ -16,26 +21,43 @@ function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean { } } +function readCertExpiry(socket: tls.TLSSocket): Date | null { + const cert = socket.getPeerCertificate(true); + if (!cert?.valid_to) return null; + const expiresAt = new Date(cert.valid_to); + return Number.isNaN(expiresAt.getTime()) ? null : expiresAt; +} + +/** Earliest non-null expiry among successful probe results (or null). */ +export function earliestTlsExpiry( + ...results: Array +): Date | null { + const times = results + .filter((r): r is TlsProbeResult => !!r?.ok && !!r.expiresAt) + .map((r) => r.expiresAt!.getTime()); + if (times.length === 0) return null; + return new Date(Math.min(...times)); +} + /** * Live TLS check: connect to host:443 with SNI and require a trusted cert * whose identity matches the hostname (not merely a valid LE chain for another name). - * Returns false on timeout, DNS failure, or cert/hostname errors. */ -export function probeTlsHost( +export function probeTlsHostDetailed( hostRaw: string, timeoutMs = DEFAULT_TIMEOUT_MS, -): Promise { +): Promise { const host = hostRaw.trim().toLowerCase(); if (!host || host.includes(':') || host.endsWith('.local')) { - return Promise.resolve(false); + return Promise.resolve({ ok: false, expiresAt: null }); } return new Promise((resolve) => { let settled = false; - const finish = (ok: boolean) => { + const finish = (result: TlsProbeResult) => { if (settled) return; settled = true; - resolve(ok); + resolve(result); }; let socket: tls.TLSSocket; @@ -49,35 +71,44 @@ export function probeTlsHost( timeout: timeoutMs, }, () => { - // authorized=true alone is not enough: a default vhost can present - // another site's valid cert (ERR_CERT_COMMON_NAME_INVALID in browsers). - const ok = socket.authorized === true && peerMatchesHost(host, socket); + const ok = + socket.authorized === true && peerMatchesHost(host, socket); + const expiresAt = ok ? readCertExpiry(socket) : null; socket.destroy(); - finish(ok); + finish({ ok, expiresAt }); }, ); } catch { - finish(false); + finish({ ok: false, expiresAt: null }); return; } socket.on('error', () => { socket.destroy(); - finish(false); + finish({ ok: false, expiresAt: null }); }); socket.on('timeout', () => { socket.destroy(); - finish(false); + finish({ ok: false, expiresAt: null }); }); }); } -/** Probe many hosts in parallel (chunked); returns Map host → ssl ok. */ -export async function probeTlsHosts( +/** Boolean wrapper for callers that only need ok/fail. */ +export async function probeTlsHost( + hostRaw: string, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + const result = await probeTlsHostDetailed(hostRaw, timeoutMs); + return result.ok; +} + +/** Probe many hosts in parallel (chunked); returns Map host → detailed result. */ +export async function probeTlsHostsDetailed( hosts: Array, chunkSize = 10, -): Promise> { +): Promise> { const unique = [ ...new Set( hosts @@ -86,16 +117,31 @@ export async function probeTlsHosts( ), ]; - const results = new Map(); + const results = new Map(); for (let i = 0; i < unique.length; i += chunkSize) { const chunk = unique.slice(i, i + chunkSize); const probed = await Promise.all( - chunk.map(async (host) => [host, await probeTlsHost(host)] as const), + chunk.map( + async (host) => [host, await probeTlsHostDetailed(host)] as const, + ), ); - for (const [host, ok] of probed) { - results.set(host, ok); + for (const [host, result] of probed) { + results.set(host, result); } } return results; } + +/** Probe many hosts; returns Map host → ssl ok. */ +export async function probeTlsHosts( + hosts: Array, + chunkSize = 10, +): Promise> { + const detailed = await probeTlsHostsDetailed(hosts, chunkSize); + const results = new Map(); + for (const [host, result] of detailed) { + results.set(host, result.ok); + } + return results; +} diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index c2f0b3d..c2881e7 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -12,7 +12,11 @@ import { Prisma } from '@prisma/client'; import { ArvanDnsService } from '../arvan-dns/arvan-dns.service'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; -import { probeTlsHost } from '../common/tls-probe'; +import { + earliestTlsExpiry, + probeTlsHost, + probeTlsHostDetailed, +} from '../common/tls-probe'; import { PrismaService } from '../prisma/prisma.service'; import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service'; import { DisableDomainDto } from './dto/disable-domain.dto'; @@ -26,6 +30,7 @@ type DomainRow = { businessId: bigint; businessName: string; sslEnabled: boolean; + sslExpiresAt: Date | null; isActive: boolean; expiresAt: Date | null; createdAt: Date; @@ -71,6 +76,7 @@ export class DomainAdminService { d.business_id AS "businessId", b.name AS "businessName", d.ssl_enabled AS "sslEnabled", + d.ssl_expires_at AS "sslExpiresAt", d.is_active AS "isActive", d.expires_at AS "expiresAt", d.created_at AS "createdAt", @@ -90,7 +96,19 @@ export class DomainAdminService { `), ]); - return { items: rows, total: totalRow[0]?.total ?? 0, page, pageSize }; + const now = Date.now(); + const items = rows.map((row) => { + const certStillValid = + !row.sslExpiresAt || row.sslExpiresAt.getTime() > now; + return { + ...row, + // List badge: issued/probed OK and cert not past notAfter. + sslEnabled: row.sslEnabled && certStillValid, + sslExpiresAt: row.sslExpiresAt, + }; + }); + + return { items, total: totalRow[0]?.total ?? 0, page, pageSize }; } async syncSsl(actor: AuthUser) { @@ -185,26 +203,28 @@ export class DomainAdminService { const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = []; for (const domain of candidates) { - const apexOk = await probeTlsHost(domain.host); + const apexProbe = await probeTlsHostDetailed(domain.host); const wwwHost = domain.host.startsWith('www.') ? domain.host : `www.${domain.host}`; - const wwwOk = await probeTlsHost(wwwHost); - const ok = apexOk && wwwOk; + const wwwProbe = await probeTlsHostDetailed(wwwHost); + const ok = apexProbe.ok && wwwProbe.ok; if (ok) { - if (!domain.sslEnabled) { - await this.prisma.domain.update({ - where: { id: domain.id }, - data: { sslEnabled: true }, - }); - } + const sslExpiresAt = earliestTlsExpiry(apexProbe, wwwProbe); + await this.prisma.domain.update({ + where: { id: domain.id }, + data: { + sslEnabled: true, + ...(sslExpiresAt ? { sslExpiresAt } : {}), + }, + }); continue; } if (domain.sslEnabled) { await this.prisma.domain.update({ where: { id: domain.id }, - data: { sslEnabled: false }, + data: { sslEnabled: false, sslExpiresAt: null }, }); } targets.push(domain); @@ -228,14 +248,21 @@ export class DomainAdminService { host: domain.host, slug: domain.deploySlug, }); - const ok = await probeTlsHost(domain.host); + const apexProbe = await probeTlsHostDetailed(domain.host); const wwwHost = domain.host.startsWith('www.') ? domain.host : `www.${domain.host}`; - const wwwOk = ok && (await probeTlsHost(wwwHost)); + const wwwProbe = await probeTlsHostDetailed(wwwHost); + const wwwOk = apexProbe.ok && wwwProbe.ok; + const sslExpiresAt = wwwOk + ? earliestTlsExpiry(apexProbe, wwwProbe) + : null; await this.prisma.domain.update({ where: { id: domain.id }, - data: { sslEnabled: wwwOk }, + data: { + sslEnabled: wwwOk, + sslExpiresAt, + }, }); if (wwwOk) { issued.push(domain.host); @@ -297,10 +324,12 @@ export class DomainAdminService { customer: { host: customerHost, status: 'ok' }, }; - let apexOk = await probeTlsHost(apex); - let wwwOk = await probeTlsHost(wwwHost); + let apexProbe = await probeTlsHostDetailed(apex); + let wwwProbe = await probeTlsHostDetailed(wwwHost); let businessOk = await probeTlsHost(businessHost); let customerOk = await probeTlsHost(customerHost); + let apexOk = apexProbe.ok; + let wwwOk = wwwProbe.ok; const storefrontNeedsIssue = !apexOk || !wwwOk; @@ -325,8 +354,10 @@ export class DomainAdminService { } else { try { await this.websiteDeployAgent.issueSsl({ host: apex, slug }); - apexOk = await probeTlsHost(apex); - wwwOk = await probeTlsHost(wwwHost); + apexProbe = await probeTlsHostDetailed(apex); + wwwProbe = await probeTlsHostDetailed(wwwHost); + apexOk = apexProbe.ok; + wwwOk = wwwProbe.ok; hosts.apex = apexOk ? { host: apex, @@ -416,9 +447,22 @@ export class DomainAdminService { } const storefrontOk = apexOk && wwwOk; + // Re-probe apex/www once more when already-ok so we persist current cert expiry. + if (storefrontOk && (!apexProbe.expiresAt || !wwwProbe.expiresAt)) { + apexProbe = await probeTlsHostDetailed(apex); + wwwProbe = await probeTlsHostDetailed(wwwHost); + apexOk = apexProbe.ok; + wwwOk = wwwProbe.ok; + } + const sslExpiresAt = + apexOk && wwwOk ? earliestTlsExpiry(apexProbe, wwwProbe) : null; + const updated = await this.prisma.domain.update({ where: { id: domainId }, - data: { sslEnabled: storefrontOk }, + data: { + sslEnabled: storefrontOk && apexOk && wwwOk, + sslExpiresAt: storefrontOk && apexOk && wwwOk ? sslExpiresAt : null, + }, }); const parts = [hosts.apex, hosts.www, hosts.business, hosts.customer]; @@ -434,6 +478,7 @@ export class DomainAdminService { status: failed.length === 0 ? ('ok' as const) : ('partial' as const), host: apex, sslEnabled: updated.sslEnabled, + sslExpiresAt: updated.sslExpiresAt?.toISOString() ?? null, hosts, issued: issued.map((p) => p.host), failed: failed.map((p) => ({ host: p.host, error: p.detail || 'failed' })), @@ -579,7 +624,10 @@ export class DomainAdminService { return this.prisma.domain.update({ where: { id: domainId }, - data: { sslEnabled: dto.sslEnabled }, + data: { + sslEnabled: dto.sslEnabled, + ...(dto.sslEnabled ? {} : { sslExpiresAt: null }), + }, }); } diff --git a/src/internal-ssl/internal-ssl.service.ts b/src/internal-ssl/internal-ssl.service.ts index 4d2db9b..2db01a8 100644 --- a/src/internal-ssl/internal-ssl.service.ts +++ b/src/internal-ssl/internal-ssl.service.ts @@ -1,12 +1,15 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Cron, CronExpression } from '@nestjs/schedule'; import { filterHostsWithDns } from '../common/dns-resolve'; +import { + earliestTlsExpiry, + probeTlsHostsDetailed, +} from '../common/tls-probe'; import { PrismaService } from '../prisma/prisma.service'; -import { probeTlsHosts } from '../common/tls-probe'; @Injectable() -export class InternalSslService implements OnModuleInit { +export class InternalSslService { private readonly logger = new Logger(InternalSslService.name); private refreshRunning = false; @@ -15,17 +18,17 @@ export class InternalSslService implements OnModuleInit { private readonly config: ConfigService, ) {} - onModuleInit() { - // Warm ssl_enabled shortly after boot so lists are accurate without waiting for cron. - setTimeout(() => { - void this.refreshSslStatuses('startup'); - }, 5_000); - } - - private async listActiveDomains(): Promise> { + private async listActiveDomains(): Promise< + Array<{ + id: bigint; + host: string; + sslEnabled: boolean; + sslExpiresAt: Date | null; + }> + > { return this.prisma.domain.findMany({ where: { isActive: true }, - select: { id: true, host: true, sslEnabled: true }, + select: { id: true, host: true, sslEnabled: true, sslExpiresAt: true }, orderBy: { host: 'asc' }, }); } @@ -82,15 +85,15 @@ export class InternalSslService implements OnModuleInit { } /** - * Probe each active apex host and sync domains.ssl_enabled to the live result. - * Runs every 2 hours (and once shortly after API boot). + * Occasional safety probe (once a day). Issue SSL writes ssl_expires_at and + * is the main source of truth for the list badge between probes. */ - @Cron(CronExpression.EVERY_2_HOURS) + @Cron(CronExpression.EVERY_DAY_AT_4AM) async refreshSslStatusesCron() { await this.refreshSslStatuses('cron'); } - async refreshSslStatuses(reason: 'cron' | 'startup' | 'manual' = 'manual') { + async refreshSslStatuses(reason: 'cron' | 'manual' = 'manual') { if (this.refreshRunning) { this.logger.warn(`SSL status refresh skipped (${reason}): already running`); return { updated: 0, checked: 0, skipped: true as const }; @@ -99,30 +102,79 @@ export class InternalSslService implements OnModuleInit { this.refreshRunning = true; const started = Date.now(); try { + const now = new Date(); const domains = await this.listActiveDomains(); + + // Expire flags that are past cert notAfter without waiting for a probe. + let updated = 0; + for (const domain of domains) { + if ( + domain.sslEnabled && + domain.sslExpiresAt && + domain.sslExpiresAt.getTime() <= now.getTime() + ) { + await this.prisma.domain.update({ + where: { id: domain.id }, + data: { sslEnabled: false }, + }); + domain.sslEnabled = false; + updated += 1; + this.logger.log( + `SSL status ${domain.host}: expired at ${domain.sslExpiresAt.toISOString()}`, + ); + } + } + const hostsToProbe = domains.flatMap((d) => { const apex = d.host.trim().toLowerCase(); if (!apex) return []; const www = apex.startsWith('www.') ? apex : `www.${apex}`; return [apex, www]; }); - const sslByHost = await probeTlsHosts(hostsToProbe); + const sslByHost = await probeTlsHostsDetailed(hostsToProbe); - let updated = 0; for (const domain of domains) { const hostKey = domain.host.trim().toLowerCase(); const wwwKey = hostKey.startsWith('www.') ? hostKey : `www.${hostKey}`; - const sslEnabled = - (sslByHost.get(hostKey) ?? false) && (sslByHost.get(wwwKey) ?? false); - if (sslEnabled === domain.sslEnabled) continue; + const apexResult = sslByHost.get(hostKey) ?? { + ok: false, + expiresAt: null, + }; + const wwwResult = sslByHost.get(wwwKey) ?? { + ok: false, + expiresAt: null, + }; + const liveOk = apexResult.ok && wwwResult.ok; + const sslExpiresAt = liveOk + ? earliestTlsExpiry(apexResult, wwwResult) + : null; + + const nextEnabled = liveOk; + const nextExpires = + liveOk && sslExpiresAt + ? sslExpiresAt + : liveOk + ? domain.sslExpiresAt + : null; + + const enabledChanged = nextEnabled !== domain.sslEnabled; + const expiryChanged = + (nextExpires?.getTime() ?? null) !== + (domain.sslExpiresAt?.getTime() ?? null); + + if (!enabledChanged && !expiryChanged) continue; await this.prisma.domain.update({ where: { id: domain.id }, - data: { sslEnabled }, + data: { + sslEnabled: nextEnabled, + sslExpiresAt: nextExpires, + }, }); updated += 1; this.logger.log( - `SSL status ${domain.host}: ${domain.sslEnabled} → ${sslEnabled}`, + `SSL status ${domain.host}: enabled ${domain.sslEnabled}→${nextEnabled}` + + (nextExpires ? ` expires ${nextExpires.toISOString()}` : ''), ); } diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index 293b593..15df72b 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -509,6 +509,11 @@ export class OrdersService { ); } } + + await tx.product.update({ + where: { id: item.productId }, + data: { saleCount: { increment: item.quantity } }, + }); } return created as OrderWithItems; diff --git a/src/portfolios/portfolios.controller.ts b/src/portfolios/portfolios.controller.ts index ba9d371..bac6b0d 100644 --- a/src/portfolios/portfolios.controller.ts +++ b/src/portfolios/portfolios.controller.ts @@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { CreatePortfolioCommentDto, CreatePortfolioDto, @@ -92,19 +93,26 @@ export class PortfoliosController { @Controller('tenants/:host/portfolios') export class PublicPortfoliosController { - constructor(private readonly service: PortfoliosService) {} + constructor( + private readonly service: PortfoliosService, + private readonly analytics: WebsiteAnalyticsService, + ) {} @Get() - list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) { - return this.service.listPublic(host, query); + async list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) { + const result = await this.service.listPublic(host, query); + this.analytics.trackPublicPage(host, 'portfolio_list'); + return result; } @Get('by-id/:portfolioId') - getById( + async getById( @Param('host') host: string, @Param('portfolioId') portfolioId: string, ) { - return this.service.getPublicById(host, portfolioId); + const result = await this.service.getPublicById(host, portfolioId); + this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id); + return result; } @Get(':portfolioId/comments') @@ -125,7 +133,9 @@ export class PublicPortfoliosController { } @Get(':slug') - getBySlug(@Param('host') host: string, @Param('slug') slug: string) { - return this.service.getPublicBySlug(host, slug); + async getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + const result = await this.service.getPublicBySlug(host, slug); + this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id); + return result; } } diff --git a/src/portfolios/portfolios.module.ts b/src/portfolios/portfolios.module.ts index e112cd3..2dac8e4 100644 --- a/src/portfolios/portfolios.module.ts +++ b/src/portfolios/portfolios.module.ts @@ -4,6 +4,7 @@ import { BusinessSettingsModule } from '../business-settings/business-settings.m import { MediaModule } from '../media/media.module'; import { SitemapModule } from '../sitemap/sitemap.module'; import { TenantModule } from '../tenant/tenant.module'; +import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module'; import { PortfoliosController, PublicPortfoliosController, @@ -11,7 +12,14 @@ import { import { PortfoliosService } from './portfolios.service'; @Module({ - imports: [AuthModule, BusinessSettingsModule, MediaModule, SitemapModule, TenantModule], + imports: [ + AuthModule, + BusinessSettingsModule, + MediaModule, + SitemapModule, + TenantModule, + WebsiteAnalyticsModule, + ], controllers: [PortfoliosController, PublicPortfoliosController], providers: [PortfoliosService], }) diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 9a0e15f..7413bda 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -15,6 +15,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto'; import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto'; import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto'; @@ -164,11 +165,14 @@ export class PublicProductsController { private readonly service: ProductsService, private readonly variationValuesService: ProductVariationValuesService, private readonly technicalInfoService: ProductTechnicalInfoService, + private readonly analytics: WebsiteAnalyticsService, ) {} @Get() - list(@Param('host') host: string, @Query() query: ListPublicProductsDto) { - return this.service.listPublic(host, query); + async list(@Param('host') host: string, @Query() query: ListPublicProductsDto) { + const result = await this.service.listPublic(host, query); + this.analytics.trackPublicPage(host, 'product_list'); + return result; } @Get('by-id/:productId/variations') @@ -196,8 +200,11 @@ export class PublicProductsController { } @Get('by-id/:productId') - getById(@Param('host') host: string, @Param('productId') productId: string) { - return this.service.getPublicById(host, productId); + async getById(@Param('host') host: string, @Param('productId') productId: string) { + const result = await this.service.getPublicById(host, productId); + this.analytics.trackPublicPage(host, 'product_detail', result.product.id); + this.service.bumpVisitCount(result.product.id); + return result; } @Get(':slug/variations') @@ -225,7 +232,10 @@ export class PublicProductsController { } @Get(':slug') - getBySlug(@Param('host') host: string, @Param('slug') slug: string) { - return this.service.getPublicBySlug(host, slug); + async getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + const result = await this.service.getPublicBySlug(host, slug); + this.analytics.trackPublicPage(host, 'product_detail', result.product.id); + this.service.bumpVisitCount(result.product.id); + return result; } } diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 70a0d3e..6cc67d1 100644 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -5,6 +5,7 @@ import { CategoriesModule } from '../categories/categories.module'; import { MediaModule } from '../media/media.module'; import { SitemapModule } from '../sitemap/sitemap.module'; import { TenantModule } from '../tenant/tenant.module'; +import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module'; import { ProductAiService } from './product-ai.service'; import { ProductTechnicalInfoService } from './product-technical-info.service'; import { ProductVariationValuesService } from './product-variation-values.service'; @@ -12,7 +13,15 @@ import { ProductsController, PublicProductsController } from './products.control import { ProductsService } from './products.service'; @Module({ - imports: [AuthModule, BrandsModule, CategoriesModule, MediaModule, SitemapModule, TenantModule], + imports: [ + AuthModule, + BrandsModule, + CategoriesModule, + MediaModule, + SitemapModule, + TenantModule, + WebsiteAnalyticsModule, + ], controllers: [ProductsController, PublicProductsController], providers: [ ProductsService, diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 853fe5f..777e43a 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -602,11 +602,39 @@ export class ProductsService { galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()), commentCount, variantCount: resolvedVariantCount, + visitCount: product.visitCount.toString(), + cartAddCount: product.cartAddCount.toString(), + saleCount: product.saleCount.toString(), createdAt: product.createdAt, updatedAt: product.updatedAt, }; } + /** Bump product detail visit counter (fire-and-forget safe). */ + bumpVisitCount(productId: string | bigint): void { + void this.incrementProductStat(productId, 'visitCount', 1).catch(() => undefined); + } + + bumpCartAddCount(productId: string | bigint, quantity = 1): void { + void this.incrementProductStat(productId, 'cartAddCount', quantity).catch( + () => undefined, + ); + } + + private async incrementProductStat( + productIdRaw: string | bigint, + field: 'visitCount' | 'cartAddCount' | 'saleCount', + by: number, + ) { + if (!Number.isFinite(by) || by <= 0) return; + const productId = + typeof productIdRaw === 'bigint' ? productIdRaw : BigInt(productIdRaw); + await this.prisma.product.update({ + where: { id: productId }, + data: { [field]: { increment: by } }, + }); + } + private buildContent(nameFa?: string, descriptionHtml?: string) { return { nameFa: nameFa?.trim() || null, diff --git a/src/store/store-items.controller.ts b/src/store/store-items.controller.ts index a13deb0..82123a0 100644 --- a/src/store/store-items.controller.ts +++ b/src/store/store-items.controller.ts @@ -21,6 +21,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { BatchCreateStoreItemsDto, BatchUpdateStoreItemDiscountsDto, @@ -166,20 +167,41 @@ export class StoreItemsController { @Controller('tenants/:host/store-items') export class PublicStoreItemsController { - constructor(private readonly service: StoreItemsService) {} + constructor( + private readonly service: StoreItemsService, + private readonly analytics: WebsiteAnalyticsService, + ) {} @Get() - list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) { - return this.service.listPublic(host, query); + async list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) { + const result = await this.service.listPublic(host, query); + this.analytics.trackPublicPage(host, 'store_item_list'); + return result; } @Get('by-product/:productId') - getByProduct(@Param('host') host: string, @Param('productId') productId: string) { - return this.service.getPublicByProduct(host, productId); + async getByProduct( + @Param('host') host: string, + @Param('productId') productId: string, + ) { + const result = await this.service.getPublicByProduct(host, productId); + if (result.storeItem) { + this.analytics.trackPublicPage(host, 'store_item_detail', result.storeItem.id); + } + return result; } @Get(':variantId') - getVariant(@Param('host') host: string, @Param('variantId') variantId: string) { - return this.service.getPublicVariant(host, variantId); + async getVariant( + @Param('host') host: string, + @Param('variantId') variantId: string, + ) { + const result = await this.service.getPublicVariant(host, variantId); + this.analytics.trackPublicPage( + host, + 'store_item_detail', + result.variant.storeItemId, + ); + return result; } } diff --git a/src/store/store.module.ts b/src/store/store.module.ts index 8b3045f..6117903 100644 --- a/src/store/store.module.ts +++ b/src/store/store.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { TenantModule } from '../tenant/tenant.module'; +import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module'; import { PublicStoreSpecialsController, StoreSpecialsController, @@ -10,7 +11,7 @@ import { StoreItemsController, PublicStoreItemsController } from './store-items. import { StoreItemsService } from './store-items.service'; @Module({ - imports: [AuthModule, TenantModule], + imports: [AuthModule, TenantModule, WebsiteAnalyticsModule], controllers: [ StoreItemsController, PublicStoreItemsController, diff --git a/src/user-titles/user-titles.controller.ts b/src/user-titles/user-titles.controller.ts new file mode 100644 index 0000000..74c20b4 --- /dev/null +++ b/src/user-titles/user-titles.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { UserTitlesService } from './user-titles.service'; + +@Controller('user-titles') +@UseGuards(JwtAuthGuard) +export class UserTitlesController { + constructor(private readonly service: UserTitlesService) {} + + @Get() + list() { + return this.service.listActive(); + } +} diff --git a/src/user-titles/user-titles.module.ts b/src/user-titles/user-titles.module.ts new file mode 100644 index 0000000..4f4a637 --- /dev/null +++ b/src/user-titles/user-titles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { UserTitlesController } from './user-titles.controller'; +import { UserTitlesService } from './user-titles.service'; + +@Module({ + imports: [AuthModule], + controllers: [UserTitlesController], + providers: [UserTitlesService], + exports: [UserTitlesService], +}) +export class UserTitlesModule {} diff --git a/src/user-titles/user-titles.service.ts b/src/user-titles/user-titles.service.ts new file mode 100644 index 0000000..18fca73 --- /dev/null +++ b/src/user-titles/user-titles.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class UserTitlesService { + constructor(private readonly prisma: PrismaService) {} + + async listActive() { + const items = await this.prisma.userTitle.findMany({ + where: { isActive: true }, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + select: { + id: true, + slug: true, + nameEn: true, + nameFa: true, + }, + }); + + return { + items: items.map((item) => ({ + id: item.id.toString(), + slug: item.slug, + nameEn: item.nameEn, + nameFa: item.nameFa, + })), + }; + } +} diff --git a/src/videos/videos.controller.ts b/src/videos/videos.controller.ts index c8c2328..028b1b8 100644 --- a/src/videos/videos.controller.ts +++ b/src/videos/videos.controller.ts @@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { VideosService } from './videos.service'; import { CreateVideoCommentDto, @@ -92,11 +93,16 @@ export class VideosController { @Controller('tenants/:host/videos') export class PublicVideosController { - constructor(private readonly service: VideosService) {} + constructor( + private readonly service: VideosService, + private readonly analytics: WebsiteAnalyticsService, + ) {} @Get() - list(@Param('host') host: string, @Query() query: ListPublicVideosDto) { - return this.service.listPublic(host, query); + async list(@Param('host') host: string, @Query() query: ListPublicVideosDto) { + const result = await this.service.listPublic(host, query); + this.analytics.trackPublicPage(host, 'video_list'); + return result; } @Get(':videoId/comments') @@ -114,7 +120,9 @@ export class PublicVideosController { } @Get(':slug') - getBySlug(@Param('host') host: string, @Param('slug') slug: string) { - return this.service.getPublicBySlug(host, slug); + async getBySlug(@Param('host') host: string, @Param('slug') slug: string) { + const result = await this.service.getPublicBySlug(host, slug); + this.analytics.trackPublicPage(host, 'video_detail', result.video.id); + return result; } } diff --git a/src/videos/videos.module.ts b/src/videos/videos.module.ts index f2f605c..f445694 100644 --- a/src/videos/videos.module.ts +++ b/src/videos/videos.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { BusinessSettingsModule } from '../business-settings/business-settings.module'; import { TenantModule } from '../tenant/tenant.module'; +import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module'; import { PublicVideosController, VideosController } from './videos.controller'; import { VideosService } from './videos.service'; @Module({ - imports: [AuthModule, BusinessSettingsModule, TenantModule], + imports: [AuthModule, BusinessSettingsModule, TenantModule, WebsiteAnalyticsModule], controllers: [VideosController, PublicVideosController], providers: [VideosService], }) diff --git a/src/website-analytics/dto/record-view.dto.ts b/src/website-analytics/dto/record-view.dto.ts index cb5b188..6e7a8ee 100644 --- a/src/website-analytics/dto/record-view.dto.ts +++ b/src/website-analytics/dto/record-view.dto.ts @@ -1,17 +1,28 @@ import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; -import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types'; +import { + WEBSITE_PAGE_KINDS, + type WebsitePageKind, +} from '../website-analytics.types'; + +/** Accepts canonical page kinds plus legacy aliases (website/product/portfolio/blog). */ +const RECORD_KINDS = [ + ...WEBSITE_PAGE_KINDS, + 'website', + 'product', + 'portfolio', + 'blog', +] as const; export class RecordWebsiteViewDto { - @IsIn([...WEBSITE_VIEW_KINDS]) - kind!: WebsiteViewKind; + @IsIn([...RECORD_KINDS]) + kind!: WebsitePageKind | 'website' | 'product' | 'portfolio' | 'blog'; - /** Product / portfolio / blog id when kind is not `website`. */ + /** Required for *_detail kinds. */ @IsOptional() @IsString() @MinLength(1) entityId?: string; - /** Page path for storefront visits (e.g. `/`, `/about`, `/products/12/slug`). */ @IsOptional() @IsString() @MaxLength(500) diff --git a/src/website-analytics/dto/view-activity-query.dto.ts b/src/website-analytics/dto/view-activity-query.dto.ts index 5847db9..ecefea9 100644 --- a/src/website-analytics/dto/view-activity-query.dto.ts +++ b/src/website-analytics/dto/view-activity-query.dto.ts @@ -1,10 +1,18 @@ import { Type } from 'class-transformer'; import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; -import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types'; +import { WEBSITE_PAGE_KINDS } from '../website-analytics.types'; + +const ACTIVITY_KINDS = [ + ...WEBSITE_PAGE_KINDS, + 'website', + 'product', + 'portfolio', + 'blog', +] as const; export class ViewActivityQueryDto { - @IsIn([...WEBSITE_VIEW_KINDS]) - kind!: WebsiteViewKind; + @IsIn([...ACTIVITY_KINDS]) + kind!: (typeof ACTIVITY_KINDS)[number]; @IsOptional() @Type(() => Number) diff --git a/src/website-analytics/website-analytics.module.ts b/src/website-analytics/website-analytics.module.ts index 143f33f..782c0fe 100644 --- a/src/website-analytics/website-analytics.module.ts +++ b/src/website-analytics/website-analytics.module.ts @@ -9,5 +9,6 @@ import { WebsiteAnalyticsService } from './website-analytics.service'; imports: [AuthModule, TenantModule], controllers: [PublicWebsiteAnalyticsController, WebsiteAnalyticsController], providers: [WebsiteAnalyticsService], + exports: [WebsiteAnalyticsService], }) export class WebsiteAnalyticsModule {} diff --git a/src/website-analytics/website-analytics.service.ts b/src/website-analytics/website-analytics.service.ts index 925229f..88aff02 100644 --- a/src/website-analytics/website-analytics.service.ts +++ b/src/website-analytics/website-analytics.service.ts @@ -2,8 +2,10 @@ import { BadRequestException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { buildDailyActivitySeries, startOfLocalDay } from '../common/daily-activity'; @@ -11,43 +13,90 @@ 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'; +import { + normalizeWebsitePageKind, + pageKindRequiresEntity, + type WebsitePageKind, +} from './website-analytics.types'; + +const EVENT_RETENTION_DAYS = 183; // ~6 months @Injectable() export class WebsiteAnalyticsService { + private readonly logger = new Logger(WebsiteAnalyticsService.name); + 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), - }, + /** + * Fire-and-forget friendly: record event + bump lifetime counter. + * Used from public list/detail GET handlers after a successful response load. + * `entityId` is trusted (already loaded by the caller); use 0 / omit for lists. + */ + trackPublicPage( + host: string, + kind: WebsitePageKind, + entityId?: string | bigint | null, + path?: string, + ): void { + void this.recordTrustedPageView(host, kind, entityId, path).catch((err) => { + this.logger.warn( + `trackPublicPage failed host=${host} kind=${kind}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); }); + } + async recordPublicView(host: string, dto: RecordWebsiteViewDto) { + const kind = normalizeWebsitePageKind(dto.kind); + if (!kind) { + throw new BadRequestException(`Invalid view kind: ${dto.kind}`); + } + await this.recordPageViewByHost(host, kind, dto.entityId, dto.path); return { ok: true as const }; } + private async recordTrustedPageView( + host: string, + kind: WebsitePageKind, + entityIdRaw?: string | bigint | null, + path?: string, + ) { + const business = await this.tenant.resolveBusinessByDomain(host); + let entityId = 0n; + if (pageKindRequiresEntity(kind)) { + const raw = + entityIdRaw === undefined || entityIdRaw === null + ? '' + : String(entityIdRaw).trim(); + if (!raw) return; + try { + entityId = typeof entityIdRaw === 'bigint' ? entityIdRaw : BigInt(raw); + } catch { + return; + } + } + await this.writeView(business.id, kind, entityId, path); + } + async dailyViewActivity( businessIdRaw: string, - kind: WebsiteViewKind, + kindRaw: string, daysRaw: number | undefined, actor: AuthUser, ) { const businessId = BigInt(businessIdRaw); await this.assertPermission(businessId, actor.id, 'business.read'); + const kind = normalizeWebsitePageKind(kindRaw); + if (!kind) { + throw new BadRequestException(`Invalid view kind: ${kindRaw}`); + } + const days = Math.min(Math.max(daysRaw ?? 30, 1), 90); const from = startOfLocalDay(days - 1); @@ -55,9 +104,9 @@ export class WebsiteAnalyticsService { Prisma.sql` SELECT viewed_at::date AS day, COUNT(*)::int AS count - FROM website_content_views + FROM website_page_views WHERE business_id = ${businessId} - AND view_kind = ${kind}::website_view_kind + AND page_kind = ${kind}::website_page_kind AND viewed_at >= ${from} GROUP BY 1 ORDER BY 1 @@ -68,33 +117,112 @@ export class WebsiteAnalyticsService { return { days, primary, - secondary: { days, total: 0, items: primary.items.map((item) => ({ ...item, count: 0 })) }, + secondary: { + days, + total: 0, + items: primary.items.map((item) => ({ ...item, count: 0 })), + }, }; } + /** Delete event rows older than ~6 months. Lifetime counters are kept. */ + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async purgeOldPageViews() { + const cutoff = new Date(); + cutoff.setUTCDate(cutoff.getUTCDate() - EVENT_RETENTION_DAYS); + + const result = await this.prisma.websitePageView.deleteMany({ + where: { viewedAt: { lt: cutoff } }, + }); + + if (result.count > 0) { + this.logger.log( + `Purged ${result.count} website_page_views older than ${cutoff.toISOString()}`, + ); + } + } + + private async recordPageViewByHost( + host: string, + kind: WebsitePageKind, + entityIdRaw?: string | bigint | null, + path?: string, + ) { + const business = await this.tenant.resolveBusinessByDomain(host); + const entityId = await this.resolveEntityId( + business.id, + kind, + entityIdRaw, + ); + + await this.writeView(business.id, kind, entityId, path); + } + + private async writeView( + businessId: bigint, + kind: WebsitePageKind, + entityId: bigint, + path?: string, + ) { + const pagePath = this.normalizePath(path); + + await this.prisma.$transaction([ + this.prisma.websitePageView.create({ + data: { + businessId, + pageKind: kind, + entityId, + pagePath, + }, + }), + this.prisma.$executeRaw` + INSERT INTO website_page_view_counters + (business_id, page_kind, entity_id, total_count, updated_at) + VALUES + (${businessId}, ${kind}::website_page_kind, ${entityId}, 1, now()) + ON CONFLICT (business_id, page_kind, entity_id) + DO UPDATE SET + total_count = website_page_view_counters.total_count + 1, + updated_at = now() + `, + ]); + } + 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); + 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; + kind: WebsitePageKind, + entityIdRaw: string | bigint | null | undefined, + ): Promise { + if (!pageKindRequiresEntity(kind)) { + return 0n; } - if (!entityIdRaw?.trim()) { + const raw = + entityIdRaw === undefined || entityIdRaw === null + ? '' + : String(entityIdRaw).trim(); + + if (!raw) { throw new BadRequestException(`entityId is required for ${kind} views`); } - const entityId = BigInt(entityIdRaw); + let entityId: bigint; + try { + entityId = typeof entityIdRaw === 'bigint' ? entityIdRaw : BigInt(raw); + } catch { + throw new BadRequestException(`Invalid entityId for ${kind}`); + } - if (kind === 'product') { + if (kind === 'product_detail') { const product = await this.prisma.product.findFirst({ where: { id: entityId, businessId }, select: { id: true }, @@ -103,7 +231,7 @@ export class WebsiteAnalyticsService { return entityId; } - if (kind === 'portfolio') { + if (kind === 'portfolio_detail') { const portfolio = await this.prisma.portfolios.findFirst({ where: { id: entityId, business_id: businessId }, select: { id: true }, @@ -112,11 +240,43 @@ export class WebsiteAnalyticsService { 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'); + if (kind === 'blog_detail') { + 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; + } + + if (kind === 'video_detail') { + const video = await this.prisma.videos.findFirst({ + where: { id: entityId, business_id: businessId }, + select: { id: true }, + }); + if (!video) throw new NotFoundException('Video not found'); + return entityId; + } + + if (kind === 'store_item_detail') { + const storeItem = await this.prisma.storeItem.findFirst({ + where: { + businessId, + OR: [{ id: entityId }, { productId: entityId }], + }, + select: { id: true }, + }); + if (storeItem) return storeItem.id; + + const variant = await this.prisma.storeItemVariant.findFirst({ + where: { id: entityId, businessId }, + select: { storeItemId: true }, + }); + if (variant) return variant.storeItemId; + + throw new NotFoundException('Store item not found'); + } + return entityId; } diff --git a/src/website-analytics/website-analytics.types.ts b/src/website-analytics/website-analytics.types.ts index e039bda..f306e03 100644 --- a/src/website-analytics/website-analytics.types.ts +++ b/src/website-analytics/website-analytics.types.ts @@ -1,25 +1,59 @@ -export const WEBSITE_VIEW_KINDS = [ - 'website', - 'product', - 'portfolio', - 'blog', +export const WEBSITE_PAGE_KINDS = [ + 'home', + 'portfolio_list', + 'portfolio_detail', + 'product_list', + 'product_detail', + 'store_item_list', + 'store_item_detail', + 'blog_list', + 'blog_detail', + 'video_list', + 'video_detail', ] as const; -export type WebsiteViewKind = (typeof WEBSITE_VIEW_KINDS)[number]; +export type WebsitePageKind = (typeof WEBSITE_PAGE_KINDS)[number]; -export function isWebsiteViewKind(value: unknown): value is WebsiteViewKind { +/** @deprecated Prefer WebsitePageKind — kept for dashboard chart query aliases. */ +export type WebsiteViewKind = WebsitePageKind | 'website' | 'product' | 'portfolio' | 'blog'; + +const DETAIL_KINDS = new Set([ + 'portfolio_detail', + 'product_detail', + 'store_item_detail', + 'blog_detail', + 'video_detail', +]); + +/** Legacy chart / POST aliases → canonical page kinds. */ +const KIND_ALIASES: Record = { + website: 'home', + product: 'product_detail', + portfolio: 'portfolio_detail', + blog: 'blog_detail', +}; + +export function isWebsitePageKind(value: unknown): value is WebsitePageKind { return ( typeof value === 'string' && - (WEBSITE_VIEW_KINDS as readonly string[]).includes(value) + (WEBSITE_PAGE_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', +export function normalizeWebsitePageKind(value: string): WebsitePageKind | null { + if (isWebsitePageKind(value)) return value; + const aliased = KIND_ALIASES[value]; + return aliased ?? null; +} + +export function pageKindRequiresEntity(kind: WebsitePageKind): boolean { + return DETAIL_KINDS.has(kind); +} + +/** Maps home dashboard chart ids to analytics page kinds. */ +export const HOME_CHART_VIEW_KIND: Partial> = { + website_views_30d: 'home', + product_views_30d: 'product_detail', + portfolio_views_30d: 'portfolio_detail', + blog_views_30d: 'blog_detail', }; diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index f0e9983..5697501 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -55,9 +55,12 @@ Public marketplace listings owned by customers — not catalog `products`. 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"`). +Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those. + +Optional explicit record (e.g. custom home without sliders): +- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }` +- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted) +- Events kept ~6 months for charts; lifetime totals kept forever in counters. ### Static images Named slots the business dashboard can replace. Fetch once per page: diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 68aa3b8..8ad16b9 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -434,7 +434,7 @@ "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.", + "description": "Optional explicit view counter. Most page views are recorded automatically when public list/detail APIs are called (blogs, products, portfolios, videos, store-items, homepage sliders). Use this for custom pages. No auth required. Events retained ~6 months; lifetime counters kept forever.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -450,15 +450,32 @@ "properties": { "kind": { "type": "string", - "enum": ["website", "product", "portfolio", "blog"] + "enum": [ + "home", + "portfolio_list", + "portfolio_detail", + "product_list", + "product_detail", + "store_item_list", + "store_item_detail", + "blog_list", + "blog_detail", + "video_list", + "video_detail", + "website", + "product", + "portfolio", + "blog" + ], + "description": "Canonical kinds preferred. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail." }, "entityId": { "type": "string", - "description": "Required when kind is product, portfolio, or blog." + "description": "Required for *_detail kinds (product, portfolio, blog, video, store item)." }, "path": { "type": "string", - "description": "Optional page path for website visits (e.g. `/`, `/about`)." + "description": "Optional page path (e.g. `/`, `/blog/my-post`)." } } } diff --git a/src/website/website-sliders.controller.ts b/src/website/website-sliders.controller.ts index b7365d8..c80002f 100644 --- a/src/website/website-sliders.controller.ts +++ b/src/website/website-sliders.controller.ts @@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator'; import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; import { CreateWebsiteSliderDto, ListWebsiteSlidersDto, @@ -23,11 +24,17 @@ import { WebsiteSlidersService } from './website-sliders.service'; @Controller('tenants/:host/website/sliders') export class PublicWebsiteSlidersController { - constructor(private readonly service: WebsiteSlidersService) {} + constructor( + private readonly service: WebsiteSlidersService, + private readonly analytics: WebsiteAnalyticsService, + ) {} @Get() - list(@Param('host') host: string) { - return this.service.listPublic(host); + async list(@Param('host') host: string) { + const result = await this.service.listPublic(host); + // Homepage signal: Meshkee storefronts load sliders on the home page. + this.analytics.trackPublicPage(host, 'home'); + return result; } } diff --git a/src/website/website.module.ts b/src/website/website.module.ts index 868ec18..7fe61cf 100644 --- a/src/website/website.module.ts +++ b/src/website/website.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { MediaModule } from '../media/media.module'; import { TenantModule } from '../tenant/tenant.module'; +import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module'; import { PublicWebsiteBrandGroupsController, WebsiteBrandGroupsController, @@ -30,7 +31,7 @@ import { import { WebsiteStaticImagesService } from './website-static-images.service'; @Module({ - imports: [AuthModule, TenantModule, MediaModule], + imports: [AuthModule, TenantModule, MediaModule, WebsiteAnalyticsModule], controllers: [ PublicWebsiteCategoryGroupsController, WebsiteCategoryGroupsController,