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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6cd0b009ef
commit
92601ae729
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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.
|
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)
|
### Analytics (dashboard charts)
|
||||||
Record storefront views so the business home charts can show traffic:
|
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.
|
||||||
- `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"`).
|
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
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
|
|||||||
@@ -434,7 +434,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["Analytics"],
|
"tags": ["Analytics"],
|
||||||
"summary": "Record a storefront page view",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -450,15 +450,32 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"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": {
|
"entityId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Required when kind is product, portfolio, or blog."
|
"description": "Required for *_detail kinds (product, portfolio, blog, video, store item)."
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional page path for website visits (e.g. `/`, `/about`)."
|
"description": "Optional page path (e.g. `/`, `/blog/my-post`)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-12
@@ -23,6 +23,8 @@ model User {
|
|||||||
firstNameEn String? @map("first_name_en") @db.VarChar(100)
|
firstNameEn String? @map("first_name_en") @db.VarChar(100)
|
||||||
lastNameEn String? @map("last_name_en") @db.VarChar(100)
|
lastNameEn String? @map("last_name_en") @db.VarChar(100)
|
||||||
oldId BigInt? @map("old_id")
|
oldId BigInt? @map("old_id")
|
||||||
|
titleId BigInt? @map("title_id")
|
||||||
|
title UserTitle? @relation(fields: [titleId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
authoredBlogs blogs[] @relation("BlogAuthor")
|
authoredBlogs blogs[] @relation("BlogAuthor")
|
||||||
businessCustomers BusinessCustomer[]
|
businessCustomers BusinessCustomer[]
|
||||||
@@ -53,6 +55,20 @@ model User {
|
|||||||
@@map("users")
|
@@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 {
|
model Business {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
@@ -72,6 +88,7 @@ model Business {
|
|||||||
logoDarkMediaId BigInt? @map("logo_dark_media_id")
|
logoDarkMediaId BigInt? @map("logo_dark_media_id")
|
||||||
faviconMediaId BigInt? @map("favicon_media_id")
|
faviconMediaId BigInt? @map("favicon_media_id")
|
||||||
oldBusinessId BigInt? @map("old_business_id")
|
oldBusinessId BigInt? @map("old_business_id")
|
||||||
|
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brands Brand[]
|
brands Brand[]
|
||||||
@@ -116,7 +133,8 @@ model Business {
|
|||||||
website_category_groups website_category_groups[]
|
website_category_groups website_category_groups[]
|
||||||
website_image_slots website_image_slots[]
|
website_image_slots website_image_slots[]
|
||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
websiteContentViews WebsiteContentView[]
|
websitePageViews WebsitePageView[]
|
||||||
|
websitePageViewCounters WebsitePageViewCounter[]
|
||||||
|
|
||||||
@@map("businesses")
|
@@map("businesses")
|
||||||
}
|
}
|
||||||
@@ -161,6 +179,7 @@ model Domain {
|
|||||||
isVerified Boolean @default(false) @map("is_verified")
|
isVerified Boolean @default(false) @map("is_verified")
|
||||||
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
||||||
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
expiresAt DateTime? @map("expires_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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
brandId BigInt? @map("brand_id")
|
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[]
|
expertReviews ExpertReview[]
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
@@ -1638,24 +1660,47 @@ enum UserProductCondition {
|
|||||||
@@map("user_product_condition")
|
@@map("user_product_condition")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WebsiteViewKind {
|
enum WebsitePageKind {
|
||||||
website
|
home
|
||||||
product
|
portfolio_list
|
||||||
portfolio
|
portfolio_detail
|
||||||
blog
|
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())
|
id BigInt @id @default(autoincrement())
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
viewKind WebsiteViewKind @map("view_kind")
|
pageKind WebsitePageKind @map("page_kind")
|
||||||
entityId BigInt? @map("entity_id")
|
entityId BigInt @default(0) @map("entity_id")
|
||||||
pagePath String? @map("page_path")
|
pagePath String? @map("page_path")
|
||||||
viewedAt DateTime @default(now()) @map("viewed_at") @db.Timestamptz(6)
|
viewedAt DateTime @default(now()) @map("viewed_at") @db.Timestamptz(6)
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
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")
|
@@index([businessId, pageKind, viewedAt(sort: Desc)], map: "idx_website_page_views_business_kind_time")
|
||||||
@@map("website_content_views")
|
@@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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { RedisModule } from './redis/redis.module';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BusinessTeamModule } from './business-team/business-team.module';
|
import { BusinessTeamModule } from './business-team/business-team.module';
|
||||||
import { BusinessAdminModule } from './business-admin/business-admin.module';
|
import { BusinessAdminModule } from './business-admin/business-admin.module';
|
||||||
|
import { UserTitlesModule } from './user-titles/user-titles.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { RolesModule } from './roles/roles.module';
|
import { RolesModule } from './roles/roles.module';
|
||||||
import { TenantModule } from './tenant/tenant.module';
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
@@ -53,6 +54,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
BusinessTeamModule,
|
BusinessTeamModule,
|
||||||
BusinessAdminModule,
|
BusinessAdminModule,
|
||||||
|
UserTitlesModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
RolesModule,
|
RolesModule,
|
||||||
TenantModule,
|
TenantModule,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import { BlogsService } from './blogs.service';
|
import { BlogsService } from './blogs.service';
|
||||||
import {
|
import {
|
||||||
CreateBlogCommentDto,
|
CreateBlogCommentDto,
|
||||||
@@ -92,16 +93,23 @@ export class BlogsController {
|
|||||||
|
|
||||||
@Controller('tenants/:host/blogs')
|
@Controller('tenants/:host/blogs')
|
||||||
export class PublicBlogsController {
|
export class PublicBlogsController {
|
||||||
constructor(private readonly service: BlogsService) {}
|
constructor(
|
||||||
|
private readonly service: BlogsService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
async list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
||||||
return this.service.listPublic(host, query);
|
const result = await this.service.listPublic(host, query);
|
||||||
|
this.analytics.trackPublicPage(host, 'blog_list');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:blogId')
|
@Get('by-id/:blogId')
|
||||||
getById(@Param('host') host: string, @Param('blogId') blogId: string) {
|
async getById(@Param('host') host: string, @Param('blogId') blogId: string) {
|
||||||
return this.service.getPublicById(host, blogId);
|
const result = await this.service.getPublicById(host, blogId);
|
||||||
|
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':blogId/comments')
|
@Get(':blogId/comments')
|
||||||
@@ -119,7 +127,9 @@ export class PublicBlogsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||||
return this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
|
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
import { SitemapModule } from '../sitemap/sitemap.module';
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import { BlogsController, PublicBlogsController } from './blogs.controller';
|
import { BlogsController, PublicBlogsController } from './blogs.controller';
|
||||||
import { BlogsService } from './blogs.service';
|
import { BlogsService } from './blogs.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BusinessSettingsModule, SitemapModule, TenantModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
BusinessSettingsModule,
|
||||||
|
SitemapModule,
|
||||||
|
TenantModule,
|
||||||
|
WebsiteAnalyticsModule,
|
||||||
|
],
|
||||||
controllers: [BlogsController, PublicBlogsController],
|
controllers: [BlogsController, PublicBlogsController],
|
||||||
providers: [BlogsService],
|
providers: [BlogsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type BusinessRow = {
|
|||||||
about: string | null;
|
about: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
annualRenewalAt: Date | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
oldBusinessId: bigint | null;
|
oldBusinessId: bigint | null;
|
||||||
domainId: bigint | null;
|
domainId: bigint | null;
|
||||||
@@ -142,6 +143,7 @@ export class BusinessAdminService {
|
|||||||
b.about AS "about",
|
b.about AS "about",
|
||||||
b.slug AS "slug",
|
b.slug AS "slug",
|
||||||
b.created_at AS "createdAt",
|
b.created_at AS "createdAt",
|
||||||
|
b.annual_renewal_at AS "annualRenewalAt",
|
||||||
b.is_active AS "isActive",
|
b.is_active AS "isActive",
|
||||||
b.old_business_id AS "oldBusinessId",
|
b.old_business_id AS "oldBusinessId",
|
||||||
dom.id AS "domainId",
|
dom.id AS "domainId",
|
||||||
@@ -372,6 +374,9 @@ export class BusinessAdminService {
|
|||||||
nameFa: dto.nameFa.trim(),
|
nameFa: dto.nameFa.trim(),
|
||||||
about: dto.about?.trim() ?? null,
|
about: dto.about?.trim() ?? null,
|
||||||
slug,
|
slug,
|
||||||
|
annualRenewalAt: dto.annualRenewalAt
|
||||||
|
? new Date(dto.annualRenewalAt)
|
||||||
|
: null,
|
||||||
oldBusinessId:
|
oldBusinessId:
|
||||||
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
||||||
settings: toPrismaJson({
|
settings: toPrismaJson({
|
||||||
@@ -455,6 +460,14 @@ export class BusinessAdminService {
|
|||||||
dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId),
|
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,
|
tx: Prisma.TransactionClient,
|
||||||
dto: Pick<
|
dto: Pick<
|
||||||
CreateBusinessDto,
|
CreateBusinessDto,
|
||||||
|
| 'ownerTitleId'
|
||||||
| 'ownerFirstName'
|
| 'ownerFirstName'
|
||||||
| 'ownerLastName'
|
| 'ownerLastName'
|
||||||
| 'ownerFirstNameEn'
|
| 'ownerFirstNameEn'
|
||||||
@@ -1087,26 +1101,45 @@ export class BusinessAdminService {
|
|||||||
where: { cellNumber: dto.ownerCellNumber },
|
where: { cellNumber: dto.ownerCellNumber },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Users can own multiple businesses. If the cell number already exists,
|
let titleId: bigint | null | undefined = undefined;
|
||||||
// leave that user unchanged and only attach the new business as owner.
|
if (dto.ownerTitleId !== undefined) {
|
||||||
if (existing) {
|
const title = await tx.userTitle.findFirst({
|
||||||
if (!existing.isActive) {
|
where: { id: BigInt(dto.ownerTitleId), isActive: true },
|
||||||
throw new BadRequestException('Owner user is inactive');
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!title) {
|
||||||
|
throw new BadRequestException('Owner title not found');
|
||||||
}
|
}
|
||||||
return existing;
|
titleId = title.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
|
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
|
||||||
|
const nameData = {
|
||||||
return tx.user.create({
|
...(titleId !== undefined ? { titleId } : {}),
|
||||||
data: {
|
|
||||||
cellNumber: dto.ownerCellNumber,
|
|
||||||
passwordHash,
|
|
||||||
firstName: dto.ownerFirstName.trim(),
|
firstName: dto.ownerFirstName.trim(),
|
||||||
lastName: dto.ownerLastName.trim(),
|
lastName: dto.ownerLastName.trim(),
|
||||||
firstNameEn: dto.ownerFirstNameEn.trim(),
|
firstNameEn: dto.ownerFirstNameEn.trim(),
|
||||||
lastNameEn: dto.ownerLastNameEn.trim(),
|
lastNameEn: dto.ownerLastNameEn.trim(),
|
||||||
|
passwordHash,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Users can own multiple businesses. If the cell number already exists,
|
||||||
|
// refresh name + password from the create form, then attach as owner.
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.isActive) {
|
||||||
|
throw new BadRequestException('Owner user is inactive');
|
||||||
|
}
|
||||||
|
return tx.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: nameData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.user.create({
|
||||||
|
data: {
|
||||||
|
cellNumber: dto.ownerCellNumber,
|
||||||
cellVerifiedAt: new Date(),
|
cellVerifiedAt: new Date(),
|
||||||
|
...nameData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1176,6 +1209,7 @@ export class BusinessAdminService {
|
|||||||
slug: string;
|
slug: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
oldBusinessId: bigint | null;
|
oldBusinessId: bigint | null;
|
||||||
|
annualRenewalAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
categoryAssignments: {
|
categoryAssignments: {
|
||||||
@@ -1217,6 +1251,7 @@ export class BusinessAdminService {
|
|||||||
isActive: business.isActive,
|
isActive: business.isActive,
|
||||||
oldBusinessId:
|
oldBusinessId:
|
||||||
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
|
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
|
||||||
|
annualRenewalAt: business.annualRenewalAt,
|
||||||
createdAt: business.createdAt,
|
createdAt: business.createdAt,
|
||||||
updatedAt: business.updatedAt,
|
updatedAt: business.updatedAt,
|
||||||
categories: business.categoryAssignments.map((a) => ({
|
categories: business.categoryAssignments.map((a) => ({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
@@ -36,6 +37,17 @@ export class CreateBusinessDto {
|
|||||||
@IsInt({ each: true })
|
@IsInt({ each: true })
|
||||||
categoryIds!: number[];
|
categoryIds!: number[];
|
||||||
|
|
||||||
|
/** Annual service renewal date (ISO). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
annualRenewalAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
ownerTitleId?: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
ownerFirstName!: string;
|
ownerFirstName!: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
@@ -53,4 +54,9 @@ export class UpdateBusinessDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@IsPositive()
|
@IsPositive()
|
||||||
oldBusinessId?: number | null;
|
oldBusinessId?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null)
|
||||||
|
@IsDateString()
|
||||||
|
annualRenewalAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ export class CartService {
|
|||||||
data: { quantity: newQuantity },
|
data: { quantity: newQuantity },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.bumpProductCartAdd(variant.storeItem.productId, quantity);
|
||||||
|
|
||||||
const refreshed = await this.loadCart(cart.id);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Cart item quantity updated',
|
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);
|
const refreshed = await this.loadCart(cart.id);
|
||||||
return {
|
return {
|
||||||
message: 'Item added to cart',
|
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(
|
private async loadPurchasableVariant(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
storeItemVariantId: bigint,
|
storeItemVariantId: bigint,
|
||||||
|
|||||||
+67
-21
@@ -2,8 +2,13 @@ import * as tls from 'tls';
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 4000;
|
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 {
|
function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean {
|
||||||
// detailed=true so SAN is present for checkServerIdentity
|
|
||||||
const cert = socket.getPeerCertificate(true);
|
const cert = socket.getPeerCertificate(true);
|
||||||
if (!cert || Object.keys(cert).length === 0) {
|
if (!cert || Object.keys(cert).length === 0) {
|
||||||
return false;
|
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<TlsProbeResult | null | undefined>
|
||||||
|
): 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
|
* 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).
|
* 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,
|
hostRaw: string,
|
||||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
): Promise<boolean> {
|
): Promise<TlsProbeResult> {
|
||||||
const host = hostRaw.trim().toLowerCase();
|
const host = hostRaw.trim().toLowerCase();
|
||||||
if (!host || host.includes(':') || host.endsWith('.local')) {
|
if (!host || host.includes(':') || host.endsWith('.local')) {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve({ ok: false, expiresAt: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (ok: boolean) => {
|
const finish = (result: TlsProbeResult) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
resolve(ok);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
let socket: tls.TLSSocket;
|
let socket: tls.TLSSocket;
|
||||||
@@ -49,35 +71,44 @@ export function probeTlsHost(
|
|||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// authorized=true alone is not enough: a default vhost can present
|
const ok =
|
||||||
// another site's valid cert (ERR_CERT_COMMON_NAME_INVALID in browsers).
|
socket.authorized === true && peerMatchesHost(host, socket);
|
||||||
const ok = socket.authorized === true && peerMatchesHost(host, socket);
|
const expiresAt = ok ? readCertExpiry(socket) : null;
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
finish(ok);
|
finish({ ok, expiresAt });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
finish(false);
|
finish({ ok: false, expiresAt: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('error', () => {
|
socket.on('error', () => {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
finish(false);
|
finish({ ok: false, expiresAt: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('timeout', () => {
|
socket.on('timeout', () => {
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
finish(false);
|
finish({ ok: false, expiresAt: null });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Probe many hosts in parallel (chunked); returns Map host → ssl ok. */
|
/** Boolean wrapper for callers that only need ok/fail. */
|
||||||
export async function probeTlsHosts(
|
export async function probeTlsHost(
|
||||||
|
hostRaw: string,
|
||||||
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<string | null | undefined>,
|
hosts: Array<string | null | undefined>,
|
||||||
chunkSize = 10,
|
chunkSize = 10,
|
||||||
): Promise<Map<string, boolean>> {
|
): Promise<Map<string, TlsProbeResult>> {
|
||||||
const unique = [
|
const unique = [
|
||||||
...new Set(
|
...new Set(
|
||||||
hosts
|
hosts
|
||||||
@@ -86,16 +117,31 @@ export async function probeTlsHosts(
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
const results = new Map<string, boolean>();
|
const results = new Map<string, TlsProbeResult>();
|
||||||
for (let i = 0; i < unique.length; i += chunkSize) {
|
for (let i = 0; i < unique.length; i += chunkSize) {
|
||||||
const chunk = unique.slice(i, i + chunkSize);
|
const chunk = unique.slice(i, i + chunkSize);
|
||||||
const probed = await Promise.all(
|
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) {
|
for (const [host, result] of probed) {
|
||||||
results.set(host, ok);
|
results.set(host, result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Probe many hosts; returns Map host → ssl ok. */
|
||||||
|
export async function probeTlsHosts(
|
||||||
|
hosts: Array<string | null | undefined>,
|
||||||
|
chunkSize = 10,
|
||||||
|
): Promise<Map<string, boolean>> {
|
||||||
|
const detailed = await probeTlsHostsDetailed(hosts, chunkSize);
|
||||||
|
const results = new Map<string, boolean>();
|
||||||
|
for (const [host, result] of detailed) {
|
||||||
|
results.set(host, result.ok);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
|
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||||
import { DisableDomainDto } from './dto/disable-domain.dto';
|
import { DisableDomainDto } from './dto/disable-domain.dto';
|
||||||
@@ -26,6 +30,7 @@ type DomainRow = {
|
|||||||
businessId: bigint;
|
businessId: bigint;
|
||||||
businessName: string;
|
businessName: string;
|
||||||
sslEnabled: boolean;
|
sslEnabled: boolean;
|
||||||
|
sslExpiresAt: Date | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -71,6 +76,7 @@ export class DomainAdminService {
|
|||||||
d.business_id AS "businessId",
|
d.business_id AS "businessId",
|
||||||
b.name AS "businessName",
|
b.name AS "businessName",
|
||||||
d.ssl_enabled AS "sslEnabled",
|
d.ssl_enabled AS "sslEnabled",
|
||||||
|
d.ssl_expires_at AS "sslExpiresAt",
|
||||||
d.is_active AS "isActive",
|
d.is_active AS "isActive",
|
||||||
d.expires_at AS "expiresAt",
|
d.expires_at AS "expiresAt",
|
||||||
d.created_at AS "createdAt",
|
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) {
|
async syncSsl(actor: AuthUser) {
|
||||||
@@ -185,26 +203,28 @@ export class DomainAdminService {
|
|||||||
const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = [];
|
const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = [];
|
||||||
|
|
||||||
for (const domain of candidates) {
|
for (const domain of candidates) {
|
||||||
const apexOk = await probeTlsHost(domain.host);
|
const apexProbe = await probeTlsHostDetailed(domain.host);
|
||||||
const wwwHost = domain.host.startsWith('www.')
|
const wwwHost = domain.host.startsWith('www.')
|
||||||
? domain.host
|
? domain.host
|
||||||
: `www.${domain.host}`;
|
: `www.${domain.host}`;
|
||||||
const wwwOk = await probeTlsHost(wwwHost);
|
const wwwProbe = await probeTlsHostDetailed(wwwHost);
|
||||||
const ok = apexOk && wwwOk;
|
const ok = apexProbe.ok && wwwProbe.ok;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
if (!domain.sslEnabled) {
|
const sslExpiresAt = earliestTlsExpiry(apexProbe, wwwProbe);
|
||||||
await this.prisma.domain.update({
|
await this.prisma.domain.update({
|
||||||
where: { id: domain.id },
|
where: { id: domain.id },
|
||||||
data: { sslEnabled: true },
|
data: {
|
||||||
|
sslEnabled: true,
|
||||||
|
...(sslExpiresAt ? { sslExpiresAt } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (domain.sslEnabled) {
|
if (domain.sslEnabled) {
|
||||||
await this.prisma.domain.update({
|
await this.prisma.domain.update({
|
||||||
where: { id: domain.id },
|
where: { id: domain.id },
|
||||||
data: { sslEnabled: false },
|
data: { sslEnabled: false, sslExpiresAt: null },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
targets.push(domain);
|
targets.push(domain);
|
||||||
@@ -228,14 +248,21 @@ export class DomainAdminService {
|
|||||||
host: domain.host,
|
host: domain.host,
|
||||||
slug: domain.deploySlug,
|
slug: domain.deploySlug,
|
||||||
});
|
});
|
||||||
const ok = await probeTlsHost(domain.host);
|
const apexProbe = await probeTlsHostDetailed(domain.host);
|
||||||
const wwwHost = domain.host.startsWith('www.')
|
const wwwHost = domain.host.startsWith('www.')
|
||||||
? domain.host
|
? domain.host
|
||||||
: `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({
|
await this.prisma.domain.update({
|
||||||
where: { id: domain.id },
|
where: { id: domain.id },
|
||||||
data: { sslEnabled: wwwOk },
|
data: {
|
||||||
|
sslEnabled: wwwOk,
|
||||||
|
sslExpiresAt,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (wwwOk) {
|
if (wwwOk) {
|
||||||
issued.push(domain.host);
|
issued.push(domain.host);
|
||||||
@@ -297,10 +324,12 @@ export class DomainAdminService {
|
|||||||
customer: { host: customerHost, status: 'ok' },
|
customer: { host: customerHost, status: 'ok' },
|
||||||
};
|
};
|
||||||
|
|
||||||
let apexOk = await probeTlsHost(apex);
|
let apexProbe = await probeTlsHostDetailed(apex);
|
||||||
let wwwOk = await probeTlsHost(wwwHost);
|
let wwwProbe = await probeTlsHostDetailed(wwwHost);
|
||||||
let businessOk = await probeTlsHost(businessHost);
|
let businessOk = await probeTlsHost(businessHost);
|
||||||
let customerOk = await probeTlsHost(customerHost);
|
let customerOk = await probeTlsHost(customerHost);
|
||||||
|
let apexOk = apexProbe.ok;
|
||||||
|
let wwwOk = wwwProbe.ok;
|
||||||
|
|
||||||
const storefrontNeedsIssue = !apexOk || !wwwOk;
|
const storefrontNeedsIssue = !apexOk || !wwwOk;
|
||||||
|
|
||||||
@@ -325,8 +354,10 @@ export class DomainAdminService {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await this.websiteDeployAgent.issueSsl({ host: apex, slug });
|
await this.websiteDeployAgent.issueSsl({ host: apex, slug });
|
||||||
apexOk = await probeTlsHost(apex);
|
apexProbe = await probeTlsHostDetailed(apex);
|
||||||
wwwOk = await probeTlsHost(wwwHost);
|
wwwProbe = await probeTlsHostDetailed(wwwHost);
|
||||||
|
apexOk = apexProbe.ok;
|
||||||
|
wwwOk = wwwProbe.ok;
|
||||||
hosts.apex = apexOk
|
hosts.apex = apexOk
|
||||||
? {
|
? {
|
||||||
host: apex,
|
host: apex,
|
||||||
@@ -416,9 +447,22 @@ export class DomainAdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storefrontOk = apexOk && wwwOk;
|
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({
|
const updated = await this.prisma.domain.update({
|
||||||
where: { id: domainId },
|
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];
|
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),
|
status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
|
||||||
host: apex,
|
host: apex,
|
||||||
sslEnabled: updated.sslEnabled,
|
sslEnabled: updated.sslEnabled,
|
||||||
|
sslExpiresAt: updated.sslExpiresAt?.toISOString() ?? null,
|
||||||
hosts,
|
hosts,
|
||||||
issued: issued.map((p) => p.host),
|
issued: issued.map((p) => p.host),
|
||||||
failed: failed.map((p) => ({ host: p.host, error: p.detail || 'failed' })),
|
failed: failed.map((p) => ({ host: p.host, error: p.detail || 'failed' })),
|
||||||
@@ -579,7 +624,10 @@ export class DomainAdminService {
|
|||||||
|
|
||||||
return this.prisma.domain.update({
|
return this.prisma.domain.update({
|
||||||
where: { id: domainId },
|
where: { id: domainId },
|
||||||
data: { sslEnabled: dto.sslEnabled },
|
data: {
|
||||||
|
sslEnabled: dto.sslEnabled,
|
||||||
|
...(dto.sslEnabled ? {} : { sslExpiresAt: null }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { filterHostsWithDns } from '../common/dns-resolve';
|
import { filterHostsWithDns } from '../common/dns-resolve';
|
||||||
|
import {
|
||||||
|
earliestTlsExpiry,
|
||||||
|
probeTlsHostsDetailed,
|
||||||
|
} from '../common/tls-probe';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { probeTlsHosts } from '../common/tls-probe';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InternalSslService implements OnModuleInit {
|
export class InternalSslService {
|
||||||
private readonly logger = new Logger(InternalSslService.name);
|
private readonly logger = new Logger(InternalSslService.name);
|
||||||
private refreshRunning = false;
|
private refreshRunning = false;
|
||||||
|
|
||||||
@@ -15,17 +18,17 @@ export class InternalSslService implements OnModuleInit {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
private async listActiveDomains(): Promise<
|
||||||
// Warm ssl_enabled shortly after boot so lists are accurate without waiting for cron.
|
Array<{
|
||||||
setTimeout(() => {
|
id: bigint;
|
||||||
void this.refreshSslStatuses('startup');
|
host: string;
|
||||||
}, 5_000);
|
sslEnabled: boolean;
|
||||||
}
|
sslExpiresAt: Date | null;
|
||||||
|
}>
|
||||||
private async listActiveDomains(): Promise<Array<{ id: bigint; host: string; sslEnabled: boolean }>> {
|
> {
|
||||||
return this.prisma.domain.findMany({
|
return this.prisma.domain.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
select: { id: true, host: true, sslEnabled: true },
|
select: { id: true, host: true, sslEnabled: true, sslExpiresAt: true },
|
||||||
orderBy: { host: 'asc' },
|
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.
|
* Occasional safety probe (once a day). Issue SSL writes ssl_expires_at and
|
||||||
* Runs every 2 hours (and once shortly after API boot).
|
* 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() {
|
async refreshSslStatusesCron() {
|
||||||
await this.refreshSslStatuses('cron');
|
await this.refreshSslStatuses('cron');
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshSslStatuses(reason: 'cron' | 'startup' | 'manual' = 'manual') {
|
async refreshSslStatuses(reason: 'cron' | 'manual' = 'manual') {
|
||||||
if (this.refreshRunning) {
|
if (this.refreshRunning) {
|
||||||
this.logger.warn(`SSL status refresh skipped (${reason}): already running`);
|
this.logger.warn(`SSL status refresh skipped (${reason}): already running`);
|
||||||
return { updated: 0, checked: 0, skipped: true as const };
|
return { updated: 0, checked: 0, skipped: true as const };
|
||||||
@@ -99,30 +102,79 @@ export class InternalSslService implements OnModuleInit {
|
|||||||
this.refreshRunning = true;
|
this.refreshRunning = true;
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
|
const now = new Date();
|
||||||
const domains = await this.listActiveDomains();
|
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 hostsToProbe = domains.flatMap((d) => {
|
||||||
const apex = d.host.trim().toLowerCase();
|
const apex = d.host.trim().toLowerCase();
|
||||||
if (!apex) return [];
|
if (!apex) return [];
|
||||||
const www = apex.startsWith('www.') ? apex : `www.${apex}`;
|
const www = apex.startsWith('www.') ? apex : `www.${apex}`;
|
||||||
return [apex, www];
|
return [apex, www];
|
||||||
});
|
});
|
||||||
const sslByHost = await probeTlsHosts(hostsToProbe);
|
const sslByHost = await probeTlsHostsDetailed(hostsToProbe);
|
||||||
|
|
||||||
let updated = 0;
|
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
const hostKey = domain.host.trim().toLowerCase();
|
const hostKey = domain.host.trim().toLowerCase();
|
||||||
const wwwKey = hostKey.startsWith('www.') ? hostKey : `www.${hostKey}`;
|
const wwwKey = hostKey.startsWith('www.') ? hostKey : `www.${hostKey}`;
|
||||||
const sslEnabled =
|
const apexResult = sslByHost.get(hostKey) ?? {
|
||||||
(sslByHost.get(hostKey) ?? false) && (sslByHost.get(wwwKey) ?? false);
|
ok: false,
|
||||||
if (sslEnabled === domain.sslEnabled) continue;
|
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({
|
await this.prisma.domain.update({
|
||||||
where: { id: domain.id },
|
where: { id: domain.id },
|
||||||
data: { sslEnabled },
|
data: {
|
||||||
|
sslEnabled: nextEnabled,
|
||||||
|
sslExpiresAt: nextExpires,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
updated += 1;
|
updated += 1;
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`SSL status ${domain.host}: ${domain.sslEnabled} → ${sslEnabled}`,
|
`SSL status ${domain.host}: enabled ${domain.sslEnabled}→${nextEnabled}` +
|
||||||
|
(nextExpires ? ` expires ${nextExpires.toISOString()}` : ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -509,6 +509,11 @@ export class OrdersService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tx.product.update({
|
||||||
|
where: { id: item.productId },
|
||||||
|
data: { saleCount: { increment: item.quantity } },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return created as OrderWithItems;
|
return created as OrderWithItems;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import {
|
import {
|
||||||
CreatePortfolioCommentDto,
|
CreatePortfolioCommentDto,
|
||||||
CreatePortfolioDto,
|
CreatePortfolioDto,
|
||||||
@@ -92,19 +93,26 @@ export class PortfoliosController {
|
|||||||
|
|
||||||
@Controller('tenants/:host/portfolios')
|
@Controller('tenants/:host/portfolios')
|
||||||
export class PublicPortfoliosController {
|
export class PublicPortfoliosController {
|
||||||
constructor(private readonly service: PortfoliosService) {}
|
constructor(
|
||||||
|
private readonly service: PortfoliosService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
|
async list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
|
||||||
return this.service.listPublic(host, query);
|
const result = await this.service.listPublic(host, query);
|
||||||
|
this.analytics.trackPublicPage(host, 'portfolio_list');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:portfolioId')
|
@Get('by-id/:portfolioId')
|
||||||
getById(
|
async getById(
|
||||||
@Param('host') host: string,
|
@Param('host') host: string,
|
||||||
@Param('portfolioId') portfolioId: 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')
|
@Get(':portfolioId/comments')
|
||||||
@@ -125,7 +133,9 @@ export class PublicPortfoliosController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||||
return this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
|
this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { BusinessSettingsModule } from '../business-settings/business-settings.m
|
|||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
import { SitemapModule } from '../sitemap/sitemap.module';
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import {
|
import {
|
||||||
PortfoliosController,
|
PortfoliosController,
|
||||||
PublicPortfoliosController,
|
PublicPortfoliosController,
|
||||||
@@ -11,7 +12,14 @@ import {
|
|||||||
import { PortfoliosService } from './portfolios.service';
|
import { PortfoliosService } from './portfolios.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BusinessSettingsModule, MediaModule, SitemapModule, TenantModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
BusinessSettingsModule,
|
||||||
|
MediaModule,
|
||||||
|
SitemapModule,
|
||||||
|
TenantModule,
|
||||||
|
WebsiteAnalyticsModule,
|
||||||
|
],
|
||||||
controllers: [PortfoliosController, PublicPortfoliosController],
|
controllers: [PortfoliosController, PublicPortfoliosController],
|
||||||
providers: [PortfoliosService],
|
providers: [PortfoliosService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.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 { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
|
||||||
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||||
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
||||||
@@ -164,11 +165,14 @@ export class PublicProductsController {
|
|||||||
private readonly service: ProductsService,
|
private readonly service: ProductsService,
|
||||||
private readonly variationValuesService: ProductVariationValuesService,
|
private readonly variationValuesService: ProductVariationValuesService,
|
||||||
private readonly technicalInfoService: ProductTechnicalInfoService,
|
private readonly technicalInfoService: ProductTechnicalInfoService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
async list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
|
||||||
return this.service.listPublic(host, query);
|
const result = await this.service.listPublic(host, query);
|
||||||
|
this.analytics.trackPublicPage(host, 'product_list');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:productId/variations')
|
@Get('by-id/:productId/variations')
|
||||||
@@ -196,8 +200,11 @@ export class PublicProductsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-id/:productId')
|
@Get('by-id/:productId')
|
||||||
getById(@Param('host') host: string, @Param('productId') productId: string) {
|
async getById(@Param('host') host: string, @Param('productId') productId: string) {
|
||||||
return this.service.getPublicById(host, productId);
|
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')
|
@Get(':slug/variations')
|
||||||
@@ -225,7 +232,10 @@ export class PublicProductsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||||
return this.service.getPublicBySlug(host, slug);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CategoriesModule } from '../categories/categories.module';
|
|||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
import { SitemapModule } from '../sitemap/sitemap.module';
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import { ProductAiService } from './product-ai.service';
|
import { ProductAiService } from './product-ai.service';
|
||||||
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||||
import { ProductVariationValuesService } from './product-variation-values.service';
|
import { ProductVariationValuesService } from './product-variation-values.service';
|
||||||
@@ -12,7 +13,15 @@ import { ProductsController, PublicProductsController } from './products.control
|
|||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BrandsModule, CategoriesModule, MediaModule, SitemapModule, TenantModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
BrandsModule,
|
||||||
|
CategoriesModule,
|
||||||
|
MediaModule,
|
||||||
|
SitemapModule,
|
||||||
|
TenantModule,
|
||||||
|
WebsiteAnalyticsModule,
|
||||||
|
],
|
||||||
controllers: [ProductsController, PublicProductsController],
|
controllers: [ProductsController, PublicProductsController],
|
||||||
providers: [
|
providers: [
|
||||||
ProductsService,
|
ProductsService,
|
||||||
|
|||||||
@@ -602,11 +602,39 @@ export class ProductsService {
|
|||||||
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
|
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
|
||||||
commentCount,
|
commentCount,
|
||||||
variantCount: resolvedVariantCount,
|
variantCount: resolvedVariantCount,
|
||||||
|
visitCount: product.visitCount.toString(),
|
||||||
|
cartAddCount: product.cartAddCount.toString(),
|
||||||
|
saleCount: product.saleCount.toString(),
|
||||||
createdAt: product.createdAt,
|
createdAt: product.createdAt,
|
||||||
updatedAt: product.updatedAt,
|
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) {
|
private buildContent(nameFa?: string, descriptionHtml?: string) {
|
||||||
return {
|
return {
|
||||||
nameFa: nameFa?.trim() || null,
|
nameFa: nameFa?.trim() || null,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import {
|
import {
|
||||||
BatchCreateStoreItemsDto,
|
BatchCreateStoreItemsDto,
|
||||||
BatchUpdateStoreItemDiscountsDto,
|
BatchUpdateStoreItemDiscountsDto,
|
||||||
@@ -166,20 +167,41 @@ export class StoreItemsController {
|
|||||||
|
|
||||||
@Controller('tenants/:host/store-items')
|
@Controller('tenants/:host/store-items')
|
||||||
export class PublicStoreItemsController {
|
export class PublicStoreItemsController {
|
||||||
constructor(private readonly service: StoreItemsService) {}
|
constructor(
|
||||||
|
private readonly service: StoreItemsService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
|
async list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
|
||||||
return this.service.listPublic(host, query);
|
const result = await this.service.listPublic(host, query);
|
||||||
|
this.analytics.trackPublicPage(host, 'store_item_list');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-product/:productId')
|
@Get('by-product/:productId')
|
||||||
getByProduct(@Param('host') host: string, @Param('productId') productId: string) {
|
async getByProduct(
|
||||||
return this.service.getPublicByProduct(host, productId);
|
@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')
|
@Get(':variantId')
|
||||||
getVariant(@Param('host') host: string, @Param('variantId') variantId: string) {
|
async getVariant(
|
||||||
return this.service.getPublicVariant(host, variantId);
|
@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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import {
|
import {
|
||||||
PublicStoreSpecialsController,
|
PublicStoreSpecialsController,
|
||||||
StoreSpecialsController,
|
StoreSpecialsController,
|
||||||
@@ -10,7 +11,7 @@ import { StoreItemsController, PublicStoreItemsController } from './store-items.
|
|||||||
import { StoreItemsService } from './store-items.service';
|
import { StoreItemsService } from './store-items.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TenantModule],
|
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
StoreItemsController,
|
StoreItemsController,
|
||||||
PublicStoreItemsController,
|
PublicStoreItemsController,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import { VideosService } from './videos.service';
|
import { VideosService } from './videos.service';
|
||||||
import {
|
import {
|
||||||
CreateVideoCommentDto,
|
CreateVideoCommentDto,
|
||||||
@@ -92,11 +93,16 @@ export class VideosController {
|
|||||||
|
|
||||||
@Controller('tenants/:host/videos')
|
@Controller('tenants/:host/videos')
|
||||||
export class PublicVideosController {
|
export class PublicVideosController {
|
||||||
constructor(private readonly service: VideosService) {}
|
constructor(
|
||||||
|
private readonly service: VideosService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
async list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
|
||||||
return this.service.listPublic(host, query);
|
const result = await this.service.listPublic(host, query);
|
||||||
|
this.analytics.trackPublicPage(host, 'video_list');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':videoId/comments')
|
@Get(':videoId/comments')
|
||||||
@@ -114,7 +120,9 @@ export class PublicVideosController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||||
return this.service.getPublicBySlug(host, slug);
|
const result = await this.service.getPublicBySlug(host, slug);
|
||||||
|
this.analytics.trackPublicPage(host, 'video_detail', result.video.id);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import { PublicVideosController, VideosController } from './videos.controller';
|
import { PublicVideosController, VideosController } from './videos.controller';
|
||||||
import { VideosService } from './videos.service';
|
import { VideosService } from './videos.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BusinessSettingsModule, TenantModule],
|
imports: [AuthModule, BusinessSettingsModule, TenantModule, WebsiteAnalyticsModule],
|
||||||
controllers: [VideosController, PublicVideosController],
|
controllers: [VideosController, PublicVideosController],
|
||||||
providers: [VideosService],
|
providers: [VideosService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
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 {
|
export class RecordWebsiteViewDto {
|
||||||
@IsIn([...WEBSITE_VIEW_KINDS])
|
@IsIn([...RECORD_KINDS])
|
||||||
kind!: WebsiteViewKind;
|
kind!: WebsitePageKind | 'website' | 'product' | 'portfolio' | 'blog';
|
||||||
|
|
||||||
/** Product / portfolio / blog id when kind is not `website`. */
|
/** Required for *_detail kinds. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
|
|
||||||
/** Page path for storefront visits (e.g. `/`, `/about`, `/products/12/slug`). */
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
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 {
|
export class ViewActivityQueryDto {
|
||||||
@IsIn([...WEBSITE_VIEW_KINDS])
|
@IsIn([...ACTIVITY_KINDS])
|
||||||
kind!: WebsiteViewKind;
|
kind!: (typeof ACTIVITY_KINDS)[number];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -9,5 +9,6 @@ import { WebsiteAnalyticsService } from './website-analytics.service';
|
|||||||
imports: [AuthModule, TenantModule],
|
imports: [AuthModule, TenantModule],
|
||||||
controllers: [PublicWebsiteAnalyticsController, WebsiteAnalyticsController],
|
controllers: [PublicWebsiteAnalyticsController, WebsiteAnalyticsController],
|
||||||
providers: [WebsiteAnalyticsService],
|
providers: [WebsiteAnalyticsService],
|
||||||
|
exports: [WebsiteAnalyticsService],
|
||||||
})
|
})
|
||||||
export class WebsiteAnalyticsModule {}
|
export class WebsiteAnalyticsModule {}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { buildDailyActivitySeries, startOfLocalDay } from '../common/daily-activity';
|
import { buildDailyActivitySeries, startOfLocalDay } from '../common/daily-activity';
|
||||||
@@ -11,43 +13,90 @@ import { PermissionsService } from '../auth/permissions.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import type { RecordWebsiteViewDto } from './dto/record-view.dto';
|
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()
|
@Injectable()
|
||||||
export class WebsiteAnalyticsService {
|
export class WebsiteAnalyticsService {
|
||||||
|
private readonly logger = new Logger(WebsiteAnalyticsService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async recordPublicView(host: string, dto: RecordWebsiteViewDto) {
|
/**
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
* Fire-and-forget friendly: record event + bump lifetime counter.
|
||||||
const businessId = business.id;
|
* 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.
|
||||||
const entityId = await this.resolveEntityId(businessId, dto.kind, dto.entityId);
|
*/
|
||||||
|
trackPublicPage(
|
||||||
await this.prisma.websiteContentView.create({
|
host: string,
|
||||||
data: {
|
kind: WebsitePageKind,
|
||||||
businessId,
|
entityId?: string | bigint | null,
|
||||||
viewKind: dto.kind,
|
path?: string,
|
||||||
entityId,
|
): void {
|
||||||
pagePath: this.normalizePath(dto.path),
|
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 };
|
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(
|
async dailyViewActivity(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
kind: WebsiteViewKind,
|
kindRaw: string,
|
||||||
daysRaw: number | undefined,
|
daysRaw: number | undefined,
|
||||||
actor: AuthUser,
|
actor: AuthUser,
|
||||||
) {
|
) {
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
await this.assertPermission(businessId, actor.id, 'business.read');
|
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 days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
|
||||||
const from = startOfLocalDay(days - 1);
|
const from = startOfLocalDay(days - 1);
|
||||||
|
|
||||||
@@ -55,9 +104,9 @@ export class WebsiteAnalyticsService {
|
|||||||
Prisma.sql`
|
Prisma.sql`
|
||||||
SELECT viewed_at::date AS day,
|
SELECT viewed_at::date AS day,
|
||||||
COUNT(*)::int AS count
|
COUNT(*)::int AS count
|
||||||
FROM website_content_views
|
FROM website_page_views
|
||||||
WHERE business_id = ${businessId}
|
WHERE business_id = ${businessId}
|
||||||
AND view_kind = ${kind}::website_view_kind
|
AND page_kind = ${kind}::website_page_kind
|
||||||
AND viewed_at >= ${from}
|
AND viewed_at >= ${from}
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
ORDER BY 1
|
ORDER BY 1
|
||||||
@@ -68,33 +117,112 @@ export class WebsiteAnalyticsService {
|
|||||||
return {
|
return {
|
||||||
days,
|
days,
|
||||||
primary,
|
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 {
|
private normalizePath(path: string | undefined): string | null {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
const trimmed = path.trim();
|
const trimmed = path.trim();
|
||||||
if (!trimmed) return null;
|
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(
|
private async resolveEntityId(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
kind: WebsiteViewKind,
|
kind: WebsitePageKind,
|
||||||
entityIdRaw: string | undefined,
|
entityIdRaw: string | bigint | null | undefined,
|
||||||
): Promise<bigint | null> {
|
): Promise<bigint> {
|
||||||
if (kind === 'website') {
|
if (!pageKindRequiresEntity(kind)) {
|
||||||
return null;
|
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`);
|
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({
|
const product = await this.prisma.product.findFirst({
|
||||||
where: { id: entityId, businessId },
|
where: { id: entityId, businessId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -103,7 +231,7 @@ export class WebsiteAnalyticsService {
|
|||||||
return entityId;
|
return entityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kind === 'portfolio') {
|
if (kind === 'portfolio_detail') {
|
||||||
const portfolio = await this.prisma.portfolios.findFirst({
|
const portfolio = await this.prisma.portfolios.findFirst({
|
||||||
where: { id: entityId, business_id: businessId },
|
where: { id: entityId, business_id: businessId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -112,6 +240,7 @@ export class WebsiteAnalyticsService {
|
|||||||
return entityId;
|
return entityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === 'blog_detail') {
|
||||||
const blog = await this.prisma.blogs.findFirst({
|
const blog = await this.prisma.blogs.findFirst({
|
||||||
where: { id: entityId, business_id: businessId },
|
where: { id: entityId, business_id: businessId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -120,6 +249,37 @@ export class WebsiteAnalyticsService {
|
|||||||
return entityId;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
private async assertPermission(
|
private async assertPermission(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
|
|||||||
@@ -1,25 +1,59 @@
|
|||||||
export const WEBSITE_VIEW_KINDS = [
|
export const WEBSITE_PAGE_KINDS = [
|
||||||
'website',
|
'home',
|
||||||
'product',
|
'portfolio_list',
|
||||||
'portfolio',
|
'portfolio_detail',
|
||||||
'blog',
|
'product_list',
|
||||||
|
'product_detail',
|
||||||
|
'store_item_list',
|
||||||
|
'store_item_detail',
|
||||||
|
'blog_list',
|
||||||
|
'blog_detail',
|
||||||
|
'video_list',
|
||||||
|
'video_detail',
|
||||||
] as const;
|
] 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<WebsitePageKind>([
|
||||||
|
'portfolio_detail',
|
||||||
|
'product_detail',
|
||||||
|
'store_item_detail',
|
||||||
|
'blog_detail',
|
||||||
|
'video_detail',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Legacy chart / POST aliases → canonical page kinds. */
|
||||||
|
const KIND_ALIASES: Record<string, WebsitePageKind> = {
|
||||||
|
website: 'home',
|
||||||
|
product: 'product_detail',
|
||||||
|
portfolio: 'portfolio_detail',
|
||||||
|
blog: 'blog_detail',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isWebsitePageKind(value: unknown): value is WebsitePageKind {
|
||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
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 function normalizeWebsitePageKind(value: string): WebsitePageKind | null {
|
||||||
export const HOME_CHART_VIEW_KIND: Partial<
|
if (isWebsitePageKind(value)) return value;
|
||||||
Record<string, WebsiteViewKind>
|
const aliased = KIND_ALIASES[value];
|
||||||
> = {
|
return aliased ?? null;
|
||||||
website_views_30d: 'website',
|
}
|
||||||
product_views_30d: 'product',
|
|
||||||
portfolio_views_30d: 'portfolio',
|
export function pageKindRequiresEntity(kind: WebsitePageKind): boolean {
|
||||||
blog_views_30d: 'blog',
|
return DETAIL_KINDS.has(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps home dashboard chart ids to analytics page kinds. */
|
||||||
|
export const HOME_CHART_VIEW_KIND: Partial<Record<string, WebsitePageKind>> = {
|
||||||
|
website_views_30d: 'home',
|
||||||
|
product_views_30d: 'product_detail',
|
||||||
|
portfolio_views_30d: 'portfolio_detail',
|
||||||
|
blog_views_30d: 'blog_detail',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.
|
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)
|
### Analytics (dashboard charts)
|
||||||
Record storefront views so the business home charts can show traffic:
|
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.
|
||||||
- `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"`).
|
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
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
|
|||||||
@@ -434,7 +434,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["Analytics"],
|
"tags": ["Analytics"],
|
||||||
"summary": "Record a storefront page view",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"$ref": "#/components/parameters/domain"
|
"$ref": "#/components/parameters/domain"
|
||||||
@@ -450,15 +450,32 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"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": {
|
"entityId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Required when kind is product, portfolio, or blog."
|
"description": "Required for *_detail kinds (product, portfolio, blog, video, store item)."
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional page path for website visits (e.g. `/`, `/about`)."
|
"description": "Optional page path (e.g. `/`, `/blog/my-post`)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import {
|
import {
|
||||||
CreateWebsiteSliderDto,
|
CreateWebsiteSliderDto,
|
||||||
ListWebsiteSlidersDto,
|
ListWebsiteSlidersDto,
|
||||||
@@ -23,11 +24,17 @@ import { WebsiteSlidersService } from './website-sliders.service';
|
|||||||
|
|
||||||
@Controller('tenants/:host/website/sliders')
|
@Controller('tenants/:host/website/sliders')
|
||||||
export class PublicWebsiteSlidersController {
|
export class PublicWebsiteSlidersController {
|
||||||
constructor(private readonly service: WebsiteSlidersService) {}
|
constructor(
|
||||||
|
private readonly service: WebsiteSlidersService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Param('host') host: string) {
|
async list(@Param('host') host: string) {
|
||||||
return this.service.listPublic(host);
|
const result = await this.service.listPublic(host);
|
||||||
|
// Homepage signal: Meshkee storefronts load sliders on the home page.
|
||||||
|
this.analytics.trackPublicPage(host, 'home');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import {
|
import {
|
||||||
PublicWebsiteBrandGroupsController,
|
PublicWebsiteBrandGroupsController,
|
||||||
WebsiteBrandGroupsController,
|
WebsiteBrandGroupsController,
|
||||||
@@ -30,7 +31,7 @@ import {
|
|||||||
import { WebsiteStaticImagesService } from './website-static-images.service';
|
import { WebsiteStaticImagesService } from './website-static-images.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TenantModule, MediaModule],
|
imports: [AuthModule, TenantModule, MediaModule, WebsiteAnalyticsModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
PublicWebsiteCategoryGroupsController,
|
PublicWebsiteCategoryGroupsController,
|
||||||
WebsiteCategoryGroupsController,
|
WebsiteCategoryGroupsController,
|
||||||
|
|||||||
Reference in New Issue
Block a user