Add page-view analytics, product counters, SSL expiry, and business renewal.
Auto-count storefront page views with 6-month event retention and lifetime counters, track product visit/cart/sale stats, persist SSL cert expiry with daily probes, and support owner titles plus annual contract renewal. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6cd0b009ef
commit
92601ae729
@@ -0,0 +1,76 @@
|
||||
-- Expand page-view kinds, rename events table, add lifetime counters.
|
||||
-- Events retained max 6 months (purged by Nest cron). Counters kept forever.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'website_page_kind') THEN
|
||||
CREATE TYPE website_page_kind AS ENUM (
|
||||
'home',
|
||||
'portfolio_list',
|
||||
'portfolio_detail',
|
||||
'product_list',
|
||||
'product_detail',
|
||||
'store_item_list',
|
||||
'store_item_detail',
|
||||
'blog_list',
|
||||
'blog_detail',
|
||||
'video_list',
|
||||
'video_detail'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Recreate events table with new kind enum (table is empty / low volume today).
|
||||
CREATE TABLE IF NOT EXISTS website_page_views (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
page_kind website_page_kind NOT NULL,
|
||||
entity_id BIGINT NOT NULL DEFAULT 0,
|
||||
page_path TEXT,
|
||||
viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_website_page_views_business_kind_time
|
||||
ON website_page_views (business_id, page_kind, viewed_at DESC);
|
||||
|
||||
-- Copy any legacy rows from website_content_views
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'website_content_views'
|
||||
) THEN
|
||||
INSERT INTO website_page_views (business_id, page_kind, entity_id, page_path, viewed_at)
|
||||
SELECT
|
||||
business_id,
|
||||
CASE view_kind::text
|
||||
WHEN 'website' THEN 'home'::website_page_kind
|
||||
WHEN 'product' THEN 'product_detail'::website_page_kind
|
||||
WHEN 'portfolio' THEN 'portfolio_detail'::website_page_kind
|
||||
WHEN 'blog' THEN 'blog_detail'::website_page_kind
|
||||
ELSE 'home'::website_page_kind
|
||||
END,
|
||||
COALESCE(entity_id, 0),
|
||||
page_path,
|
||||
viewed_at
|
||||
FROM website_content_views;
|
||||
|
||||
DROP TABLE website_content_views;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DROP TYPE IF EXISTS website_view_kind;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS website_page_view_counters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
page_kind website_page_kind NOT NULL,
|
||||
entity_id BIGINT NOT NULL DEFAULT 0,
|
||||
total_count BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_website_page_view_counters_biz_kind_entity
|
||||
UNIQUE (business_id, page_kind, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_website_page_view_counters_business_kind
|
||||
ON website_page_view_counters (business_id, page_kind);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Lifetime product analysis counters (visits, cart adds, units sold).
|
||||
ALTER TABLE products
|
||||
ADD COLUMN IF NOT EXISTS visit_count BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS cart_add_count BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS sale_count BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Store Let's Encrypt / TLS notAfter after a successful SSL issue.
|
||||
-- List Valid/Invalid prefers this date; live probes run at most once per day.
|
||||
ALTER TABLE domains
|
||||
ADD COLUMN IF NOT EXISTS ssl_expires_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_domains_ssl_expires_at
|
||||
ON domains (ssl_expires_at)
|
||||
WHERE ssl_expires_at IS NOT NULL;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Business annual renewal date + user honorific titles.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_titles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
slug VARCHAR(32) NOT NULL,
|
||||
name_en VARCHAR(64) NOT NULL,
|
||||
name_fa VARCHAR(64) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT user_titles_slug_unique UNIQUE (slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_titles_sort
|
||||
ON user_titles (sort_order, id);
|
||||
|
||||
INSERT INTO user_titles (slug, name_en, name_fa, sort_order)
|
||||
VALUES
|
||||
('mr', 'Mr', 'آقای', 10),
|
||||
('mrs', 'Mrs', 'خانم', 20),
|
||||
('ms', 'Ms', 'خانم', 30),
|
||||
('dr', 'Dr', 'دکتر', 40),
|
||||
('eng', 'Eng.', 'مهندس', 50)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS title_id BIGINT REFERENCES user_titles (id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_title_id ON users (title_id);
|
||||
|
||||
ALTER TABLE businesses
|
||||
ADD COLUMN IF NOT EXISTS annual_renewal_at TIMESTAMPTZ;
|
||||
@@ -55,9 +55,12 @@ Public marketplace listings owned by customers — not catalog `products`.
|
||||
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -509,6 +509,11 @@ export class OrdersService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await tx.product.update({
|
||||
where: { id: item.productId },
|
||||
data: { saleCount: { increment: item.quantity } },
|
||||
});
|
||||
}
|
||||
|
||||
return created as OrderWithItems;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { UserTitlesService } from './user-titles.service';
|
||||
|
||||
@Controller('user-titles')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserTitlesController {
|
||||
constructor(private readonly service: UserTitlesService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.service.listActive();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UserTitlesController } from './user-titles.controller';
|
||||
import { UserTitlesService } from './user-titles.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [UserTitlesController],
|
||||
providers: [UserTitlesService],
|
||||
exports: [UserTitlesService],
|
||||
})
|
||||
export class UserTitlesModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserTitlesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listActive() {
|
||||
const items = await this.prisma.userTitle.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
nameEn: true,
|
||||
nameFa: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
slug: item.slug,
|
||||
nameEn: item.nameEn,
|
||||
nameFa: item.nameFa,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { 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,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],
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user