From ad6f9dcf1d351d5506e2621150bf35d09e208da0 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 23 Aug 2026 16:47:46 +0330 Subject: [PATCH] Expose portfolio projectUrl and document it for websites. Wire create/update/list/detail to project_url and sync website OpenAPI, Postman, and AI brief. Co-authored-by: Cursor --- docs/website-api/AI_PROMPT.md | 2 +- .../Meshkee-Website-API.postman_collection.json | 10 ++++++++++ docs/website-api/openapi.json | 6 +++--- src/portfolios/dto/portfolio.dto.ts | 8 ++++++++ src/portfolios/portfolios.service.ts | 10 ++++++++++ src/website-docs/static/AI_PROMPT.md | 2 +- .../static/Meshkee-Website-API.postman_collection.json | 10 ++++++++++ src/website-docs/static/openapi.json | 6 +++--- 8 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index e0bb8c5..2205368 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -31,7 +31,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`) 2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`) -3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`). +3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`). 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 9a5da17..2121d56 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -906,6 +906,7 @@ ], "request": { "method": "GET", + "description": "Published portfolios (newest / highest sortOrder first). Items include nullable `projectUrl` (external website link).", "url": { "raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12", "host": [ @@ -948,9 +949,18 @@ "name": "Get published portfolio by slug (website)", "request": { "method": "GET", + "description": "Portfolio detail. Includes nullable `projectUrl` (external website link).", "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}" } }, + { + "name": "Get published portfolio by id (website)", + "request": { + "method": "GET", + "description": "Preferred for /portfolios/{id}/{titleFaSlug} pages. Includes nullable `projectUrl`.", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/by-id/{{portfolioId}}" + } + }, { "name": "List portfolio comments (website)", "request": { diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 485585f..a7a8180 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -1512,7 +1512,7 @@ ], "responses": { "200": { - "description": "{ items, total, page, pageSize }" + "description": "{ items, total, page, pageSize }. Each item includes id, title/titleFa/titleEn, slug, abstract, projectUrl (nullable website link), status, categoryId/categoryName, tags, titleImageUrl, gallery, sortOrder, publishedAt, createdAt, updatedAt. Ordered by sortOrder desc, then publishedAt/createdAt desc." } } } @@ -1538,7 +1538,7 @@ ], "responses": { "200": { - "description": "{ portfolio }" + "description": "{ portfolio } — same fields as list items, plus mainTextHtml, comments, and commentCount. Includes nullable projectUrl (external website link)." } } } @@ -1564,7 +1564,7 @@ ], "responses": { "200": { - "description": "{ portfolio }" + "description": "{ portfolio } — same fields as list items, plus mainTextHtml, comments, and commentCount. Includes nullable projectUrl (external website link)." } } } diff --git a/src/portfolios/dto/portfolio.dto.ts b/src/portfolios/dto/portfolio.dto.ts index 0ca3b17..feb96ca 100644 --- a/src/portfolios/dto/portfolio.dto.ts +++ b/src/portfolios/dto/portfolio.dto.ts @@ -88,6 +88,10 @@ export class CreatePortfolioDto { @IsString() abstract?: string; + @IsOptional() + @IsString() + projectUrl?: string; + @IsOptional() @IsString() mainTextHtml?: string; @@ -146,6 +150,10 @@ export class UpdatePortfolioDto { @IsString() abstract?: string | null; + @IsOptional() + @IsString() + projectUrl?: string | null; + @IsOptional() @IsString() mainTextHtml?: string | null; diff --git a/src/portfolios/portfolios.service.ts b/src/portfolios/portfolios.service.ts index ab4bc11..9243f1a 100644 --- a/src/portfolios/portfolios.service.ts +++ b/src/portfolios/portfolios.service.ts @@ -159,6 +159,7 @@ export class PortfoliosService { slug, description: dto.abstract?.trim() || null, content: this.buildContent(dto.mainTextHtml) as Prisma.InputJsonValue, + project_url: this.normalizeProjectUrl(dto.projectUrl), status, featured_media_id: featuredMediaId, sort_order: sortOrder, @@ -300,6 +301,9 @@ export class PortfoliosService { if (dto.abstract !== undefined) { data.description = dto.abstract?.trim() || null; } + if (dto.projectUrl !== undefined) { + data.project_url = this.normalizeProjectUrl(dto.projectUrl); + } if (dto.status !== undefined) { data.status = dto.status; } @@ -743,6 +747,7 @@ export class PortfoliosService { titleEn: portfolio.title_en?.trim() || null, slug: portfolio.slug, abstract: portfolio.description ?? '', + projectUrl: portfolio.project_url?.trim() || null, mainTextHtml: (content.html as string | undefined) ?? '', status: portfolio.status, categoryId: categoryAssignment?.categoryId.toString() ?? null, @@ -835,6 +840,11 @@ export class PortfoliosService { }; } + private normalizeProjectUrl(value?: string | null): string | null { + const trimmed = value?.trim() || ''; + return trimmed.length > 0 ? trimmed : null; + } + private async nextSortOrder(businessId: bigint): Promise { const result = await this.prisma.portfolios.aggregate({ where: { business_id: businessId }, diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index e0bb8c5..2205368 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -31,7 +31,7 @@ You are building a **Meshkee business website (storefront)**. You must use the M ### Typical bootstrap sequence 1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`) 2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials (`source` repeats the tenant setting; items are store listings when `source` is `store_item`) -3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`). +3. Catalog: categories, products (`GET /products/{slug}` includes `relatedProducts`: same category then same brand, in-stock first), store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings). Portfolios list newest first (`sortOrder` desc, then `publishedAt` / `createdAt` desc); each portfolio may include nullable `projectUrl` (external website link). List filters: products/blogs/portfolios/videos accept `?tag=` (exact match on `metadata.tags`). 4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens). 5. Cart checkout with `addressId` or inline `shippingAddress` + `payment` - For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl` 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 9a5da17..2121d56 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -906,6 +906,7 @@ ], "request": { "method": "GET", + "description": "Published portfolios (newest / highest sortOrder first). Items include nullable `projectUrl` (external website link).", "url": { "raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12", "host": [ @@ -948,9 +949,18 @@ "name": "Get published portfolio by slug (website)", "request": { "method": "GET", + "description": "Portfolio detail. Includes nullable `projectUrl` (external website link).", "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}" } }, + { + "name": "Get published portfolio by id (website)", + "request": { + "method": "GET", + "description": "Preferred for /portfolios/{id}/{titleFaSlug} pages. Includes nullable `projectUrl`.", + "url": "{{baseUrl}}/tenants/{{domain}}/portfolios/by-id/{{portfolioId}}" + } + }, { "name": "List portfolio comments (website)", "request": { diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 485585f..a7a8180 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -1512,7 +1512,7 @@ ], "responses": { "200": { - "description": "{ items, total, page, pageSize }" + "description": "{ items, total, page, pageSize }. Each item includes id, title/titleFa/titleEn, slug, abstract, projectUrl (nullable website link), status, categoryId/categoryName, tags, titleImageUrl, gallery, sortOrder, publishedAt, createdAt, updatedAt. Ordered by sortOrder desc, then publishedAt/createdAt desc." } } } @@ -1538,7 +1538,7 @@ ], "responses": { "200": { - "description": "{ portfolio }" + "description": "{ portfolio } — same fields as list items, plus mainTextHtml, comments, and commentCount. Includes nullable projectUrl (external website link)." } } } @@ -1564,7 +1564,7 @@ ], "responses": { "200": { - "description": "{ portfolio }" + "description": "{ portfolio } — same fields as list items, plus mainTextHtml, comments, and commentCount. Includes nullable projectUrl (external website link)." } } }