From 6517877bedfec4689227d1ec446ddb608d9cecfc Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Tue, 15 Sep 2026 15:35:46 +0330 Subject: [PATCH] Use id+slug paths for user products and count their visits. Align storefront/sitemap URLs with catalog products, add by-id public APIs, and include user-product detail views in the product visit chart. Co-authored-by: Cursor --- .../092_website_page_kind_user_products.sql | 4 + docs/PROJECT_CONTEXT.md | 10 +- docs/website-api/AI_PROMPT.md | 19 +-- ...eshkee-Website-API.postman_collection.json | 20 ++- docs/website-api/index.html | 7 +- docs/website-api/openapi.json | 67 +++++++++- prisma/schema.prisma | 2 + src/sitemap/sitemap-config.util.ts | 13 +- src/sitemap/sitemap.constants.ts | 2 +- src/sitemap/sitemap.service.ts | 20 ++- src/user-products/user-products.module.ts | 2 + .../user-products.public.controller.ts | 44 +++++- src/user-products/user-products.service.ts | 126 +++++++++++++----- .../website-analytics.service.ts | 14 +- .../website-analytics.types.ts | 15 +++ src/website-docs/static/AI_PROMPT.md | 19 +-- ...eshkee-Website-API.postman_collection.json | 20 ++- src/website-docs/static/index.html | 7 +- src/website-docs/static/openapi.json | 67 +++++++++- 19 files changed, 392 insertions(+), 86 deletions(-) create mode 100644 database/migrations/092_website_page_kind_user_products.sql diff --git a/database/migrations/092_website_page_kind_user_products.sql b/database/migrations/092_website_page_kind_user_products.sql new file mode 100644 index 0000000..b80d923 --- /dev/null +++ b/database/migrations/092_website_page_kind_user_products.sql @@ -0,0 +1,4 @@ +-- Track public customer marketplace (user product) list/detail page views. + +ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_list'; +ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_detail'; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index cc6c32b..886b8eb 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -274,7 +274,7 @@ All routes are prefixed with `/api/v1`. | GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` | | GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instruction/{slug}` | | GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` | -| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{slug}` (module `customer_products`) | +| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{id}/{slug}` (module `customer_products`) | | GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` | | POST | `/tenants/:host/torob_api/v3/products` | Torob Product API v3 (JWT). Requires store module + `settings.store.torobEnabled`. Nginx: `POST https://{host}/torob_api/v3/products` | | GET | `/tenants/:host/categories/by-id/:categoryId` | Public category by id (for category landing pages) | @@ -363,9 +363,11 @@ Base: `/tenants/:host/user-products` | Method | Path | Description | |--------|------|-------------| -| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) | -| GET | `/:slug` | Details + gallery (`images`, `galleryMediaIds`) + technical values | -| GET | `/:slug/technical-info` | Category technical form + values | +| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination); items include `pathSlug` | +| GET | `/by-id/:id` | Details by id (preferred for `/user-products/{id}/{pathSlug}` pages) | +| GET | `/by-id/:id/technical-info` | Category technical form + values by id | +| GET | `/:slug` | Details by DB slug (legacy) | +| GET | `/:slug/technical-info` | Category technical form + values by slug (legacy) | #### Cart (customer — JWT, must be business customer) diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index fdb09d8..6a1a5d1 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -172,9 +172,11 @@ Nginx on the shop apex still proxies bank callbacks (`https:///m ### User products (customer listings) Public marketplace listings owned by customers — not catalog `products`. -- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) -- `GET /tenants/{domain}/user-products/{slug}` — details + gallery (`technicalValues` = values only, no labels) -- `GET /tenants/{domain}/user-products/{slug}/technical-info` — **required for specs UI**: `{ form: { fields: [{ id, label, key, type, ... }] }, values: [...] }` +- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination). Each item includes `id`, `pathSlug`, `titleFa`/`titleEn`. +- `GET /tenants/{domain}/user-products/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only) +- `GET /tenants/{domain}/user-products/by-id/{id}/technical-info` — specs labels + values by id +- `GET /tenants/{domain}/user-products/{slug}` — legacy resolve by DB slug (still supported) +- `GET /tenants/{domain}/user-products/{slug}/technical-info` — legacy specs by slug 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. ### Technical details / specs table (catalog products + user products) @@ -183,7 +185,7 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product | Detail page | Specs endpoint (labels + values) | |-------------|----------------------------------| | `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` | -| `GET .../user-products/{slug}` | `GET .../user-products/{slug}/technical-info` | +| `GET .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) | **How to render:** 1. Call `technical-info` (same slug/id as the detail page). @@ -196,12 +198,13 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product **Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response. ### Analytics (dashboard charts) -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. +Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, user-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) +- `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`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted) - Events kept ~6 months for charts; lifetime totals kept forever in counters. +- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`. ### Static images Named slots the business dashboard can replace. Fetch once per page: @@ -274,13 +277,13 @@ Minimal example: - **Canonical detail URLs (Meshkee default for all sites):** - Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts). - Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`. - - User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`. + - User product (customer listing): `/user-products/{id}/{pathSlug}` — use `id` + `pathSlug` from the list/detail API (or slugify `titleFa` / `titleEn`); resolve via `GET /tenants/{domain}/user-products/by-id/{id}` (slug segment is SEO-only). - Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id. - Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`. - Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`. - Video: `/videos/{slug}` — use the video’s `slug` from the API. - Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API. -- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`. +- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{id}/{pathSlug}`. - Omit `templates` in `sitemap-config.json` unless this site uses non-default paths. - **Sitemap files (proxied by nginx — do not ship local copies):** - `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules) diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 79e7b04..dec8ee1 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -1354,11 +1354,27 @@ } } }, + { + "name": "Get user product by id", + "request": { + "method": "GET", + "description": "Preferred for /user-products/{id}/{pathSlug} pages. technicalValues are values only — use technical-info for specs UI.", + "url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}" + } + }, + { + "name": "Get user product technical info by id", + "request": { + "method": "GET", + "description": "Required for technical-details UI when page is resolved by id: form.fields (labels) + values.", + "url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}/technical-info" + } + }, { "name": "Get user product by slug", "request": { "method": "GET", - "description": "Listing detail + gallery. technicalValues are values only (fieldId + text/option) — no labels. Use technical-info for specs UI.", + "description": "Legacy slug resolve. Prefer by-id for storefront pages. technicalValues are values only (fieldId + text/option) — no labels.", "url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}" } }, @@ -1366,7 +1382,7 @@ "name": "Get user product technical info by slug", "request": { "method": "GET", - "description": "Required for technical-details UI: form.fields (labels) + values. Join field.id ↔ value.fieldId. No separate category-variation-fields public endpoint.", + "description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.", "url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info" } } diff --git a/docs/website-api/index.html b/docs/website-api/index.html index 180b0a4..c1ad26b 100644 --- a/docs/website-api/index.html +++ b/docs/website-api/index.html @@ -143,16 +143,17 @@

