From fe6a9006651b4e26d1ce0f18a27b69a0f27bcd28 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Mon, 24 Aug 2026 17:23:43 +0330 Subject: [PATCH] feat(perf): implement comprehensive Core Web Vitals, ISR caching, and media optimizations --- backend/package-lock.json | 76 + backend/package.json | 2 + backend/src/admin/admin.service.ts | 28 +- backend/src/admin/blogs.controller.ts | 12 +- backend/src/admin/blogs.service.ts | 100 +- backend/src/admin/dto/product.dto.ts | 13 +- backend/src/app.module.ts | 2 + backend/src/blogs/blogs.service.ts | 37 +- .../revalidation/revalidation.module.ts | 9 + .../revalidation/revalidation.service.ts | 123 + backend/src/main.ts | 3 + backend/src/products/products.service.ts | 13 +- backend/src/reviews/reviews.service.ts | 22 +- frontend/application/app/ClientLayout.tsx | 10 +- .../application/app/api/revalidate/route.ts | 69 + frontend/application/app/blog/[slug]/page.tsx | 12 +- frontend/application/app/blog/page.tsx | 10 +- frontend/application/app/globals.css | 16 +- frontend/application/app/layout.tsx | 3 +- frontend/application/app/shop/[slug]/page.tsx | 7 +- .../application/components/ArchivePage.tsx | 8 +- frontend/application/components/BlogPage.tsx | 3 + .../components/BlogPreviewSection.tsx | 6 +- .../components/FeaturedProducts.tsx | 1 + .../components/PodcastInlinePlayer.tsx | 1 + .../application/components/ProductPage.tsx | 8 +- frontend/application/components/SafeImage.tsx | 31 +- frontend/application/lib/fonts.ts | 28 + .../lib/services/productService.ts | 26 + frontend/application/next.config.ts | 18 +- graphify-out/.graphify_labels.json | 77 +- graphify-out/.graphify_labels.json.sig | 2 +- graphify-out/2026-08-24/.graphify_labels.json | 54 +- graphify-out/2026-08-24/GRAPH_REPORT.md | 194 +- graphify-out/2026-08-24/graph.json | 5283 +++--- graphify-out/2026-08-24/manifest.json | 1144 +- graphify-out/GRAPH_REPORT.md | 384 +- ...41913018360de5596b9e6eff52d191522adfe.json | 1 + graphify-out/cache/stat-index.json | 2 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 14364 +++++++++------- graphify-out/manifest.json | 1292 +- 42 files changed, 12941 insertions(+), 10561 deletions(-) create mode 100644 backend/src/common/revalidation/revalidation.module.ts create mode 100644 backend/src/common/revalidation/revalidation.service.ts create mode 100644 frontend/application/app/api/revalidate/route.ts create mode 100644 frontend/application/lib/fonts.ts create mode 100644 graphify-out/cache/ast/v0.9.44/c05865bc3bcdf5575a5889c564941913018360de5596b9e6eff52d191522adfe.json diff --git a/backend/package-lock.json b/backend/package-lock.json index b7c5731..638ef2f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "compression": "^1.8.1", "helmet": "^8.2.0", "ioredis": "^5.11.0", "js-yaml": "^4.1.0", @@ -38,6 +39,7 @@ "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/js-yaml": "^4.0.9", @@ -2855,6 +2857,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -4911,6 +4924,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8223,6 +8290,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index 564ed09..0c9132a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "compression": "^1.8.1", "helmet": "^8.2.0", "ioredis": "^5.11.0", "js-yaml": "^4.1.0", @@ -50,6 +51,7 @@ "@nestjs/testing": "^11.0.1", "@types/bcrypt": "^6.0.0", "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/js-yaml": "^4.0.9", diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 26d0065..4c5e4bc 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -12,6 +12,7 @@ import { CreateUserDto, UpdateUserDto } from './dto/user.dto'; import { ProductDto } from './dto/product.dto'; import { normalizeMobile } from '../common/utils/phone.utils'; import { CANONICAL_ALIASES } from '../settings/settings.service'; +import { RevalidationService } from '../common/revalidation/revalidation.service'; export class PaginationQuery { page?: number | string; @@ -51,6 +52,7 @@ export class AdminService { constructor( private prisma: PrismaService, private redisService: RedisService, + private revalidationService: RevalidationService, ) {} async getDashboardStats() { @@ -456,6 +458,10 @@ export class AdminService { }); } + await this.revalidationService.revalidateProduct( + product.slug, + product.artNo, + ); return product; } @@ -516,11 +522,15 @@ export class AdminService { metaDescription: data.metaDescription, keywords: data.keywords, canonicalUrl: data.canonicalUrl, - stockStatus: data.stockStatus !== undefined ? data.stockStatus : undefined, + stockStatus: + data.stockStatus !== undefined ? data.stockStatus : undefined, noIndex: data.noIndex !== undefined ? data.noIndex : undefined, noFollow: data.noFollow !== undefined ? data.noFollow : undefined, ogImage: data.ogImage !== undefined ? data.ogImage : undefined, - featuredImageAlt: data.featuredImageAlt !== undefined ? data.featuredImageAlt : undefined, + featuredImageAlt: + data.featuredImageAlt !== undefined + ? data.featuredImageAlt + : undefined, slug: data.slug || data.artNo, }, }); @@ -539,13 +549,25 @@ export class AdminService { } } + await this.revalidationService.revalidateProduct( + product.slug, + product.artNo, + ); return product; } async deleteProduct(id: string) { - return this.prisma.product.delete({ + const existing = await this.prisma.product.findUnique({ where: { id } }); + const res = await this.prisma.product.delete({ where: { id }, }); + if (existing) { + await this.revalidationService.revalidateProduct( + existing.slug, + existing.artNo, + ); + } + return res; } async getOrders(query: PaginationQuery = {}) { diff --git a/backend/src/admin/blogs.controller.ts b/backend/src/admin/blogs.controller.ts index e10da4b..b948cad 100644 --- a/backend/src/admin/blogs.controller.ts +++ b/backend/src/admin/blogs.controller.ts @@ -37,7 +37,12 @@ export class BlogsController { @ApiQuery({ name: 'status', required: false }) @ApiQuery({ name: 'categoryId', required: false }) async getBlogs( - @Query() query: PaginationDto & { status?: string; categoryId?: string; tagId?: string }, + @Query() + query: PaginationDto & { + status?: string; + categoryId?: string; + tagId?: string; + }, ) { return this.blogsService.getBlogs(query); } @@ -120,10 +125,7 @@ export class BlogsController { @Put(':id') @ApiOperation({ summary: 'ویرایش مقاله' }) - async updateBlog( - @Param('id') id: string, - @Body() body: any, - ) { + async updateBlog(@Param('id') id: string, @Body() body: any) { const data = await this.blogsService.updateBlog(id, body); return { success: true, data }; } diff --git a/backend/src/admin/blogs.service.ts b/backend/src/admin/blogs.service.ts index 1183cc8..eec4644 100644 --- a/backend/src/admin/blogs.service.ts +++ b/backend/src/admin/blogs.service.ts @@ -2,12 +2,22 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PaginationDto } from '../common/dto/pagination.dto'; +import { RevalidationService } from '../common/revalidation/revalidation.service'; @Injectable() export class BlogsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private revalidationService: RevalidationService, + ) {} - async getBlogs(query: PaginationDto & { status?: string; categoryId?: string; tagId?: string }) { + async getBlogs( + query: PaginationDto & { + status?: string; + categoryId?: string; + tagId?: string; + }, + ) { const page = Number(query.page) || 1; const limit = Number(query.limit) || 10; const skip = (page - 1) * limit; @@ -37,7 +47,9 @@ export class BlogsService { take: limit, orderBy: { createdAt: 'desc' }, include: { - author: { select: { id: true, firstName: true, lastName: true, email: true } }, + author: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, category: { select: { id: true, name: true, slug: true } }, tags: { select: { id: true, name: true, slug: true } }, _count: { select: { comments: true } }, @@ -87,7 +99,10 @@ export class BlogsService { for (const tName of tagNames) { const trimmed = String(tName).trim(); if (trimmed) { - const slug = trimmed.toLowerCase().replace(/\s+/g, '-').replace(/[^\w\u0600-\u06FF-]+/g, ''); + const slug = trimmed + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w\u0600-\u06FF-]+/g, ''); tagConnectOrCreate.push({ where: { name: trimmed }, create: { name: trimmed, slug: slug || `tag-${Date.now()}` }, @@ -101,13 +116,19 @@ export class BlogsService { const createPayload: Prisma.BlogCreateInput = { ...rest, readingTime: readingTime ? Number(readingTime) : 3, - publishedAt: publishedAt ? new Date(publishedAt) : (rest.status === 'PUBLISHED' ? new Date() : null), + publishedAt: publishedAt + ? new Date(publishedAt) + : rest.status === 'PUBLISHED' + ? new Date() + : null, author: { connect: { id: targetAuthorId } }, ...(categoryId ? { category: { connect: { id: categoryId } } } : {}), - ...(tagConnectOrCreate.length > 0 ? { tags: { connectOrCreate: tagConnectOrCreate } } : {}), + ...(tagConnectOrCreate.length > 0 + ? { tags: { connectOrCreate: tagConnectOrCreate } } + : {}), }; - return this.prisma.blog.create({ + const created = await this.prisma.blog.create({ data: createPayload, include: { author: { select: { firstName: true, lastName: true } }, @@ -115,6 +136,9 @@ export class BlogsService { tags: true, }, }); + + await this.revalidationService.revalidateBlog(created.slug); + return created; } async updateBlog(id: string, data: any) { @@ -133,8 +157,12 @@ export class BlogsService { const updatePayload: Prisma.BlogUpdateInput = { ...rest, - ...(readingTime !== undefined ? { readingTime: Number(readingTime) } : {}), - ...(publishedAt !== undefined ? { publishedAt: publishedAt ? new Date(publishedAt) : null } : {}), + ...(readingTime !== undefined + ? { readingTime: Number(readingTime) } + : {}), + ...(publishedAt !== undefined + ? { publishedAt: publishedAt ? new Date(publishedAt) : null } + : {}), ...(authorId ? { author: { connect: { id: authorId } } } : {}), ...(categoryId !== undefined ? categoryId @@ -148,7 +176,10 @@ export class BlogsService { for (const tName of tagNames) { const trimmed = String(tName).trim(); if (trimmed) { - const slug = trimmed.toLowerCase().replace(/\s+/g, '-').replace(/[^\w\u0600-\u06FF-]+/g, ''); + const slug = trimmed + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w\u0600-\u06FF-]+/g, ''); tagConnectOrCreate.push({ where: { name: trimmed }, create: { name: trimmed, slug: slug || `tag-${Date.now()}` }, @@ -161,7 +192,7 @@ export class BlogsService { }; } - return this.prisma.blog.update({ + const updated = await this.prisma.blog.update({ where: { id }, data: updatePayload, include: { @@ -170,18 +201,29 @@ export class BlogsService { tags: true, }, }); + + await this.revalidationService.revalidateBlog(updated.slug); + return updated; } async deleteBlog(id: string) { const blog = await this.prisma.blog.findUnique({ where: { id } }); if (!blog) throw new NotFoundException('Blog not found'); - return this.prisma.blog.delete({ where: { id } }); + const res = await this.prisma.blog.delete({ where: { id } }); + await this.revalidationService.revalidateBlog(blog.slug); + return res; } // Authors (Users who can write) async getAuthors() { return this.prisma.user.findMany({ - select: { id: true, firstName: true, lastName: true, email: true, role: true }, + select: { + id: true, + firstName: true, + lastName: true, + email: true, + role: true, + }, orderBy: { firstName: 'asc' }, }); } @@ -221,7 +263,12 @@ export class BlogsService { } async createBlogTag(data: { name: string; slug?: string }) { - const slug = data.slug || data.name.toLowerCase().replace(/\s+/g, '-').replace(/[^\w\u0600-\u06FF-]+/g, ''); + const slug = + data.slug || + data.name + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w\u0600-\u06FF-]+/g, ''); return this.prisma.blogTag.create({ data: { name: data.name, slug: slug || `tag-${Date.now()}` }, }); @@ -267,14 +314,31 @@ export class BlogsService { } async updateCommentStatus(id: string, status: 'APPROVED' | 'REJECTED') { - const comment = await this.prisma.blogComment.findUnique({ where: { id } }); + const comment = await this.prisma.blogComment.findUnique({ + where: { id }, + include: { blog: { select: { slug: true } } }, + }); if (!comment) throw new NotFoundException('Comment not found'); - return this.prisma.blogComment.update({ where: { id }, data: { status } }); + const updated = await this.prisma.blogComment.update({ + where: { id }, + data: { status }, + }); + if (comment.blog?.slug) { + await this.revalidationService.revalidateBlog(comment.blog.slug); + } + return updated; } async deleteComment(id: string) { - const comment = await this.prisma.blogComment.findUnique({ where: { id } }); + const comment = await this.prisma.blogComment.findUnique({ + where: { id }, + include: { blog: { select: { slug: true } } }, + }); if (!comment) throw new NotFoundException('Comment not found'); - return this.prisma.blogComment.delete({ where: { id } }); + const res = await this.prisma.blogComment.delete({ where: { id } }); + if (comment.blog?.slug) { + await this.revalidationService.revalidateBlog(comment.blog.slug); + } + return res; } } diff --git a/backend/src/admin/dto/product.dto.ts b/backend/src/admin/dto/product.dto.ts index 62a4201..30db37d 100644 --- a/backend/src/admin/dto/product.dto.ts +++ b/backend/src/admin/dto/product.dto.ts @@ -198,7 +198,10 @@ export class ProductDto { @IsArray() symptoms?: string[]; - @ApiPropertyOptional({ description: 'وضعیت موجودی کالا (IN_STOCK, OUT_OF_STOCK, PRE_ORDER, DISCONTINUED)' }) + @ApiPropertyOptional({ + description: + 'وضعیت موجودی کالا (IN_STOCK, OUT_OF_STOCK, PRE_ORDER, DISCONTINUED)', + }) @IsOptional() @IsString() stockStatus?: 'IN_STOCK' | 'OUT_OF_STOCK' | 'PRE_ORDER' | 'DISCONTINUED'; @@ -208,12 +211,16 @@ export class ProductDto { @IsBoolean() noIndex?: boolean; - @ApiPropertyOptional({ description: 'عدم دنبال کردن لینک‌ها توسط موتورهای جستجو' }) + @ApiPropertyOptional({ + description: 'عدم دنبال کردن لینک‌ها توسط موتورهای جستجو', + }) @IsOptional() @IsBoolean() noFollow?: boolean; - @ApiPropertyOptional({ description: 'تصویر کارت شبکه‌های اجتماعی (og:image)' }) + @ApiPropertyOptional({ + description: 'تصویر کارت شبکه‌های اجتماعی (og:image)', + }) @IsOptional() @IsString() ogImage?: string | null; diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 22ab289..7e38a9e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -33,11 +33,13 @@ import { ReviewsModule } from './reviews/reviews.module'; import { DoctorsModule } from './doctors/doctors.module'; import { FaqModule } from './faq/faq.module'; import { MenuModule } from './menu/menu.module'; +import { RevalidationModule } from './common/revalidation/revalidation.module'; import { Request, Response, NextFunction } from 'express'; @Module({ imports: [ + RevalidationModule, SmsModule, ContactModule, PrismaModule, diff --git a/backend/src/blogs/blogs.service.ts b/backend/src/blogs/blogs.service.ts index fa88fa2..9e99b1c 100644 --- a/backend/src/blogs/blogs.service.ts +++ b/backend/src/blogs/blogs.service.ts @@ -29,10 +29,7 @@ export class BlogsService { const whereClause: Prisma.BlogWhereInput = { isPublished: true, status: 'PUBLISHED', - OR: [ - { publishedAt: null }, - { publishedAt: { lte: now } }, - ], + OR: [{ publishedAt: null }, { publishedAt: { lte: now } }], }; if (search) { @@ -67,7 +64,9 @@ export class BlogsService { where: whereClause, skip, take: Number(limit), - orderBy: { [sortBy === 'createdAt' ? 'createdAt' : 'publishedAt']: sortOrder }, + orderBy: { + [sortBy === 'createdAt' ? 'createdAt' : 'publishedAt']: sortOrder, + }, include: { author: { select: { firstName: true, lastName: true }, @@ -184,7 +183,11 @@ export class BlogsService { let relatedBlogs: any[] = []; if (blog.relatedBlogIds && blog.relatedBlogIds.length > 0) { relatedBlogs = await this.prisma.blog.findMany({ - where: { id: { in: blog.relatedBlogIds }, isPublished: true, status: 'PUBLISHED' }, + where: { + id: { in: blog.relatedBlogIds }, + isPublished: true, + status: 'PUBLISHED', + }, select: { id: true, title: true, @@ -219,10 +222,12 @@ export class BlogsService { } // Increment view count asynchronously - await this.prisma.blog.update({ - where: { id: blog.id }, - data: { viewCount: { increment: 1 } }, - }).catch(() => null); + await this.prisma.blog + .update({ + where: { id: blog.id }, + data: { viewCount: { increment: 1 } }, + }) + .catch(() => null); return { ...blog, @@ -237,7 +242,11 @@ export class BlogsService { orderBy: { order: 'asc' }, include: { children: true, - _count: { select: { blogs: { where: { isPublished: true, status: 'PUBLISHED' } } } }, + _count: { + select: { + blogs: { where: { isPublished: true, status: 'PUBLISHED' } }, + }, + }, }, }); } @@ -246,7 +255,11 @@ export class BlogsService { return this.prisma.blogTag.findMany({ orderBy: { createdAt: 'desc' }, include: { - _count: { select: { blogs: { where: { isPublished: true, status: 'PUBLISHED' } } } }, + _count: { + select: { + blogs: { where: { isPublished: true, status: 'PUBLISHED' } }, + }, + }, }, }); } diff --git a/backend/src/common/revalidation/revalidation.module.ts b/backend/src/common/revalidation/revalidation.module.ts new file mode 100644 index 0000000..557f9d6 --- /dev/null +++ b/backend/src/common/revalidation/revalidation.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RevalidationService } from './revalidation.service'; + +@Global() +@Module({ + providers: [RevalidationService], + exports: [RevalidationService], +}) +export class RevalidationModule {} diff --git a/backend/src/common/revalidation/revalidation.service.ts b/backend/src/common/revalidation/revalidation.service.ts new file mode 100644 index 0000000..34af670 --- /dev/null +++ b/backend/src/common/revalidation/revalidation.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class RevalidationService { + private readonly logger = new Logger(RevalidationService.name); + + private get frontendUrl(): string { + const raw = + process.env.FRONTEND_URL || + process.env.NEXT_PUBLIC_SITE_URL || + 'http://localhost:3000'; + return raw.replace(/\/+$/, ''); + } + + private get secret(): string { + return ( + process.env.REVALIDATION_SECRET || 'canina_revalidation_secret_key_2026' + ); + } + + async revalidateTag(tag: string): Promise { + try { + const url = `${this.frontendUrl}/api/revalidate`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-revalidate-secret': this.secret, + }, + body: JSON.stringify({ tag, secret: this.secret }), + }); + + if (!res.ok) { + this.logger.warn( + `Failed to revalidate tag [${tag}] on frontend: ${res.status} ${res.statusText}`, + ); + return false; + } + + this.logger.log(`Successfully revalidated cache tag [${tag}]`); + return true; + } catch (err: any) { + this.logger.debug( + `Revalidation tag request skipped or frontend offline: ${err?.message}`, + ); + return false; + } + } + + async revalidatePath(path: string): Promise { + try { + const url = `${this.frontendUrl}/api/revalidate`; + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-revalidate-secret': this.secret, + }, + body: JSON.stringify({ path, secret: this.secret }), + }); + + if (!res.ok) { + this.logger.warn( + `Failed to revalidate path [${path}] on frontend: ${res.status} ${res.statusText}`, + ); + return false; + } + + this.logger.log(`Successfully revalidated cache path [${path}]`); + return true; + } catch (err: any) { + this.logger.debug( + `Revalidation path request skipped or frontend offline: ${err?.message}`, + ); + return false; + } + } + + async revalidateProduct(slug?: string, artNo?: string): Promise { + const tasks: Promise[] = [ + this.revalidateTag('products'), + this.revalidateTag('home-data'), + this.revalidatePath('/shop'), + this.revalidatePath('/'), + ]; + + if (slug) { + tasks.push(this.revalidateTag(`product-${slug}`)); + tasks.push(this.revalidatePath(`/shop/${slug}`)); + } + if (artNo && artNo !== slug) { + tasks.push(this.revalidateTag(`product-${artNo}`)); + tasks.push(this.revalidatePath(`/shop/${artNo}`)); + } + + await Promise.allSettled(tasks); + } + + async revalidateBlog(slug?: string): Promise { + const tasks: Promise[] = [ + this.revalidateTag('blogs'), + this.revalidateTag('home-data'), + this.revalidatePath('/blog'), + this.revalidatePath('/'), + ]; + + if (slug) { + tasks.push(this.revalidateTag(`blog-${slug}`)); + tasks.push(this.revalidatePath(`/blog/${slug}`)); + } + + await Promise.allSettled(tasks); + } + + async revalidateSettings(): Promise { + await Promise.allSettled([ + this.revalidateTag('settings'), + this.revalidateTag('home-data'), + this.revalidateTag('seo-config'), + this.revalidatePath('/'), + ]); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index b4d2193..dfee4b0 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -8,6 +8,7 @@ import { CustomHttpExceptionFilter } from './common/filters/http-exception.filte import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter'; import { DecimalInterceptor } from './common/interceptors/decimal.interceptor'; import helmet from 'helmet'; +import compression from 'compression'; // Ensure BigInt can be serialized to JSON across all NestJS / Express responses (BigInt.prototype as any).toJSON = function () { @@ -46,6 +47,8 @@ async function bootstrap() { ...staticUploadOptions, }); + app.use(compression()); + app.use( helmet({ contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index aed9d73..7fbc344 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -2,10 +2,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { GetProductsDto } from './dto/get-products.dto'; import { Prisma } from '@prisma/client'; +import { RevalidationService } from '../common/revalidation/revalidation.service'; @Injectable() export class ProductsService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private revalidationService: RevalidationService, + ) {} async findAll(filters: GetProductsDto, userRole?: string) { const { @@ -272,9 +276,14 @@ export class ProductsService { if (!product) { throw new NotFoundException(`Product with ID ${id} not found`); } - return this.prisma.product.update({ + const updated = await this.prisma.product.update({ where: { id }, data: { dosageConfig }, }); + await this.revalidationService.revalidateProduct( + product.slug, + product.artNo, + ); + return updated; } } diff --git a/backend/src/reviews/reviews.service.ts b/backend/src/reviews/reviews.service.ts index 0410ea4..284a84c 100644 --- a/backend/src/reviews/reviews.service.ts +++ b/backend/src/reviews/reviews.service.ts @@ -7,12 +7,16 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { CreateReviewDto } from './dto/create-review.dto'; import { UpdateReviewDto } from './dto/update-review.dto'; +import { RevalidationService } from '../common/revalidation/revalidation.service'; @Injectable() export class ReviewsService { private readonly logger = new Logger(ReviewsService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly revalidationService: RevalidationService, + ) {} /** * 1. Submit a new review for a product (User/Guest) @@ -203,6 +207,7 @@ export class ReviewsService { async updateReviewStatus(id: string, dto: UpdateReviewDto) { const review = await this.prisma.productReview.findUnique({ where: { id }, + include: { product: { select: { slug: true, artNo: true } } }, }); if (!review) { @@ -218,6 +223,13 @@ export class ReviewsService { }, }); + if (review.product) { + await this.revalidationService.revalidateProduct( + review.product.slug, + review.product.artNo, + ); + } + return { success: true, message: 'وضعیت دیدگاه با موفقیت بروزرسانی شد', @@ -231,6 +243,7 @@ export class ReviewsService { async deleteReview(id: string) { const review = await this.prisma.productReview.findUnique({ where: { id }, + include: { product: { select: { slug: true, artNo: true } } }, }); if (!review) { @@ -239,6 +252,13 @@ export class ReviewsService { await this.prisma.productReview.delete({ where: { id } }); + if (review.product) { + await this.revalidationService.revalidateProduct( + review.product.slug, + review.product.artNo, + ); + } + return { success: true, message: 'دیدگاه با موفقیت حذف شد', diff --git a/frontend/application/app/ClientLayout.tsx b/frontend/application/app/ClientLayout.tsx index b33378d..51cf215 100644 --- a/frontend/application/app/ClientLayout.tsx +++ b/frontend/application/app/ClientLayout.tsx @@ -6,11 +6,13 @@ import Footer from "../components/Footer"; import { NetworkBanner } from "../components/NetworkBanner"; import { Toaster, toast } from "sonner"; import { usePathname, useRouter } from 'next/navigation'; +import dynamic from 'next/dynamic'; + +const CartDrawer = dynamic(() => import("../components/CartDrawer"), { ssr: false }); +const LoginModal = dynamic(() => import("../components/LoginModal"), { ssr: false }); +const AuthModal = dynamic(() => import("../components/AuthModal"), { ssr: false }); +const B2BPortal = dynamic(() => import("../components/B2BPortal"), { ssr: false }); -import CartDrawer from "../components/CartDrawer"; -import LoginModal from "../components/LoginModal"; -import AuthModal from "../components/AuthModal"; -import B2BPortal from "../components/B2BPortal"; import { useUserStore } from "../lib/store/userStore"; import { useSettingsStore } from "../lib/store/settingsStore"; import { useUIStore } from "../lib/store/uiStore"; diff --git a/frontend/application/app/api/revalidate/route.ts b/frontend/application/app/api/revalidate/route.ts new file mode 100644 index 0000000..7210b6d --- /dev/null +++ b/frontend/application/app/api/revalidate/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { revalidateTag, revalidatePath } from 'next/cache'; + +export async function GET(request: NextRequest) { + return handleRevalidate(request); +} + +export async function POST(request: NextRequest) { + return handleRevalidate(request); +} + +async function handleRevalidate(request: NextRequest) { + const { searchParams } = new URL(request.url); + let secret = searchParams.get('secret') || request.headers.get('x-revalidate-secret'); + let tag = searchParams.get('tag'); + let path = searchParams.get('path'); + + // Check auth header if bearer token provided + const authHeader = request.headers.get('authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + secret = authHeader.substring(7); + } + + if (request.method === 'POST') { + try { + const body = await request.json().catch(() => ({})); + if (body && typeof body === 'object') { + if (body.secret) secret = body.secret; + if (body.tag) tag = body.tag; + if (body.path) path = body.path; + } + } catch { + // Ignore body read error + } + } + + const expectedSecret = process.env.REVALIDATION_SECRET || 'canina_revalidation_secret_key_2026'; + if (!secret || secret !== expectedSecret) { + return NextResponse.json( + { success: false, message: 'Invalid revalidation secret token' }, + { status: 401 } + ); + } + + if (!tag && !path) { + return NextResponse.json( + { success: false, message: 'Missing tag or path parameter for cache revalidation' }, + { status: 400 } + ); + } + + const revalidated: { tag?: string; path?: string } = {}; + + if (tag) { + revalidateTag(tag, 'default'); + revalidated.tag = tag; + } + + if (path) { + revalidatePath(path); + revalidated.path = path; + } + + return NextResponse.json({ + revalidated: true, + ...revalidated, + now: Date.now(), + }); +} diff --git a/frontend/application/app/blog/[slug]/page.tsx b/frontend/application/app/blog/[slug]/page.tsx index 56c93ea..6b48e0d 100644 --- a/frontend/application/app/blog/[slug]/page.tsx +++ b/frontend/application/app/blog/[slug]/page.tsx @@ -24,7 +24,12 @@ async function getBlog(rawSlug: string) { const slug = decodeURIComponent(rawSlug); const apiUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001/api'; const apiBase = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl.replace(/\/$/, '')}/api`; - const res = await fetch(`${apiBase}/blogs/${encodeURIComponent(slug)}`, { next: { revalidate: 60 } }); + const res = await fetch(`${apiBase}/blogs/${encodeURIComponent(slug)}`, { + next: { + tags: ['blogs', `blog-${slug}`], + revalidate: 3600, + }, + }); if (!res.ok) { console.error(`[Blog] Failed to fetch blog ${slug}: ${res.status}`); return null; @@ -37,7 +42,7 @@ async function getBlog(rawSlug: string) { } } -export const revalidate = 60; +export const revalidate = 3600; export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { const { slug } = await params; @@ -301,9 +306,10 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug: src={blog.imageUrl} alt={blog.imageAlt || blog.title} fill + priority + sizes="(max-width: 1024px) 100vw, 800px" className="w-full h-full object-cover" unoptimized - priority /> {blog.imageCaption && (
diff --git a/frontend/application/app/blog/page.tsx b/frontend/application/app/blog/page.tsx index 35f2ce0..34c171f 100644 --- a/frontend/application/app/blog/page.tsx +++ b/frontend/application/app/blog/page.tsx @@ -10,11 +10,15 @@ export async function generateMetadata(): Promise { }); } +export const revalidate = 3600; + async function getCategories() { const apiUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001/api'; const apiBase = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl.replace(/\/$/, '')}/api`; try { - const res = await fetch(`${apiBase}/blogs/categories`, { next: { revalidate: 60 } }); + const res = await fetch(`${apiBase}/blogs/categories`, { + next: { tags: ['blogs', 'blog-categories'], revalidate: 3600 }, + }); if (!res.ok) return []; const json = await res.json(); return json.data || json || []; @@ -28,7 +32,9 @@ async function getBlogs() { const apiUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4001/api'; const apiBase = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl.replace(/\/$/, '')}/api`; try { - const res = await fetch(`${apiBase}/blogs?limit=50`, { next: { revalidate: 60 } }); + const res = await fetch(`${apiBase}/blogs?limit=50`, { + next: { tags: ['blogs'], revalidate: 3600 }, + }); if (!res.ok) { console.error(`[Blog] Failed to fetch blogs: ${res.status}`); return []; diff --git a/frontend/application/app/globals.css b/frontend/application/app/globals.css index 1b64507..6448099 100644 --- a/frontend/application/app/globals.css +++ b/frontend/application/app/globals.css @@ -70,13 +70,13 @@ @import "tailwindcss"; @theme { - --font-sans: "Vazirmatn"; - --font-vazir: "Vazirmatn"; - --font-shabnam: "Shabnam"; - --font-sahel: "Sahel"; - --font-lalezar: "Lalezar", cursive; + --font-sans: var(--font-vazir), "Vazirmatn", system-ui, sans-serif; + --font-vazir: var(--font-vazir), "Vazirmatn", sans-serif; + --font-shabnam: "Shabnam", sans-serif; + --font-sahel: "Sahel", sans-serif; + --font-lalezar: var(--font-lalezar), "Lalezar", cursive; --font-nastaliq: "IranNastaliq", serif; - --font-iranian-sans: "IranianSans"; + --font-iranian-sans: "IranianSans", sans-serif; /* Brand Colors */ --color-canina-blue: #1E4053; @@ -99,11 +99,11 @@ @layer base { :root { - font-family: "Vazirmatn" !important; + font-family: var(--font-vazir), "Vazirmatn", sans-serif !important; } html, body { - font-family: "Vazirmatn" !important; + font-family: var(--font-vazir), "Vazirmatn", sans-serif !important; @apply bg-medical-gray-50 text-medical-gray-900 antialiased max-w-[100vw] overflow-x-hidden; font-feature-settings: "ss01", "ss02", "cv01", "cv11"; } diff --git a/frontend/application/app/layout.tsx b/frontend/application/app/layout.tsx index 1277999..3e8c2ba 100644 --- a/frontend/application/app/layout.tsx +++ b/frontend/application/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import './globals.css'; import ClientLayout from './ClientLayout'; import { getSeoConfig } from '../lib/seo'; +import { vazirmatn, lalezar } from '../lib/fonts'; export async function generateMetadata(): Promise { const config = await getSeoConfig(); @@ -118,7 +119,7 @@ export default function RootLayout({ const safeJsonLd = JSON.stringify(jsonLd).replace(/ +