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.
### Analytics (dashboard charts)
Record storefront views so the business home charts can show traffic:
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "website"|"product"|"portfolio"|"blog", "entityId"?: "...", "path"?: "/about" }`
- Call once per page view from the website (product detail, portfolio, blog post, or general pages with `kind: "website"`).
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
Optional explicit record (e.g. custom home without sliders):
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
### Static images
Named slots the business dashboard can replace. Fetch once per page:
+21 -4
View File
@@ -434,7 +434,7 @@
"post": {
"tags": ["Analytics"],
"summary": "Record a storefront page view",
"description": "Fire-and-forget view counter for business dashboard charts. Call once per page impression from the website (product detail, portfolio, blog post, or general site page). No auth required.",
"description": "Optional explicit view counter. Most page views are recorded automatically when public list/detail APIs are called (blogs, products, portfolios, videos, store-items, homepage sliders). Use this for custom pages. No auth required. Events retained ~6 months; lifetime counters kept forever.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -450,15 +450,32 @@
"properties": {
"kind": {
"type": "string",
"enum": ["website", "product", "portfolio", "blog"]
"enum": [
"home",
"portfolio_list",
"portfolio_detail",
"product_list",
"product_detail",
"store_item_list",
"store_item_detail",
"blog_list",
"blog_detail",
"video_list",
"video_detail",
"website",
"product",
"portfolio",
"blog"
],
"description": "Canonical kinds preferred. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
},
"entityId": {
"type": "string",
"description": "Required when kind is product, portfolio, or blog."
"description": "Required for *_detail kinds (product, portfolio, blog, video, store item)."
},
"path": {
"type": "string",
"description": "Optional page path for website visits (e.g. `/`, `/about`)."
"description": "Optional page path (e.g. `/`, `/blog/my-post`)."
}
}
}
+57 -12
View File
@@ -23,6 +23,8 @@ model User {
firstNameEn String? @map("first_name_en") @db.VarChar(100)
lastNameEn String? @map("last_name_en") @db.VarChar(100)
oldId BigInt? @map("old_id")
titleId BigInt? @map("title_id")
title UserTitle? @relation(fields: [titleId], references: [id], onDelete: SetNull, onUpdate: NoAction)
addresses Address[]
authoredBlogs blogs[] @relation("BlogAuthor")
businessCustomers BusinessCustomer[]
@@ -53,6 +55,20 @@ model User {
@@map("users")
}
model UserTitle {
id BigInt @id @default(autoincrement())
slug String @unique(map: "user_titles_slug_unique") @db.VarChar(32)
nameEn String @map("name_en") @db.VarChar(64)
nameFa String @map("name_fa") @db.VarChar(64)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users User[]
@@index([sortOrder, id], map: "idx_user_titles_sort")
@@map("user_titles")
}
model Business {
id BigInt @id @default(autoincrement())
name String @db.VarChar(255)
@@ -72,6 +88,7 @@ model Business {
logoDarkMediaId BigInt? @map("logo_dark_media_id")
faviconMediaId BigInt? @map("favicon_media_id")
oldBusinessId BigInt? @map("old_business_id")
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
addresses Address[]
blogs blogs[]
brands Brand[]
@@ -116,7 +133,8 @@ model Business {
website_category_groups website_category_groups[]
website_image_slots website_image_slots[]
website_sliders website_sliders[]
websiteContentViews WebsiteContentView[]
websitePageViews WebsitePageView[]
websitePageViewCounters WebsitePageViewCounter[]
@@map("businesses")
}
@@ -161,6 +179,7 @@ model Domain {
isVerified Boolean @default(false) @map("is_verified")
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
sslEnabled Boolean @default(false) @map("ssl_enabled")
sslExpiresAt DateTime? @map("ssl_expires_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
@@ -436,6 +455,9 @@ model Product {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
brandId BigInt? @map("brand_id")
visitCount BigInt @default(0) @map("visit_count")
cartAddCount BigInt @default(0) @map("cart_add_count")
saleCount BigInt @default(0) @map("sale_count")
expertReviews ExpertReview[]
favorites Favorite[]
orderItems OrderItem[]
@@ -1638,24 +1660,47 @@ enum UserProductCondition {
@@map("user_product_condition")
}
enum WebsiteViewKind {
website
product
portfolio
blog
enum WebsitePageKind {
home
portfolio_list
portfolio_detail
product_list
product_detail
store_item_list
store_item_detail
blog_list
blog_detail
video_list
video_detail
@@map("website_view_kind")
@@map("website_page_kind")
}
model WebsiteContentView {
/// Raw page-view events (retained ~6 months; purged by cron).
model WebsitePageView {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
viewKind WebsiteViewKind @map("view_kind")
entityId BigInt? @map("entity_id")
pageKind WebsitePageKind @map("page_kind")
entityId BigInt @default(0) @map("entity_id")
pagePath String? @map("page_path")
viewedAt DateTime @default(now()) @map("viewed_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([businessId, viewKind, viewedAt(sort: Desc)], map: "idx_website_content_views_business_kind_time")
@@map("website_content_views")
@@index([businessId, pageKind, viewedAt(sort: Desc)], map: "idx_website_page_views_business_kind_time")
@@map("website_page_views")
}
/// Lifetime totals per page kind (+ entity for detail pages). Never purged.
model WebsitePageViewCounter {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
pageKind WebsitePageKind @map("page_kind")
entityId BigInt @default(0) @map("entity_id")
totalCount BigInt @default(0) @map("total_count")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([businessId, pageKind, entityId], map: "uq_website_page_view_counters_biz_kind_entity")
@@index([businessId, pageKind], map: "idx_website_page_view_counters_business_kind")
@@map("website_page_view_counters")
}
+2
View File
@@ -6,6 +6,7 @@ import { RedisModule } from './redis/redis.module';
import { AuthModule } from './auth/auth.module';
import { BusinessTeamModule } from './business-team/business-team.module';
import { BusinessAdminModule } from './business-admin/business-admin.module';
import { UserTitlesModule } from './user-titles/user-titles.module';
import { UsersModule } from './users/users.module';
import { RolesModule } from './roles/roles.module';
import { TenantModule } from './tenant/tenant.module';
@@ -53,6 +54,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
AuthModule,
BusinessTeamModule,
BusinessAdminModule,
UserTitlesModule,
UsersModule,
RolesModule,
TenantModule,
+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 { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import { BlogsService } from './blogs.service';
import {
CreateBlogCommentDto,
@@ -92,16 +93,23 @@ export class BlogsController {
@Controller('tenants/:host/blogs')
export class PublicBlogsController {
constructor(private readonly service: BlogsService) {}
constructor(
private readonly service: BlogsService,
private readonly analytics: WebsiteAnalyticsService,
) {}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
return this.service.listPublic(host, query);
async list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
const result = await this.service.listPublic(host, query);
this.analytics.trackPublicPage(host, 'blog_list');
return result;
}
@Get('by-id/:blogId')
getById(@Param('host') host: string, @Param('blogId') blogId: string) {
return this.service.getPublicById(host, blogId);
async getById(@Param('host') host: string, @Param('blogId') blogId: string) {
const result = await this.service.getPublicById(host, blogId);
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
return result;
}
@Get(':blogId/comments')
@@ -119,7 +127,9 @@ export class PublicBlogsController {
}
@Get(':slug')
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicBySlug(host, slug);
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
const result = await this.service.getPublicBySlug(host, slug);
this.analytics.trackPublicPage(host, 'blog_detail', result.blog.id);
return result;
}
}
+8 -1
View File
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
import { BlogsController, PublicBlogsController } from './blogs.controller';
import { BlogsService } from './blogs.service';
@Module({
imports: [AuthModule, BusinessSettingsModule, SitemapModule, TenantModule],
imports: [
AuthModule,
BusinessSettingsModule,
SitemapModule,
TenantModule,
WebsiteAnalyticsModule,
],
controllers: [BlogsController, PublicBlogsController],
providers: [BlogsService],
})
+44 -9
View File
@@ -59,6 +59,7 @@ type BusinessRow = {
about: string | null;
slug: string;
createdAt: Date;
annualRenewalAt: Date | null;
isActive: boolean;
oldBusinessId: bigint | null;
domainId: bigint | null;
@@ -142,6 +143,7 @@ export class BusinessAdminService {
b.about AS "about",
b.slug AS "slug",
b.created_at AS "createdAt",
b.annual_renewal_at AS "annualRenewalAt",
b.is_active AS "isActive",
b.old_business_id AS "oldBusinessId",
dom.id AS "domainId",
@@ -372,6 +374,9 @@ export class BusinessAdminService {
nameFa: dto.nameFa.trim(),
about: dto.about?.trim() ?? null,
slug,
annualRenewalAt: dto.annualRenewalAt
? new Date(dto.annualRenewalAt)
: null,
oldBusinessId:
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
settings: toPrismaJson({
@@ -455,6 +460,14 @@ export class BusinessAdminService {
dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId),
}
: {}),
...(dto.annualRenewalAt !== undefined
? {
annualRenewalAt:
dto.annualRenewalAt === null
? null
: new Date(dto.annualRenewalAt),
}
: {}),
},
});
@@ -1075,6 +1088,7 @@ export class BusinessAdminService {
tx: Prisma.TransactionClient,
dto: Pick<
CreateBusinessDto,
| 'ownerTitleId'
| 'ownerFirstName'
| 'ownerLastName'
| 'ownerFirstNameEn'
@@ -1087,26 +1101,45 @@ export class BusinessAdminService {
where: { cellNumber: dto.ownerCellNumber },
});
let titleId: bigint | null | undefined = undefined;
if (dto.ownerTitleId !== undefined) {
const title = await tx.userTitle.findFirst({
where: { id: BigInt(dto.ownerTitleId), isActive: true },
select: { id: true },
});
if (!title) {
throw new BadRequestException('Owner title not found');
}
titleId = title.id;
}
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
const nameData = {
...(titleId !== undefined ? { titleId } : {}),
firstName: dto.ownerFirstName.trim(),
lastName: dto.ownerLastName.trim(),
firstNameEn: dto.ownerFirstNameEn.trim(),
lastNameEn: dto.ownerLastNameEn.trim(),
passwordHash,
};
// Users can own multiple businesses. If the cell number already exists,
// leave that user unchanged and only attach the new business as owner.
// refresh name + password from the create form, then attach as owner.
if (existing) {
if (!existing.isActive) {
throw new BadRequestException('Owner user is inactive');
}
return existing;
return tx.user.update({
where: { id: existing.id },
data: nameData,
});
}
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
return tx.user.create({
data: {
cellNumber: dto.ownerCellNumber,
passwordHash,
firstName: dto.ownerFirstName.trim(),
lastName: dto.ownerLastName.trim(),
firstNameEn: dto.ownerFirstNameEn.trim(),
lastNameEn: dto.ownerLastNameEn.trim(),
cellVerifiedAt: new Date(),
...nameData,
},
});
}
@@ -1176,6 +1209,7 @@ export class BusinessAdminService {
slug: string;
isActive: boolean;
oldBusinessId: bigint | null;
annualRenewalAt: Date | null;
createdAt: Date;
updatedAt: Date;
categoryAssignments: {
@@ -1217,6 +1251,7 @@ export class BusinessAdminService {
isActive: business.isActive,
oldBusinessId:
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
annualRenewalAt: business.annualRenewalAt,
createdAt: business.createdAt,
updatedAt: business.updatedAt,
categories: business.categoryAssignments.map((a) => ({
@@ -1,6 +1,7 @@
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsOptional,
IsPositive,
@@ -36,6 +37,17 @@ export class CreateBusinessDto {
@IsInt({ each: true })
categoryIds!: number[];
/** Annual service renewal date (ISO). */
@IsOptional()
@IsDateString()
annualRenewalAt?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@IsPositive()
ownerTitleId?: number;
@IsString()
@MinLength(2)
ownerFirstName!: string;
@@ -1,6 +1,7 @@
import {
ArrayMinSize,
IsArray,
IsDateString,
IsInt,
IsOptional,
IsPositive,
@@ -53,4 +54,9 @@ export class UpdateBusinessDto {
@IsInt()
@IsPositive()
oldBusinessId?: number | null;
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsDateString()
annualRenewalAt?: string | null;
}
+12
View File
@@ -92,6 +92,8 @@ export class CartService {
data: { quantity: newQuantity },
});
await this.bumpProductCartAdd(variant.storeItem.productId, quantity);
const refreshed = await this.loadCart(cart.id);
return {
message: 'Cart item quantity updated',
@@ -107,6 +109,8 @@ export class CartService {
},
});
await this.bumpProductCartAdd(variant.storeItem.productId, quantity);
const refreshed = await this.loadCart(cart.id);
return {
message: 'Item added to cart',
@@ -307,6 +311,14 @@ export class CartService {
});
}
private async bumpProductCartAdd(productId: bigint, quantity: number) {
if (!Number.isFinite(quantity) || quantity <= 0) return;
await this.prisma.product.update({
where: { id: productId },
data: { cartAddCount: { increment: quantity } },
});
}
private async loadPurchasableVariant(
businessId: bigint,
storeItemVariantId: bigint,
+67 -21
View File
@@ -2,8 +2,13 @@ import * as tls from 'tls';
const DEFAULT_TIMEOUT_MS = 4000;
export type TlsProbeResult = {
ok: boolean;
/** Certificate notAfter when the probe succeeded and a peer cert was present. */
expiresAt: Date | null;
};
function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean {
// detailed=true so SAN is present for checkServerIdentity
const cert = socket.getPeerCertificate(true);
if (!cert || Object.keys(cert).length === 0) {
return false;
@@ -16,26 +21,43 @@ function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean {
}
}
function readCertExpiry(socket: tls.TLSSocket): Date | null {
const cert = socket.getPeerCertificate(true);
if (!cert?.valid_to) return null;
const expiresAt = new Date(cert.valid_to);
return Number.isNaN(expiresAt.getTime()) ? null : expiresAt;
}
/** Earliest non-null expiry among successful probe results (or null). */
export function earliestTlsExpiry(
...results: Array<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
* whose identity matches the hostname (not merely a valid LE chain for another name).
* Returns false on timeout, DNS failure, or cert/hostname errors.
*/
export function probeTlsHost(
export function probeTlsHostDetailed(
hostRaw: string,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<boolean> {
): Promise<TlsProbeResult> {
const host = hostRaw.trim().toLowerCase();
if (!host || host.includes(':') || host.endsWith('.local')) {
return Promise.resolve(false);
return Promise.resolve({ ok: false, expiresAt: null });
}
return new Promise((resolve) => {
let settled = false;
const finish = (ok: boolean) => {
const finish = (result: TlsProbeResult) => {
if (settled) return;
settled = true;
resolve(ok);
resolve(result);
};
let socket: tls.TLSSocket;
@@ -49,35 +71,44 @@ export function probeTlsHost(
timeout: timeoutMs,
},
() => {
// authorized=true alone is not enough: a default vhost can present
// another site's valid cert (ERR_CERT_COMMON_NAME_INVALID in browsers).
const ok = socket.authorized === true && peerMatchesHost(host, socket);
const ok =
socket.authorized === true && peerMatchesHost(host, socket);
const expiresAt = ok ? readCertExpiry(socket) : null;
socket.destroy();
finish(ok);
finish({ ok, expiresAt });
},
);
} catch {
finish(false);
finish({ ok: false, expiresAt: null });
return;
}
socket.on('error', () => {
socket.destroy();
finish(false);
finish({ ok: false, expiresAt: null });
});
socket.on('timeout', () => {
socket.destroy();
finish(false);
finish({ ok: false, expiresAt: null });
});
});
}
/** Probe many hosts in parallel (chunked); returns Map host → ssl ok. */
export async function probeTlsHosts(
/** Boolean wrapper for callers that only need ok/fail. */
export async function probeTlsHost(
hostRaw: string,
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<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>,
chunkSize = 10,
): Promise<Map<string, boolean>> {
): Promise<Map<string, TlsProbeResult>> {
const unique = [
...new Set(
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) {
const chunk = unique.slice(i, i + chunkSize);
const probed = await Promise.all(
chunk.map(async (host) => [host, await probeTlsHost(host)] as const),
chunk.map(
async (host) => [host, await probeTlsHostDetailed(host)] as const,
),
);
for (const [host, ok] of probed) {
results.set(host, ok);
for (const [host, result] of probed) {
results.set(host, result);
}
}
return results;
}
/** Probe many hosts; returns Map host → ssl ok. */
export async function probeTlsHosts(
hosts: Array<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;
}
+69 -21
View File
@@ -12,7 +12,11 @@ import { Prisma } from '@prisma/client';
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { probeTlsHost } from '../common/tls-probe';
import {
earliestTlsExpiry,
probeTlsHost,
probeTlsHostDetailed,
} from '../common/tls-probe';
import { PrismaService } from '../prisma/prisma.service';
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
import { DisableDomainDto } from './dto/disable-domain.dto';
@@ -26,6 +30,7 @@ type DomainRow = {
businessId: bigint;
businessName: string;
sslEnabled: boolean;
sslExpiresAt: Date | null;
isActive: boolean;
expiresAt: Date | null;
createdAt: Date;
@@ -71,6 +76,7 @@ export class DomainAdminService {
d.business_id AS "businessId",
b.name AS "businessName",
d.ssl_enabled AS "sslEnabled",
d.ssl_expires_at AS "sslExpiresAt",
d.is_active AS "isActive",
d.expires_at AS "expiresAt",
d.created_at AS "createdAt",
@@ -90,7 +96,19 @@ export class DomainAdminService {
`),
]);
return { items: rows, total: totalRow[0]?.total ?? 0, page, pageSize };
const now = Date.now();
const items = rows.map((row) => {
const certStillValid =
!row.sslExpiresAt || row.sslExpiresAt.getTime() > now;
return {
...row,
// List badge: issued/probed OK and cert not past notAfter.
sslEnabled: row.sslEnabled && certStillValid,
sslExpiresAt: row.sslExpiresAt,
};
});
return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
}
async syncSsl(actor: AuthUser) {
@@ -185,26 +203,28 @@ export class DomainAdminService {
const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = [];
for (const domain of candidates) {
const apexOk = await probeTlsHost(domain.host);
const apexProbe = await probeTlsHostDetailed(domain.host);
const wwwHost = domain.host.startsWith('www.')
? domain.host
: `www.${domain.host}`;
const wwwOk = await probeTlsHost(wwwHost);
const ok = apexOk && wwwOk;
const wwwProbe = await probeTlsHostDetailed(wwwHost);
const ok = apexProbe.ok && wwwProbe.ok;
if (ok) {
if (!domain.sslEnabled) {
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled: true },
});
}
const sslExpiresAt = earliestTlsExpiry(apexProbe, wwwProbe);
await this.prisma.domain.update({
where: { id: domain.id },
data: {
sslEnabled: true,
...(sslExpiresAt ? { sslExpiresAt } : {}),
},
});
continue;
}
if (domain.sslEnabled) {
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled: false },
data: { sslEnabled: false, sslExpiresAt: null },
});
}
targets.push(domain);
@@ -228,14 +248,21 @@ export class DomainAdminService {
host: domain.host,
slug: domain.deploySlug,
});
const ok = await probeTlsHost(domain.host);
const apexProbe = await probeTlsHostDetailed(domain.host);
const wwwHost = domain.host.startsWith('www.')
? domain.host
: `www.${domain.host}`;
const wwwOk = ok && (await probeTlsHost(wwwHost));
const wwwProbe = await probeTlsHostDetailed(wwwHost);
const wwwOk = apexProbe.ok && wwwProbe.ok;
const sslExpiresAt = wwwOk
? earliestTlsExpiry(apexProbe, wwwProbe)
: null;
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled: wwwOk },
data: {
sslEnabled: wwwOk,
sslExpiresAt,
},
});
if (wwwOk) {
issued.push(domain.host);
@@ -297,10 +324,12 @@ export class DomainAdminService {
customer: { host: customerHost, status: 'ok' },
};
let apexOk = await probeTlsHost(apex);
let wwwOk = await probeTlsHost(wwwHost);
let apexProbe = await probeTlsHostDetailed(apex);
let wwwProbe = await probeTlsHostDetailed(wwwHost);
let businessOk = await probeTlsHost(businessHost);
let customerOk = await probeTlsHost(customerHost);
let apexOk = apexProbe.ok;
let wwwOk = wwwProbe.ok;
const storefrontNeedsIssue = !apexOk || !wwwOk;
@@ -325,8 +354,10 @@ export class DomainAdminService {
} else {
try {
await this.websiteDeployAgent.issueSsl({ host: apex, slug });
apexOk = await probeTlsHost(apex);
wwwOk = await probeTlsHost(wwwHost);
apexProbe = await probeTlsHostDetailed(apex);
wwwProbe = await probeTlsHostDetailed(wwwHost);
apexOk = apexProbe.ok;
wwwOk = wwwProbe.ok;
hosts.apex = apexOk
? {
host: apex,
@@ -416,9 +447,22 @@ export class DomainAdminService {
}
const storefrontOk = apexOk && wwwOk;
// Re-probe apex/www once more when already-ok so we persist current cert expiry.
if (storefrontOk && (!apexProbe.expiresAt || !wwwProbe.expiresAt)) {
apexProbe = await probeTlsHostDetailed(apex);
wwwProbe = await probeTlsHostDetailed(wwwHost);
apexOk = apexProbe.ok;
wwwOk = wwwProbe.ok;
}
const sslExpiresAt =
apexOk && wwwOk ? earliestTlsExpiry(apexProbe, wwwProbe) : null;
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: { sslEnabled: storefrontOk },
data: {
sslEnabled: storefrontOk && apexOk && wwwOk,
sslExpiresAt: storefrontOk && apexOk && wwwOk ? sslExpiresAt : null,
},
});
const parts = [hosts.apex, hosts.www, hosts.business, hosts.customer];
@@ -434,6 +478,7 @@ export class DomainAdminService {
status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
host: apex,
sslEnabled: updated.sslEnabled,
sslExpiresAt: updated.sslExpiresAt?.toISOString() ?? null,
hosts,
issued: issued.map((p) => p.host),
failed: failed.map((p) => ({ host: p.host, error: p.detail || 'failed' })),
@@ -579,7 +624,10 @@ export class DomainAdminService {
return this.prisma.domain.update({
where: { id: domainId },
data: { sslEnabled: dto.sslEnabled },
data: {
sslEnabled: dto.sslEnabled,
...(dto.sslEnabled ? {} : { sslExpiresAt: null }),
},
});
}
+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 { Cron, CronExpression } from '@nestjs/schedule';
import { filterHostsWithDns } from '../common/dns-resolve';
import {
earliestTlsExpiry,
probeTlsHostsDetailed,
} from '../common/tls-probe';
import { PrismaService } from '../prisma/prisma.service';
import { probeTlsHosts } from '../common/tls-probe';
@Injectable()
export class InternalSslService implements OnModuleInit {
export class InternalSslService {
private readonly logger = new Logger(InternalSslService.name);
private refreshRunning = false;
@@ -15,17 +18,17 @@ export class InternalSslService implements OnModuleInit {
private readonly config: ConfigService,
) {}
onModuleInit() {
// Warm ssl_enabled shortly after boot so lists are accurate without waiting for cron.
setTimeout(() => {
void this.refreshSslStatuses('startup');
}, 5_000);
}
private async listActiveDomains(): Promise<Array<{ id: bigint; host: string; sslEnabled: boolean }>> {
private async listActiveDomains(): Promise<
Array<{
id: bigint;
host: string;
sslEnabled: boolean;
sslExpiresAt: Date | null;
}>
> {
return this.prisma.domain.findMany({
where: { isActive: true },
select: { id: true, host: true, sslEnabled: true },
select: { id: true, host: true, sslEnabled: true, sslExpiresAt: true },
orderBy: { host: 'asc' },
});
}
@@ -82,15 +85,15 @@ export class InternalSslService implements OnModuleInit {
}
/**
* Probe each active apex host and sync domains.ssl_enabled to the live result.
* Runs every 2 hours (and once shortly after API boot).
* Occasional safety probe (once a day). Issue SSL writes ssl_expires_at and
* is the main source of truth for the list badge between probes.
*/
@Cron(CronExpression.EVERY_2_HOURS)
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async refreshSslStatusesCron() {
await this.refreshSslStatuses('cron');
}
async refreshSslStatuses(reason: 'cron' | 'startup' | 'manual' = 'manual') {
async refreshSslStatuses(reason: 'cron' | 'manual' = 'manual') {
if (this.refreshRunning) {
this.logger.warn(`SSL status refresh skipped (${reason}): already running`);
return { updated: 0, checked: 0, skipped: true as const };
@@ -99,30 +102,79 @@ export class InternalSslService implements OnModuleInit {
this.refreshRunning = true;
const started = Date.now();
try {
const now = new Date();
const domains = await this.listActiveDomains();
// Expire flags that are past cert notAfter without waiting for a probe.
let updated = 0;
for (const domain of domains) {
if (
domain.sslEnabled &&
domain.sslExpiresAt &&
domain.sslExpiresAt.getTime() <= now.getTime()
) {
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled: false },
});
domain.sslEnabled = false;
updated += 1;
this.logger.log(
`SSL status ${domain.host}: expired at ${domain.sslExpiresAt.toISOString()}`,
);
}
}
const hostsToProbe = domains.flatMap((d) => {
const apex = d.host.trim().toLowerCase();
if (!apex) return [];
const www = apex.startsWith('www.') ? apex : `www.${apex}`;
return [apex, www];
});
const sslByHost = await probeTlsHosts(hostsToProbe);
const sslByHost = await probeTlsHostsDetailed(hostsToProbe);
let updated = 0;
for (const domain of domains) {
const hostKey = domain.host.trim().toLowerCase();
const wwwKey = hostKey.startsWith('www.') ? hostKey : `www.${hostKey}`;
const sslEnabled =
(sslByHost.get(hostKey) ?? false) && (sslByHost.get(wwwKey) ?? false);
if (sslEnabled === domain.sslEnabled) continue;
const apexResult = sslByHost.get(hostKey) ?? {
ok: false,
expiresAt: null,
};
const wwwResult = sslByHost.get(wwwKey) ?? {
ok: false,
expiresAt: null,
};
const liveOk = apexResult.ok && wwwResult.ok;
const sslExpiresAt = liveOk
? earliestTlsExpiry(apexResult, wwwResult)
: null;
const nextEnabled = liveOk;
const nextExpires =
liveOk && sslExpiresAt
? sslExpiresAt
: liveOk
? domain.sslExpiresAt
: null;
const enabledChanged = nextEnabled !== domain.sslEnabled;
const expiryChanged =
(nextExpires?.getTime() ?? null) !==
(domain.sslExpiresAt?.getTime() ?? null);
if (!enabledChanged && !expiryChanged) continue;
await this.prisma.domain.update({
where: { id: domain.id },
data: { sslEnabled },
data: {
sslEnabled: nextEnabled,
sslExpiresAt: nextExpires,
},
});
updated += 1;
this.logger.log(
`SSL status ${domain.host}: ${domain.sslEnabled} → ${sslEnabled}`,
`SSL status ${domain.host}: enabled ${domain.sslEnabled}→${nextEnabled}` +
(nextExpires ? ` expires ${nextExpires.toISOString()}` : ''),
);
}
+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;
+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 { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import {
CreatePortfolioCommentDto,
CreatePortfolioDto,
@@ -92,19 +93,26 @@ export class PortfoliosController {
@Controller('tenants/:host/portfolios')
export class PublicPortfoliosController {
constructor(private readonly service: PortfoliosService) {}
constructor(
private readonly service: PortfoliosService,
private readonly analytics: WebsiteAnalyticsService,
) {}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
return this.service.listPublic(host, query);
async list(@Param('host') host: string, @Query() query: ListPublicPortfoliosDto) {
const result = await this.service.listPublic(host, query);
this.analytics.trackPublicPage(host, 'portfolio_list');
return result;
}
@Get('by-id/:portfolioId')
getById(
async getById(
@Param('host') host: string,
@Param('portfolioId') portfolioId: string,
) {
return this.service.getPublicById(host, portfolioId);
const result = await this.service.getPublicById(host, portfolioId);
this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id);
return result;
}
@Get(':portfolioId/comments')
@@ -125,7 +133,9 @@ export class PublicPortfoliosController {
}
@Get(':slug')
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicBySlug(host, slug);
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
const result = await this.service.getPublicBySlug(host, slug);
this.analytics.trackPublicPage(host, 'portfolio_detail', result.portfolio.id);
return result;
}
}
+9 -1
View File
@@ -4,6 +4,7 @@ import { BusinessSettingsModule } from '../business-settings/business-settings.m
import { MediaModule } from '../media/media.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
import {
PortfoliosController,
PublicPortfoliosController,
@@ -11,7 +12,14 @@ import {
import { PortfoliosService } from './portfolios.service';
@Module({
imports: [AuthModule, BusinessSettingsModule, MediaModule, SitemapModule, TenantModule],
imports: [
AuthModule,
BusinessSettingsModule,
MediaModule,
SitemapModule,
TenantModule,
WebsiteAnalyticsModule,
],
controllers: [PortfoliosController, PublicPortfoliosController],
providers: [PortfoliosService],
})
+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 { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
@@ -164,11 +165,14 @@ export class PublicProductsController {
private readonly service: ProductsService,
private readonly variationValuesService: ProductVariationValuesService,
private readonly technicalInfoService: ProductTechnicalInfoService,
private readonly analytics: WebsiteAnalyticsService,
) {}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
return this.service.listPublic(host, query);
async list(@Param('host') host: string, @Query() query: ListPublicProductsDto) {
const result = await this.service.listPublic(host, query);
this.analytics.trackPublicPage(host, 'product_list');
return result;
}
@Get('by-id/:productId/variations')
@@ -196,8 +200,11 @@ export class PublicProductsController {
}
@Get('by-id/:productId')
getById(@Param('host') host: string, @Param('productId') productId: string) {
return this.service.getPublicById(host, productId);
async getById(@Param('host') host: string, @Param('productId') productId: string) {
const result = await this.service.getPublicById(host, productId);
this.analytics.trackPublicPage(host, 'product_detail', result.product.id);
this.service.bumpVisitCount(result.product.id);
return result;
}
@Get(':slug/variations')
@@ -225,7 +232,10 @@ export class PublicProductsController {
}
@Get(':slug')
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicBySlug(host, slug);
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
const result = await this.service.getPublicBySlug(host, slug);
this.analytics.trackPublicPage(host, 'product_detail', result.product.id);
this.service.bumpVisitCount(result.product.id);
return result;
}
}
+10 -1
View File
@@ -5,6 +5,7 @@ import { CategoriesModule } from '../categories/categories.module';
import { MediaModule } from '../media/media.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
import { ProductAiService } from './product-ai.service';
import { ProductTechnicalInfoService } from './product-technical-info.service';
import { ProductVariationValuesService } from './product-variation-values.service';
@@ -12,7 +13,15 @@ import { ProductsController, PublicProductsController } from './products.control
import { ProductsService } from './products.service';
@Module({
imports: [AuthModule, BrandsModule, CategoriesModule, MediaModule, SitemapModule, TenantModule],
imports: [
AuthModule,
BrandsModule,
CategoriesModule,
MediaModule,
SitemapModule,
TenantModule,
WebsiteAnalyticsModule,
],
controllers: [ProductsController, PublicProductsController],
providers: [
ProductsService,
+28
View File
@@ -602,11 +602,39 @@ export class ProductsService {
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
commentCount,
variantCount: resolvedVariantCount,
visitCount: product.visitCount.toString(),
cartAddCount: product.cartAddCount.toString(),
saleCount: product.saleCount.toString(),
createdAt: product.createdAt,
updatedAt: product.updatedAt,
};
}
/** Bump product detail visit counter (fire-and-forget safe). */
bumpVisitCount(productId: string | bigint): void {
void this.incrementProductStat(productId, 'visitCount', 1).catch(() => undefined);
}
bumpCartAddCount(productId: string | bigint, quantity = 1): void {
void this.incrementProductStat(productId, 'cartAddCount', quantity).catch(
() => undefined,
);
}
private async incrementProductStat(
productIdRaw: string | bigint,
field: 'visitCount' | 'cartAddCount' | 'saleCount',
by: number,
) {
if (!Number.isFinite(by) || by <= 0) return;
const productId =
typeof productIdRaw === 'bigint' ? productIdRaw : BigInt(productIdRaw);
await this.prisma.product.update({
where: { id: productId },
data: { [field]: { increment: by } },
});
}
private buildContent(nameFa?: string, descriptionHtml?: string) {
return {
nameFa: nameFa?.trim() || null,
+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 { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import {
BatchCreateStoreItemsDto,
BatchUpdateStoreItemDiscountsDto,
@@ -166,20 +167,41 @@ export class StoreItemsController {
@Controller('tenants/:host/store-items')
export class PublicStoreItemsController {
constructor(private readonly service: StoreItemsService) {}
constructor(
private readonly service: StoreItemsService,
private readonly analytics: WebsiteAnalyticsService,
) {}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
return this.service.listPublic(host, query);
async list(@Param('host') host: string, @Query() query: ListPublicStoreItemsDto) {
const result = await this.service.listPublic(host, query);
this.analytics.trackPublicPage(host, 'store_item_list');
return result;
}
@Get('by-product/:productId')
getByProduct(@Param('host') host: string, @Param('productId') productId: string) {
return this.service.getPublicByProduct(host, productId);
async getByProduct(
@Param('host') host: string,
@Param('productId') productId: string,
) {
const result = await this.service.getPublicByProduct(host, productId);
if (result.storeItem) {
this.analytics.trackPublicPage(host, 'store_item_detail', result.storeItem.id);
}
return result;
}
@Get(':variantId')
getVariant(@Param('host') host: string, @Param('variantId') variantId: string) {
return this.service.getPublicVariant(host, variantId);
async getVariant(
@Param('host') host: string,
@Param('variantId') variantId: string,
) {
const result = await this.service.getPublicVariant(host, variantId);
this.analytics.trackPublicPage(
host,
'store_item_detail',
result.variant.storeItemId,
);
return result;
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TenantModule } from '../tenant/tenant.module';
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
import {
PublicStoreSpecialsController,
StoreSpecialsController,
@@ -10,7 +11,7 @@ import { StoreItemsController, PublicStoreItemsController } from './store-items.
import { StoreItemsService } from './store-items.service';
@Module({
imports: [AuthModule, TenantModule],
imports: [AuthModule, TenantModule, WebsiteAnalyticsModule],
controllers: [
StoreItemsController,
PublicStoreItemsController,
+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 { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import { VideosService } from './videos.service';
import {
CreateVideoCommentDto,
@@ -92,11 +93,16 @@ export class VideosController {
@Controller('tenants/:host/videos')
export class PublicVideosController {
constructor(private readonly service: VideosService) {}
constructor(
private readonly service: VideosService,
private readonly analytics: WebsiteAnalyticsService,
) {}
@Get()
list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
return this.service.listPublic(host, query);
async list(@Param('host') host: string, @Query() query: ListPublicVideosDto) {
const result = await this.service.listPublic(host, query);
this.analytics.trackPublicPage(host, 'video_list');
return result;
}
@Get(':videoId/comments')
@@ -114,7 +120,9 @@ export class PublicVideosController {
}
@Get(':slug')
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
return this.service.getPublicBySlug(host, slug);
async getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
const result = await this.service.getPublicBySlug(host, slug);
this.analytics.trackPublicPage(host, 'video_detail', result.video.id);
return result;
}
}
+2 -1
View File
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
import { TenantModule } from '../tenant/tenant.module';
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
import { PublicVideosController, VideosController } from './videos.controller';
import { VideosService } from './videos.service';
@Module({
imports: [AuthModule, BusinessSettingsModule, TenantModule],
imports: [AuthModule, BusinessSettingsModule, TenantModule, WebsiteAnalyticsModule],
controllers: [VideosController, PublicVideosController],
providers: [VideosService],
})
+16 -5
View File
@@ -1,17 +1,28 @@
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types';
import {
WEBSITE_PAGE_KINDS,
type WebsitePageKind,
} from '../website-analytics.types';
/** Accepts canonical page kinds plus legacy aliases (website/product/portfolio/blog). */
const RECORD_KINDS = [
...WEBSITE_PAGE_KINDS,
'website',
'product',
'portfolio',
'blog',
] as const;
export class RecordWebsiteViewDto {
@IsIn([...WEBSITE_VIEW_KINDS])
kind!: WebsiteViewKind;
@IsIn([...RECORD_KINDS])
kind!: WebsitePageKind | 'website' | 'product' | 'portfolio' | 'blog';
/** Product / portfolio / blog id when kind is not `website`. */
/** Required for *_detail kinds. */
@IsOptional()
@IsString()
@MinLength(1)
entityId?: string;
/** Page path for storefront visits (e.g. `/`, `/about`, `/products/12/slug`). */
@IsOptional()
@IsString()
@MaxLength(500)
@@ -1,10 +1,18 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
import { WEBSITE_VIEW_KINDS, type WebsiteViewKind } from '../website-analytics.types';
import { WEBSITE_PAGE_KINDS } from '../website-analytics.types';
const ACTIVITY_KINDS = [
...WEBSITE_PAGE_KINDS,
'website',
'product',
'portfolio',
'blog',
] as const;
export class ViewActivityQueryDto {
@IsIn([...WEBSITE_VIEW_KINDS])
kind!: WebsiteViewKind;
@IsIn([...ACTIVITY_KINDS])
kind!: (typeof ACTIVITY_KINDS)[number];
@IsOptional()
@Type(() => Number)
@@ -9,5 +9,6 @@ import { WebsiteAnalyticsService } from './website-analytics.service';
imports: [AuthModule, TenantModule],
controllers: [PublicWebsiteAnalyticsController, WebsiteAnalyticsController],
providers: [WebsiteAnalyticsService],
exports: [WebsiteAnalyticsService],
})
export class WebsiteAnalyticsModule {}
@@ -2,8 +2,10 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { buildDailyActivitySeries, startOfLocalDay } from '../common/daily-activity';
@@ -11,43 +13,90 @@ import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import type { RecordWebsiteViewDto } from './dto/record-view.dto';
import type { WebsiteViewKind } from './website-analytics.types';
import {
normalizeWebsitePageKind,
pageKindRequiresEntity,
type WebsitePageKind,
} from './website-analytics.types';
const EVENT_RETENTION_DAYS = 183; // ~6 months
@Injectable()
export class WebsiteAnalyticsService {
private readonly logger = new Logger(WebsiteAnalyticsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly tenant: TenantService,
private readonly permissions: PermissionsService,
) {}
async recordPublicView(host: string, dto: RecordWebsiteViewDto) {
const business = await this.tenant.resolveBusinessByDomain(host);
const businessId = business.id;
const entityId = await this.resolveEntityId(businessId, dto.kind, dto.entityId);
await this.prisma.websiteContentView.create({
data: {
businessId,
viewKind: dto.kind,
entityId,
pagePath: this.normalizePath(dto.path),
},
/**
* Fire-and-forget friendly: record event + bump lifetime counter.
* Used from public list/detail GET handlers after a successful response load.
* `entityId` is trusted (already loaded by the caller); use 0 / omit for lists.
*/
trackPublicPage(
host: string,
kind: WebsitePageKind,
entityId?: string | bigint | null,
path?: string,
): void {
void this.recordTrustedPageView(host, kind, entityId, path).catch((err) => {
this.logger.warn(
`trackPublicPage failed host=${host} kind=${kind}: ${
err instanceof Error ? err.message : String(err)
}`,
);
});
}
async recordPublicView(host: string, dto: RecordWebsiteViewDto) {
const kind = normalizeWebsitePageKind(dto.kind);
if (!kind) {
throw new BadRequestException(`Invalid view kind: ${dto.kind}`);
}
await this.recordPageViewByHost(host, kind, dto.entityId, dto.path);
return { ok: true as const };
}
private async recordTrustedPageView(
host: string,
kind: WebsitePageKind,
entityIdRaw?: string | bigint | null,
path?: string,
) {
const business = await this.tenant.resolveBusinessByDomain(host);
let entityId = 0n;
if (pageKindRequiresEntity(kind)) {
const raw =
entityIdRaw === undefined || entityIdRaw === null
? ''
: String(entityIdRaw).trim();
if (!raw) return;
try {
entityId = typeof entityIdRaw === 'bigint' ? entityIdRaw : BigInt(raw);
} catch {
return;
}
}
await this.writeView(business.id, kind, entityId, path);
}
async dailyViewActivity(
businessIdRaw: string,
kind: WebsiteViewKind,
kindRaw: string,
daysRaw: number | undefined,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'business.read');
const kind = normalizeWebsitePageKind(kindRaw);
if (!kind) {
throw new BadRequestException(`Invalid view kind: ${kindRaw}`);
}
const days = Math.min(Math.max(daysRaw ?? 30, 1), 90);
const from = startOfLocalDay(days - 1);
@@ -55,9 +104,9 @@ export class WebsiteAnalyticsService {
Prisma.sql`
SELECT viewed_at::date AS day,
COUNT(*)::int AS count
FROM website_content_views
FROM website_page_views
WHERE business_id = ${businessId}
AND view_kind = ${kind}::website_view_kind
AND page_kind = ${kind}::website_page_kind
AND viewed_at >= ${from}
GROUP BY 1
ORDER BY 1
@@ -68,33 +117,112 @@ export class WebsiteAnalyticsService {
return {
days,
primary,
secondary: { days, total: 0, items: primary.items.map((item) => ({ ...item, count: 0 })) },
secondary: {
days,
total: 0,
items: primary.items.map((item) => ({ ...item, count: 0 })),
},
};
}
/** Delete event rows older than ~6 months. Lifetime counters are kept. */
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async purgeOldPageViews() {
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - EVENT_RETENTION_DAYS);
const result = await this.prisma.websitePageView.deleteMany({
where: { viewedAt: { lt: cutoff } },
});
if (result.count > 0) {
this.logger.log(
`Purged ${result.count} website_page_views older than ${cutoff.toISOString()}`,
);
}
}
private async recordPageViewByHost(
host: string,
kind: WebsitePageKind,
entityIdRaw?: string | bigint | null,
path?: string,
) {
const business = await this.tenant.resolveBusinessByDomain(host);
const entityId = await this.resolveEntityId(
business.id,
kind,
entityIdRaw,
);
await this.writeView(business.id, kind, entityId, path);
}
private async writeView(
businessId: bigint,
kind: WebsitePageKind,
entityId: bigint,
path?: string,
) {
const pagePath = this.normalizePath(path);
await this.prisma.$transaction([
this.prisma.websitePageView.create({
data: {
businessId,
pageKind: kind,
entityId,
pagePath,
},
}),
this.prisma.$executeRaw`
INSERT INTO website_page_view_counters
(business_id, page_kind, entity_id, total_count, updated_at)
VALUES
(${businessId}, ${kind}::website_page_kind, ${entityId}, 1, now())
ON CONFLICT (business_id, page_kind, entity_id)
DO UPDATE SET
total_count = website_page_view_counters.total_count + 1,
updated_at = now()
`,
]);
}
private normalizePath(path: string | undefined): string | null {
if (!path) return null;
const trimmed = path.trim();
if (!trimmed) return null;
return trimmed.startsWith('/') ? trimmed.slice(0, 500) : `/${trimmed}`.slice(0, 500);
return trimmed.startsWith('/')
? trimmed.slice(0, 500)
: `/${trimmed}`.slice(0, 500);
}
private async resolveEntityId(
businessId: bigint,
kind: WebsiteViewKind,
entityIdRaw: string | undefined,
): Promise<bigint | null> {
if (kind === 'website') {
return null;
kind: WebsitePageKind,
entityIdRaw: string | bigint | null | undefined,
): Promise<bigint> {
if (!pageKindRequiresEntity(kind)) {
return 0n;
}
if (!entityIdRaw?.trim()) {
const raw =
entityIdRaw === undefined || entityIdRaw === null
? ''
: String(entityIdRaw).trim();
if (!raw) {
throw new BadRequestException(`entityId is required for ${kind} views`);
}
const entityId = BigInt(entityIdRaw);
let entityId: bigint;
try {
entityId = typeof entityIdRaw === 'bigint' ? entityIdRaw : BigInt(raw);
} catch {
throw new BadRequestException(`Invalid entityId for ${kind}`);
}
if (kind === 'product') {
if (kind === 'product_detail') {
const product = await this.prisma.product.findFirst({
where: { id: entityId, businessId },
select: { id: true },
@@ -103,7 +231,7 @@ export class WebsiteAnalyticsService {
return entityId;
}
if (kind === 'portfolio') {
if (kind === 'portfolio_detail') {
const portfolio = await this.prisma.portfolios.findFirst({
where: { id: entityId, business_id: businessId },
select: { id: true },
@@ -112,11 +240,43 @@ export class WebsiteAnalyticsService {
return entityId;
}
const blog = await this.prisma.blogs.findFirst({
where: { id: entityId, business_id: businessId },
select: { id: true },
});
if (!blog) throw new NotFoundException('Blog not found');
if (kind === 'blog_detail') {
const blog = await this.prisma.blogs.findFirst({
where: { id: entityId, business_id: businessId },
select: { id: true },
});
if (!blog) throw new NotFoundException('Blog not found');
return entityId;
}
if (kind === 'video_detail') {
const video = await this.prisma.videos.findFirst({
where: { id: entityId, business_id: businessId },
select: { id: true },
});
if (!video) throw new NotFoundException('Video not found');
return entityId;
}
if (kind === 'store_item_detail') {
const storeItem = await this.prisma.storeItem.findFirst({
where: {
businessId,
OR: [{ id: entityId }, { productId: entityId }],
},
select: { id: true },
});
if (storeItem) return storeItem.id;
const variant = await this.prisma.storeItemVariant.findFirst({
where: { id: entityId, businessId },
select: { storeItemId: true },
});
if (variant) return variant.storeItemId;
throw new NotFoundException('Store item not found');
}
return entityId;
}
@@ -1,25 +1,59 @@
export const WEBSITE_VIEW_KINDS = [
'website',
'product',
'portfolio',
'blog',
export const WEBSITE_PAGE_KINDS = [
'home',
'portfolio_list',
'portfolio_detail',
'product_list',
'product_detail',
'store_item_list',
'store_item_detail',
'blog_list',
'blog_detail',
'video_list',
'video_detail',
] as const;
export type WebsiteViewKind = (typeof WEBSITE_VIEW_KINDS)[number];
export type WebsitePageKind = (typeof WEBSITE_PAGE_KINDS)[number];
export function isWebsiteViewKind(value: unknown): value is WebsiteViewKind {
/** @deprecated Prefer WebsitePageKind — kept for dashboard chart query aliases. */
export type WebsiteViewKind = WebsitePageKind | 'website' | 'product' | 'portfolio' | 'blog';
const DETAIL_KINDS = new Set<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 (
typeof value === 'string' &&
(WEBSITE_VIEW_KINDS as readonly string[]).includes(value)
(WEBSITE_PAGE_KINDS as readonly string[]).includes(value)
);
}
/** Maps home dashboard chart ids to analytics view kinds. */
export const HOME_CHART_VIEW_KIND: Partial<
Record<string, WebsiteViewKind>
> = {
website_views_30d: 'website',
product_views_30d: 'product',
portfolio_views_30d: 'portfolio',
blog_views_30d: 'blog',
export function normalizeWebsitePageKind(value: string): WebsitePageKind | null {
if (isWebsitePageKind(value)) return value;
const aliased = KIND_ALIASES[value];
return aliased ?? null;
}
export function pageKindRequiresEntity(kind: WebsitePageKind): boolean {
return DETAIL_KINDS.has(kind);
}
/** Maps home dashboard chart ids to analytics page kinds. */
export const HOME_CHART_VIEW_KIND: Partial<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.
### Analytics (dashboard charts)
Record storefront views so the business home charts can show traffic:
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "website"|"product"|"portfolio"|"blog", "entityId"?: "...", "path"?: "/about" }`
- Call once per page view from the website (product detail, portfolio, blog post, or general pages with `kind: "website"`).
Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, portfolios, videos, store-items, and homepage sliders). No extra website code is required for those.
Optional explicit record (e.g. custom home without sliders):
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
- `kind` values: `home`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted)
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
### Static images
Named slots the business dashboard can replace. Fetch once per page:
+21 -4
View File
@@ -434,7 +434,7 @@
"post": {
"tags": ["Analytics"],
"summary": "Record a storefront page view",
"description": "Fire-and-forget view counter for business dashboard charts. Call once per page impression from the website (product detail, portfolio, blog post, or general site page). No auth required.",
"description": "Optional explicit view counter. Most page views are recorded automatically when public list/detail APIs are called (blogs, products, portfolios, videos, store-items, homepage sliders). Use this for custom pages. No auth required. Events retained ~6 months; lifetime counters kept forever.",
"parameters": [
{
"$ref": "#/components/parameters/domain"
@@ -450,15 +450,32 @@
"properties": {
"kind": {
"type": "string",
"enum": ["website", "product", "portfolio", "blog"]
"enum": [
"home",
"portfolio_list",
"portfolio_detail",
"product_list",
"product_detail",
"store_item_list",
"store_item_detail",
"blog_list",
"blog_detail",
"video_list",
"video_detail",
"website",
"product",
"portfolio",
"blog"
],
"description": "Canonical kinds preferred. Legacy aliases: website→home, product→product_detail, portfolio→portfolio_detail, blog→blog_detail."
},
"entityId": {
"type": "string",
"description": "Required when kind is product, portfolio, or blog."
"description": "Required for *_detail kinds (product, portfolio, blog, video, store item)."
},
"path": {
"type": "string",
"description": "Optional page path for website visits (e.g. `/`, `/about`)."
"description": "Optional page path (e.g. `/`, `/blog/my-post`)."
}
}
}
+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 { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import {
CreateWebsiteSliderDto,
ListWebsiteSlidersDto,
@@ -23,11 +24,17 @@ import { WebsiteSlidersService } from './website-sliders.service';
@Controller('tenants/:host/website/sliders')
export class PublicWebsiteSlidersController {
constructor(private readonly service: WebsiteSlidersService) {}
constructor(
private readonly service: WebsiteSlidersService,
private readonly analytics: WebsiteAnalyticsService,
) {}
@Get()
list(@Param('host') host: string) {
return this.service.listPublic(host);
async list(@Param('host') host: string) {
const result = await this.service.listPublic(host);
// Homepage signal: Meshkee storefronts load sliders on the home page.
this.analytics.trackPublicPage(host, 'home');
return result;
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MediaModule } from '../media/media.module';
import { TenantModule } from '../tenant/tenant.module';
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
import {
PublicWebsiteBrandGroupsController,
WebsiteBrandGroupsController,
@@ -30,7 +31,7 @@ import {
import { WebsiteStaticImagesService } from './website-static-images.service';
@Module({
imports: [AuthModule, TenantModule, MediaModule],
imports: [AuthModule, TenantModule, MediaModule, WebsiteAnalyticsModule],
controllers: [
PublicWebsiteCategoryGroupsController,
WebsiteCategoryGroupsController,