User products (customer listings)

Marketplace-style stock listings created by customers. Public read-only under - /tenants/{domain}/user-products (list / details / technical-info). + /tenants/{domain}/user-products (list / by-id / details / technical-info). + Storefront URLs: /user-products/{id}/{pathSlug}. See OpenAPI tag User Products.

Technical details: detail responses include technicalValues with values only (no field labels). For a label→value specs table, call - GET /tenants/{domain}/user-products/{slug}/technical-info + GET /tenants/{domain}/user-products/by-id/{id}/technical-info (catalog products: - .../products/{slug}/technical-info) and join + .../products/by-id/{id}/technical-info) and join form.fields[].id ↔ values[].fieldId. There is no public product-category-variation-fields route.

diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index dd732cc..4c29c8d 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -39,7 +39,7 @@ }, { "name": "User Products", - "description": "Customer marketplace listings. Detail returns `technicalValues` without labels; use `GET .../user-products/{slug}/technical-info` for form field labels + values." + "description": "Customer marketplace listings. Prefer `GET .../user-products/by-id/{id}` for storefront pages. Detail returns `technicalValues` without labels; use `.../technical-info` for form field labels + values." }, { "name": "Store" @@ -434,7 +434,7 @@ "SEO" ], "summary": "User products sitemap urlset", - "description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.", + "description": "Published customer marketplace listings. Default paths: `/user-products/{id}/{slug}` (slug from titleFa/titleEn). Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -659,6 +659,8 @@ "blog_detail", "video_list", "video_detail", + "user_product_list", + "user_product_detail", "website", "product", "portfolio", @@ -668,7 +670,7 @@ }, "entityId": { "type": "string", - "description": "Required for *_detail kinds (product, portfolio, blog, video, store item)." + "description": "Required for *_detail kinds (product, user product, portfolio, blog, video, store item)." }, "path": { "type": "string", @@ -1262,7 +1264,64 @@ ], "responses": { "200": { - "description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt." + "description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, pathSlug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt." + } + } + } + }, + "/tenants/{domain}/user-products/by-id/{productId}": { + "get": { + "tags": [ + "User Products" + ], + "summary": "User product by id (preferred for /user-products/{id}/{pathSlug} pages)", + "description": "Full published listing resolved by id. Prefer this for storefront detail pages — the path slug segment is SEO-only.\n\n**Important:** `technicalValues` items are **values only**. For a label→value table, call `GET /tenants/{domain}/user-products/by-id/{productId}/technical-info`.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ product } with gallery, pathSlug, technicalValues (values only), countryId, cityId" + }, + "404": { + "description": "Not found or not published" + } + } + } + }, + "/tenants/{domain}/user-products/by-id/{productId}/technical-info": { + "get": { + "tags": [ + "User Products" + ], + "summary": "User product technical form + values by id", + "description": "**Use this for the listing specs UI** when the page is resolved by id. Same shape as the slug technical-info route.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ form: { id, categoryId, fields: [...] } | null, values: [...] }" } } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 93c20a7..39f601c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1745,6 +1745,8 @@ enum WebsitePageKind { blog_detail video_list video_detail + user_product_list + user_product_detail @@map("website_page_kind") } diff --git a/src/sitemap/sitemap-config.util.ts b/src/sitemap/sitemap-config.util.ts index 35155de..dcc280d 100644 --- a/src/sitemap/sitemap-config.util.ts +++ b/src/sitemap/sitemap-config.util.ts @@ -111,9 +111,16 @@ export function resolveSitemapConfig( DEFAULT_SITEMAP_PATH_TEMPLATES.instruction, workshop: stored?.templates?.workshop ?? DEFAULT_SITEMAP_PATH_TEMPLATES.workshop, - userProduct: - stored?.templates?.userProduct ?? - DEFAULT_SITEMAP_PATH_TEMPLATES.userProduct, + userProduct: resolveUserProductTemplate(stored?.templates?.userProduct), }, }; } + +/** Prefer id+slug paths; upgrade legacy slug-only defaults still stored on tenants. */ +function resolveUserProductTemplate(stored: string | undefined): string { + const raw = stored?.trim(); + if (!raw || raw === '/user-products/{slug}') { + return DEFAULT_SITEMAP_PATH_TEMPLATES.userProduct; + } + return raw; +} diff --git a/src/sitemap/sitemap.constants.ts b/src/sitemap/sitemap.constants.ts index a8cf3f0..79c61ec 100644 --- a/src/sitemap/sitemap.constants.ts +++ b/src/sitemap/sitemap.constants.ts @@ -11,7 +11,7 @@ export const DEFAULT_SITEMAP_PATH_TEMPLATES = { video: '/videos/{slug}', instruction: '/instruction/{slug}', workshop: '/workshops/{slug}', - userProduct: '/user-products/{slug}', + userProduct: '/user-products/{id}/{slug}', } as const; export const WEBSITE_SITEMAP_CONFIG_PATH = '/meshkee/sitemap-config.json'; diff --git a/src/sitemap/sitemap.service.ts b/src/sitemap/sitemap.service.ts index c56770b..657d028 100644 --- a/src/sitemap/sitemap.service.ts +++ b/src/sitemap/sitemap.service.ts @@ -479,19 +479,27 @@ export class SitemapService { }, select: { id: true, - slug: true, + title: true, + content: true, updatedAt: true, }, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], }); - return rows.map((row) => - this.toEntry(baseUrl, template, { + return rows.map((row) => { + const content = this.asRecord(row.content); + const titleEn = + typeof content.titleEn === 'string' ? content.titleEn.trim() : ''; + const pathSlug = slugifyForUrl( + row.title?.trim() || titleEn, + 'user-product', + ); + return this.toEntry(baseUrl, template, { id: row.id, - slug: row.slug, + slug: pathSlug, updatedAt: row.updatedAt, - }), - ); + }); + }); } private toEntry( diff --git a/src/user-products/user-products.module.ts b/src/user-products/user-products.module.ts index ff9fb20..1f15b03 100644 --- a/src/user-products/user-products.module.ts +++ b/src/user-products/user-products.module.ts @@ -4,6 +4,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 { UserProductsAdminController } from './user-products.admin.controller'; import { UserProductsController } from './user-products.controller'; import { PublicUserProductsController } from './user-products.public.controller'; @@ -16,6 +17,7 @@ import { UserProductsService } from './user-products.service'; MediaModule, TenantModule, SitemapModule, + WebsiteAnalyticsModule, ], controllers: [ UserProductsController, diff --git a/src/user-products/user-products.public.controller.ts b/src/user-products/user-products.public.controller.ts index 82d0326..e60d208 100644 --- a/src/user-products/user-products.public.controller.ts +++ b/src/user-products/user-products.public.controller.ts @@ -1,17 +1,45 @@ import { Controller, Get, Param, Query } from '@nestjs/common'; import { ListPublicUserProductsDto } from './dto/user-product.dto'; import { UserProductsService } from './user-products.service'; +import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service'; @Controller('tenants/:host/user-products') export class PublicUserProductsController { - constructor(private readonly service: UserProductsService) {} + constructor( + private readonly service: UserProductsService, + private readonly analytics: WebsiteAnalyticsService, + ) {} @Get() - list( + async list( @Param('host') host: string, @Query() query: ListPublicUserProductsDto, ) { - return this.service.listPublic(host, query); + const result = await this.service.listPublic(host, query); + this.analytics.trackPublicPage(host, 'user_product_list'); + return result; + } + + @Get('by-id/:productId/technical-info') + getTechnicalInfoById( + @Param('host') host: string, + @Param('productId') productId: string, + ) { + return this.service.getPublicTechnicalInfoById(host, productId); + } + + @Get('by-id/:productId') + async getById( + @Param('host') host: string, + @Param('productId') productId: string, + ) { + const result = await this.service.getPublicById(host, productId); + this.analytics.trackPublicPage( + host, + 'user_product_detail', + result.product.id, + ); + return result; } @Get(':slug/technical-info') @@ -20,7 +48,13 @@ export class PublicUserProductsController { } @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, + 'user_product_detail', + result.product.id, + ); + return result; } } diff --git a/src/user-products/user-products.service.ts b/src/user-products/user-products.service.ts index 66ca33a..1449e4f 100644 --- a/src/user-products/user-products.service.ts +++ b/src/user-products/user-products.service.ts @@ -32,14 +32,12 @@ import { } from './dto/user-product.dto'; import { TenantService } from '../tenant/tenant.service'; import { SitemapService } from '../sitemap/sitemap.service'; +import { slugifyForUrl } from '../sitemap/seo-slug.util'; -function slugify(value: string): string { - return ( - value - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'user-product' +function buildUserProductSlug(titleFa: string, titleEn?: string | null): string { + return slugifyForUrl( + titleFa.trim() || titleEn?.trim() || '', + 'user-product', ); } @@ -168,11 +166,9 @@ export class UserProductsService { async getPublicBySlug(host: string, slug: string) { const business = await this.tenant.resolveBusinessByDomain(host); - const businessId = business.id; - const product = await this.prisma.userProduct.findFirst({ where: { - businessId, + businessId: business.id, slug, status: ContentStatus.published, }, @@ -183,6 +179,81 @@ export class UserProductsService { throw new NotFoundException('User product not found'); } + return this.serializePublicDetail(business.id, product); + } + + async getPublicById(host: string, productIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + let productId: bigint; + try { + productId = BigInt(productIdRaw); + } catch { + throw new NotFoundException('User product not found'); + } + + const product = await this.prisma.userProduct.findFirst({ + where: { + businessId: business.id, + id: productId, + status: ContentStatus.published, + }, + include: userProductDetailInclude, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + return this.serializePublicDetail(business.id, product); + } + + async getPublicTechnicalInfoBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const product = await this.prisma.userProduct.findFirst({ + where: { + businessId: business.id, + slug, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + return this.getPublicTechnicalInfoForProduct(business.id, product.id); + } + + async getPublicTechnicalInfoById(host: string, productIdRaw: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + let productId: bigint; + try { + productId = BigInt(productIdRaw); + } catch { + throw new NotFoundException('User product not found'); + } + + const product = await this.prisma.userProduct.findFirst({ + where: { + businessId: business.id, + id: productId, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + return this.getPublicTechnicalInfoForProduct(business.id, product.id); + } + + private async serializePublicDetail( + businessId: bigint, + product: UserProductDetailRow, + ) { const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ product.id, ]); @@ -197,27 +268,14 @@ export class UserProductsService { }; } - async getPublicTechnicalInfoBySlug(host: string, slug: string) { - const business = await this.tenant.resolveBusinessByDomain(host); - const businessId = business.id; - - const product = await this.prisma.userProduct.findFirst({ - where: { - businessId, - slug, - status: ContentStatus.published, - }, - select: { id: true }, - }); - - if (!product) { - throw new NotFoundException('User product not found'); - } - + private async getPublicTechnicalInfoForProduct( + businessId: bigint, + productId: bigint, + ) { const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ - product.id, + productId, ]); - const category = categoryByEntity.get(product.id.toString()); + const category = categoryByEntity.get(productId.toString()); if (!category) { return { form: null, @@ -235,7 +293,7 @@ export class UserProductsService { } const detail = await this.prisma.userProduct.findFirst({ - where: { id: product.id }, + where: { id: productId }, include: userProductDetailInclude, }); if (!detail) { @@ -337,7 +395,10 @@ export class UserProductsService { const technicalValues = dto.technicalValues ?? []; this.validateTechnicalValues(form?.fields ?? [], technicalValues); - const slug = await this.ensureUniqueSlug(businessId, slugify(titleFa)); + const slug = await this.ensureUniqueSlug( + businessId, + buildUserProductSlug(titleFa, dto.titleEn), + ); const priceCurrency = dto.priceCurrency ?? 'IRT'; const content: Prisma.InputJsonValue = { ...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}), @@ -542,7 +603,7 @@ export class UserProductsService { if (titleFa !== existing.title) { slug = await this.ensureUniqueSlug( businessId, - slugify(titleFa), + buildUserProductSlug(titleFa, dto.titleEn), productId, ); } @@ -1272,6 +1333,7 @@ export class UserProductsService { return { id: product.id.toString(), slug: product.slug, + pathSlug: buildUserProductSlug(product.title, titleEn), title: product.title, titleFa: product.title, titleEn, diff --git a/src/website-analytics/website-analytics.service.ts b/src/website-analytics/website-analytics.service.ts index eee6f9f..d83b566 100644 --- a/src/website-analytics/website-analytics.service.ts +++ b/src/website-analytics/website-analytics.service.ts @@ -21,6 +21,7 @@ import type { RecordWebsiteViewDto } from './dto/record-view.dto'; import { normalizeWebsitePageKind, pageKindRequiresEntity, + DAILY_VIEW_KIND_EXPAND, VIEW_SUMMARY_GROUP_IDS, VIEW_SUMMARY_GROUP_KINDS, type ViewPeriodCounts, @@ -129,7 +130,9 @@ export class WebsiteAnalyticsService { COUNT(*)::int AS count FROM website_page_views WHERE business_id = ${businessId} - AND page_kind = ${kind}::website_page_kind + AND page_kind::text IN (${Prisma.join( + [...(DAILY_VIEW_KIND_EXPAND[kind!] ?? [kind!])], + )}) AND viewed_at >= ${from} GROUP BY 1 ORDER BY 1 @@ -382,6 +385,15 @@ export class WebsiteAnalyticsService { throw new NotFoundException('Store item not found'); } + if (kind === 'user_product_detail') { + const userProduct = await this.prisma.userProduct.findFirst({ + where: { id: entityId, businessId }, + select: { id: true }, + }); + if (!userProduct) throw new NotFoundException('User product not found'); + return entityId; + } + return entityId; } diff --git a/src/website-analytics/website-analytics.types.ts b/src/website-analytics/website-analytics.types.ts index c5d858f..7d8fad9 100644 --- a/src/website-analytics/website-analytics.types.ts +++ b/src/website-analytics/website-analytics.types.ts @@ -10,6 +10,8 @@ export const WEBSITE_PAGE_KINDS = [ 'blog_detail', 'video_list', 'video_detail', + 'user_product_list', + 'user_product_detail', ] as const; export type WebsitePageKind = (typeof WEBSITE_PAGE_KINDS)[number]; @@ -23,8 +25,19 @@ const DETAIL_KINDS = new Set([ 'store_item_detail', 'blog_detail', 'video_detail', + 'user_product_detail', ]); +/** + * When a dashboard chart asks for one kind, also count these siblings. + * Product visit chart includes catalog products + customer marketplace listings. + */ +export const DAILY_VIEW_KIND_EXPAND: Partial< + Record +> = { + product_detail: ['product_detail', 'user_product_detail'], +}; + /** Legacy chart / POST aliases → canonical page kinds. */ const KIND_ALIASES: Record = { website: 'home', @@ -80,6 +93,8 @@ export const VIEW_SUMMARY_GROUP_KINDS: Record< 'product_detail', 'store_item_list', 'store_item_detail', + 'user_product_list', + 'user_product_detail', ], blog: ['blog_list', 'blog_detail'], portfolio: ['portfolio_list', 'portfolio_detail'], diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index fdb09d8..6a1a5d1 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -172,9 +172,11 @@ Nginx on the shop apex still proxies bank callbacks (`https:///m ### User products (customer listings) Public marketplace listings owned by customers — not catalog `products`. -- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) -- `GET /tenants/{domain}/user-products/{slug}` — details + gallery (`technicalValues` = values only, no labels) -- `GET /tenants/{domain}/user-products/{slug}/technical-info` — **required for specs UI**: `{ form: { fields: [{ id, label, key, type, ... }] }, values: [...] }` +- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination). Each item includes `id`, `pathSlug`, `titleFa`/`titleEn`. +- `GET /tenants/{domain}/user-products/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only) +- `GET /tenants/{domain}/user-products/by-id/{id}/technical-info` — specs labels + values by id +- `GET /tenants/{domain}/user-products/{slug}` — legacy resolve by DB slug (still supported) +- `GET /tenants/{domain}/user-products/{slug}/technical-info` — legacy specs by slug 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. ### Technical details / specs table (catalog products + user products) @@ -183,7 +185,7 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product | Detail page | Specs endpoint (labels + values) | |-------------|----------------------------------| | `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` | -| `GET .../user-products/{slug}` | `GET .../user-products/{slug}/technical-info` | +| `GET .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) | **How to render:** 1. Call `technical-info` (same slug/id as the detail page). @@ -196,12 +198,13 @@ Use product categories from `GET /tenants/{domain}/categories?entityType=product **Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response. ### Analytics (dashboard charts) -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. +Page views are recorded **automatically** when the website calls the normal public list/detail APIs (blogs, products, user-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) +- `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`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted) - Events kept ~6 months for charts; lifetime totals kept forever in counters. +- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`. ### Static images Named slots the business dashboard can replace. Fetch once per page: @@ -274,13 +277,13 @@ Minimal example: - **Canonical detail URLs (Meshkee default for all sites):** - Product: `/products/{id}/{nameFaSlug}` — build `nameFaSlug` from `nameFa` (fallback `title`); resolve page via `GET /tenants/{domain}/products/by-id/{id}` (slug segment is SEO-only; redirect to canonical if it drifts). - Product category: `/products/category/{categoryId}/{nameFaSlug}` — build slug from `nameFa` (fallback `name`); resolve via `GET /tenants/{domain}/categories/by-id/{id}`, then list products with `categoryId`. - - User product (customer listing): `/user-products/{slug}` — use the listing’s `slug` from the API; resolve via `GET /tenants/{domain}/user-products/{slug}`. + - User product (customer listing): `/user-products/{id}/{pathSlug}` — use `id` + `pathSlug` from the list/detail API (or slugify `titleFa` / `titleEn`); resolve via `GET /tenants/{domain}/user-products/by-id/{id}` (slug segment is SEO-only). - Blog: `/blog/{slug}` — use the blog’s `slug` from the API; resolve via `GET /tenants/{domain}/blogs/{slug}` (or list + match). Prefer slug routes over id. - Portfolio: `/portfolio/{slug}` — use the portfolio’s `slug` from the API; resolve via `GET /tenants/{domain}/portfolios/{slug}`. - Instruction: `/instruction/{slug}` — use the instruction’s `slug` from the API; resolve via `GET /tenants/{domain}/instructions/{slug}`. - Video: `/videos/{slug}` — use the video’s `slug` from the API. - Workshop: `/workshops/{slug}` — use the workshop’s `slug` from the API. -- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{slug}`. +- When linking from lists/cards, use the same slug-based detail paths. Category links use `/products/category/{id}/{nameFaSlug}`. User-product cards use `/user-products/{id}/{pathSlug}`. - Omit `templates` in `sitemap-config.json` unless this site uses non-default paths. - **Sitemap files (proxied by nginx — do not ship local copies):** - `/sitemap.xml` — sitemap **index** (lists child sitemaps for enabled modules) diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index 79e7b04..dec8ee1 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -1354,11 +1354,27 @@ } } }, + { + "name": "Get user product by id", + "request": { + "method": "GET", + "description": "Preferred for /user-products/{id}/{pathSlug} pages. technicalValues are values only — use technical-info for specs UI.", + "url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}" + } + }, + { + "name": "Get user product technical info by id", + "request": { + "method": "GET", + "description": "Required for technical-details UI when page is resolved by id: form.fields (labels) + values.", + "url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}/technical-info" + } + }, { "name": "Get user product by slug", "request": { "method": "GET", - "description": "Listing detail + gallery. technicalValues are values only (fieldId + text/option) — no labels. Use technical-info for specs UI.", + "description": "Legacy slug resolve. Prefer by-id for storefront pages. technicalValues are values only (fieldId + text/option) — no labels.", "url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}" } }, @@ -1366,7 +1382,7 @@ "name": "Get user product technical info by slug", "request": { "method": "GET", - "description": "Required for technical-details UI: form.fields (labels) + values. Join field.id ↔ value.fieldId. No separate category-variation-fields public endpoint.", + "description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.", "url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info" } } diff --git a/src/website-docs/static/index.html b/src/website-docs/static/index.html index 180b0a4..c1ad26b 100644 --- a/src/website-docs/static/index.html +++ b/src/website-docs/static/index.html @@ -143,16 +143,17 @@

