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:
Alireza Hassani
2026-08-26 10:13:06 +03:30
co-authored by Cursor
parent 6cd0b009ef
commit 92601ae729
39 changed files with 1013 additions and 198 deletions
@@ -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;
+6 -3
View File
@@ -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:
+21 -4
View File
@@ -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
View File
@@ -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")
} }
+2
View File
@@ -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,
+17 -7
View File
@@ -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;
} }
} }
+8 -1
View File
@@ -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],
}) })
+46 -11
View File
@@ -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;
} }
+12
View File
@@ -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
View File
@@ -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;
}
+66 -18
View File
@@ -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 }),
},
}); });
} }
+75 -23
View File
@@ -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()}` : ''),
); );
} }
+5
View File
@@ -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;
+17 -7
View File
@@ -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;
} }
} }
+9 -1
View File
@@ -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],
}) })
+16 -6
View File
@@ -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;
} }
} }
+10 -1
View File
@@ -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,
+28
View File
@@ -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,
+29 -7
View File
@@ -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;
} }
} }
+2 -1
View File
@@ -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,
+14
View File
@@ -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();
}
}
+12
View File
@@ -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 {}
+29
View File
@@ -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,
})),
};
}
}
+13 -5
View File
@@ -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 -1
View File
@@ -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],
}) })
+16 -5
View File
@@ -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',
}; };
+6 -3
View File
@@ -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:
+21 -4
View File
@@ -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`)."
} }
} }
} }
+10 -3
View File
@@ -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 -1
View File
@@ -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,