User products (customer listings)

Marketplace-style stock listings created by customers. Public read-only under - /tenants/{domain}/user-products (list / details / technical-info). + /tenants/{domain}/user-products (list / by-id / details / technical-info). + Storefront URLs: /user-products/{id}/{pathSlug}. See OpenAPI tag User Products.

Technical details: detail responses include technicalValues with values only (no field labels). For a label→value specs table, call - GET /tenants/{domain}/user-products/{slug}/technical-info + GET /tenants/{domain}/user-products/by-id/{id}/technical-info (catalog products: - .../products/{slug}/technical-info) and join + .../products/by-id/{id}/technical-info) and join form.fields[].id ↔ values[].fieldId. There is no public product-category-variation-fields route.

diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index dd732cc..4c29c8d 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -39,7 +39,7 @@ }, { "name": "User Products", - "description": "Customer marketplace listings. Detail returns `technicalValues` without labels; use `GET .../user-products/{slug}/technical-info` for form field labels + values." + "description": "Customer marketplace listings. Prefer `GET .../user-products/by-id/{id}` for storefront pages. Detail returns `technicalValues` without labels; use `.../technical-info` for form field labels + values." }, { "name": "Store" @@ -434,7 +434,7 @@ "SEO" ], "summary": "User products sitemap urlset", - "description": "Published customer marketplace listings. Default paths: `/user-products/{slug}`. Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.", + "description": "Published customer marketplace listings. Default paths: `/user-products/{id}/{slug}` (slug from titleFa/titleEn). Included in the index when the `customer_products` module is enabled. Override path via sitemap-config `templates.userProduct`.", "parameters": [ { "$ref": "#/components/parameters/domain" @@ -659,6 +659,8 @@ "blog_detail", "video_list", "video_detail", + "user_product_list", + "user_product_detail", "website", "product", "portfolio", @@ -668,7 +670,7 @@ }, "entityId": { "type": "string", - "description": "Required for *_detail kinds (product, portfolio, blog, video, store item)." + "description": "Required for *_detail kinds (product, user product, portfolio, blog, video, store item)." }, "path": { "type": "string", @@ -1262,7 +1264,64 @@ ], "responses": { "200": { - "description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt." + "description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, pathSlug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt." + } + } + } + }, + "/tenants/{domain}/user-products/by-id/{productId}": { + "get": { + "tags": [ + "User Products" + ], + "summary": "User product by id (preferred for /user-products/{id}/{pathSlug} pages)", + "description": "Full published listing resolved by id. Prefer this for storefront detail pages — the path slug segment is SEO-only.\n\n**Important:** `technicalValues` items are **values only**. For a label→value table, call `GET /tenants/{domain}/user-products/by-id/{productId}/technical-info`.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ product } with gallery, pathSlug, technicalValues (values only), countryId, cityId" + }, + "404": { + "description": "Not found or not published" + } + } + } + }, + "/tenants/{domain}/user-products/by-id/{productId}/technical-info": { + "get": { + "tags": [ + "User Products" + ], + "summary": "User product technical form + values by id", + "description": "**Use this for the listing specs UI** when the page is resolved by id. Same shape as the slug technical-info route.", + "parameters": [ + { + "$ref": "#/components/parameters/domain" + }, + { + "name": "productId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "{ form: { id, categoryId, fields: [...] } | null, values: [...] }" } } }