Add store-item Excel export/import for bulk price and stock updates.

Dashboard can download grouped product variants and re-upload to update price and stock. Also expose Content-Disposition for downloads and keep related product/customer API docs in sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 01:02:43 +03:30
co-authored by Cursor
parent 4a5780e74d
commit 7277817b6e
17 changed files with 1553 additions and 52 deletions
+3 -1
View File
@@ -1,7 +1,7 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
> Last updated: August 14, 2026
> Last updated: August 18, 2026
## What This Project Is
@@ -504,6 +504,8 @@ PATCH /businesses/:businessId/store-items/variants/:variantId
}
```
**Bulk Excel (dashboard):** `GET /businesses/:businessId/store-items/export` downloads one row per variant (`variant_id`, names, variation, `price`, `stock`). `POST /businesses/:businessId/store-items/import` (multipart `file`) updates only price and stock. Empty price = call for price; empty stock = unlimited. Does not add or remove variants.
### Technical forms (product categories)
- One form per product category
+1 -1
View File
@@ -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`
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
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)
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`
@@ -1197,6 +1197,7 @@
"name": "Get published product by slug",
"request": {
"method": "GET",
"description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).",
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
}
},
+1 -1
View File
@@ -449,7 +449,7 @@
],
"responses": {
"200": {
"description": "{ product }"
"description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
}
}
}
+806 -5
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,6 +23,7 @@
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"exceljs": "^4.4.0",
"ioredis": "^5.4.1",
"mysql2": "^3.23.2",
"passport": "^0.7.0",
@@ -3,6 +3,7 @@ import {
ExecutionContext,
Injectable,
NestInterceptor,
StreamableFile,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
@@ -15,6 +16,14 @@ function serializeBigInt(value: unknown): unknown {
return value.toISOString();
}
if (value instanceof StreamableFile) {
return value;
}
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
return value;
}
if (Array.isArray(value)) {
return value.map(serializeBigInt);
}
+19
View File
@@ -18,6 +18,8 @@ type CustomerListRow = {
cellNumber: string;
firstName: string | null;
lastName: string | null;
firstNameEn: string | null;
lastNameEn: string | null;
email: string | null;
createdAt: Date;
isEnabled: boolean;
@@ -63,7 +65,10 @@ export class CustomersService {
AND (
u.first_name ILIKE ${nameLike}
OR u.last_name ILIKE ${nameLike}
OR u.first_name_en ILIKE ${nameLike}
OR u.last_name_en ILIKE ${nameLike}
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike}
)
` : Prisma.empty}
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty}
@@ -76,6 +81,8 @@ export class CustomersService {
u.cell_number AS "cellNumber",
u.first_name AS "firstName",
u.last_name AS "lastName",
u.first_name_en AS "firstNameEn",
u.last_name_en AS "lastNameEn",
u.email AS "email",
COALESCE(bc.created_at, bu.created_at) AS "createdAt",
COALESCE(bc.is_enabled, TRUE) AS "isEnabled",
@@ -109,6 +116,8 @@ export class CustomersService {
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
firstNameEn: user.firstNameEn,
lastNameEn: user.lastNameEn,
email: user.email,
label: this.formatLabel(user),
createdAt: user.createdAt.toISOString(),
@@ -335,6 +344,8 @@ export class CustomersService {
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
firstNameEn: user.firstNameEn,
lastNameEn: user.lastNameEn,
email: user.email,
label: this.formatLabel(user),
createdAt: membership.link.createdAt.toISOString(),
@@ -375,6 +386,8 @@ export class CustomersService {
const hasProfileUpdate =
dto.firstName !== undefined ||
dto.lastName !== undefined ||
dto.firstNameEn !== undefined ||
dto.lastNameEn !== undefined ||
dto.email !== undefined ||
dto.cellNumber !== undefined;
@@ -395,6 +408,10 @@ export class CustomersService {
data: {
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
firstNameEn:
dto.firstNameEn !== undefined ? dto.firstNameEn.trim() || null : undefined,
lastNameEn:
dto.lastNameEn !== undefined ? dto.lastNameEn.trim() || null : undefined,
email: dto.email !== undefined ? dto.email.trim() || null : undefined,
cellNumber: dto.cellNumber?.trim(),
},
@@ -416,6 +433,8 @@ export class CustomersService {
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
firstNameEn: user.firstNameEn,
lastNameEn: user.lastNameEn,
email: user.email,
label: this.formatLabel(user),
isEnabled: refreshed.isEnabled,
+24 -1
View File
@@ -1,4 +1,11 @@
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
import {
IsBoolean,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateIf,
} from 'class-validator';
export class UpdateCustomerDto {
@IsOptional()
@@ -8,13 +15,29 @@ export class UpdateCustomerDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
lastName?: string;
@IsOptional()
@IsString()
@ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0)
@MinLength(2)
@MaxLength(100)
firstNameEn?: string;
@IsOptional()
@IsString()
@ValidateIf((_, value) => typeof value === 'string' && value.trim().length > 0)
@MinLength(2)
@MaxLength(100)
lastNameEn?: string;
@IsOptional()
@IsString()
email?: string;
+3 -1
View File
@@ -24,7 +24,9 @@ async function bootstrap() {
}),
);
app.useGlobalInterceptors(new BigIntSerializerInterceptor());
app.enableCors();
app.enableCors({
exposedHeaders: ['Content-Disposition'],
});
const port = process.env.PORT ?? 3000;
await app.listen(port);
+157 -39
View File
@@ -30,6 +30,20 @@ type ProductWithRelations = Prisma.ProductGetPayload<{
};
}>;
const productListInclude = {
featuredMedia: true,
brand: { include: { imageMedia: true } },
} satisfies Prisma.ProductInclude;
const RELATED_PRODUCTS_LIMIT = 8;
const emptyStoreSummary = {
variantCount: 0,
minPrice: null as number | null,
maxPrice: null as number | null,
inStock: false,
};
@Injectable()
export class ProductsService {
constructor(
@@ -137,28 +151,7 @@ export class ProductsService {
this.prisma.product.count({ where }),
]);
const storeSummaries = await this.loadStoreSummariesForProducts(
businessId,
items.map((item) => item.id),
);
const serialized = await Promise.all(
items.map(async (item) => {
const product = await this.serializeProduct(item, undefined, {
approvedCommentsOnly: true,
});
const store = storeSummaries.get(item.id.toString());
return {
...product,
store: store ?? {
variantCount: 0,
minPrice: null,
maxPrice: null,
inStock: false,
},
};
}),
);
const serialized = await this.serializePublicListItems(businessId, items);
return { items: serialized, total, page, pageSize };
}
@@ -173,31 +166,21 @@ export class ProductsService {
slug,
status: ContentStatus.published,
},
include: {
featuredMedia: true,
brand: { include: { imageMedia: true } },
},
include: productListInclude,
});
if (!product) {
throw new NotFoundException('Product not found');
}
const storeSummaries = await this.loadStoreSummariesForProducts(businessId, [product.id]);
const serialized = await this.serializeProduct(product, undefined, {
approvedCommentsOnly: true,
});
const [serializedList, relatedProducts] = await Promise.all([
this.serializePublicListItems(businessId, [product]),
this.loadRelatedPublicProducts(businessId, product),
]);
return {
product: {
...serialized,
store: storeSummaries.get(product.id.toString()) ?? {
variantCount: 0,
minPrice: null,
maxPrice: null,
inStock: false,
},
},
product: serializedList[0],
relatedProducts,
};
}
@@ -697,6 +680,141 @@ export class ProductsService {
};
}
private async serializePublicListItems(
businessId: bigint,
items: ProductWithRelations[],
) {
const storeSummaries = await this.loadStoreSummariesForProducts(
businessId,
items.map((item) => item.id),
);
return Promise.all(
items.map(async (item) => {
const product = await this.serializeProduct(item, undefined, {
approvedCommentsOnly: true,
});
return {
...product,
store: storeSummaries.get(item.id.toString()) ?? { ...emptyStoreSummary },
};
}),
);
}
/**
* Related products for the details page.
* Relativity: same category first, then same brand.
* Availability: in-stock (unlimited or qty > 0) before out-of-stock within each group.
*/
private async loadRelatedPublicProducts(
businessId: bigint,
source: ProductWithRelations,
limit = RELATED_PRODUCTS_LIMIT,
) {
const collected: ProductWithRelations[] = [];
const seen = new Set<string>([source.id.toString()]);
const remaining = () => limit - collected.length;
const append = (items: ProductWithRelations[]) => {
for (const item of items) {
if (collected.length >= limit) {
break;
}
const id = item.id.toString();
if (seen.has(id)) {
continue;
}
seen.add(id);
collected.push(item);
}
};
const fetchPool = async (
extraWhere: Prisma.ProductWhereInput,
take: number,
) => {
if (take <= 0) {
return [];
}
return this.prisma.product.findMany({
where: {
businessId,
status: ContentStatus.published,
AND: [{ id: { not: source.id } }, extraWhere],
},
orderBy: [{ sortOrder: 'asc' }, { publishedAt: 'desc' }, { createdAt: 'desc' }],
take,
include: productListInclude,
});
};
const inStockWhere = this.publishedInStockWhere();
const outOfStockWhere: Prisma.ProductWhereInput = { NOT: inStockWhere };
const assignment = await this.prisma.categoryAssignment.findFirst({
where: {
businessId,
entityType: MediaEntityType.product,
entityId: source.id,
},
select: { categoryId: true },
});
if (assignment) {
const categoryRows = await this.prisma.categoryAssignment.findMany({
where: {
businessId,
entityType: MediaEntityType.product,
categoryId: assignment.categoryId,
entityId: { not: source.id },
},
select: { entityId: true },
});
const categoryIds = categoryRows.map((row) => row.entityId);
if (categoryIds.length > 0) {
const categoryWhere: Prisma.ProductWhereInput = { id: { in: categoryIds } };
const [inStock, outOfStock] = await Promise.all([
fetchPool({ AND: [categoryWhere, inStockWhere] }, limit),
fetchPool({ AND: [categoryWhere, outOfStockWhere] }, limit),
]);
append(inStock);
append(outOfStock);
}
}
if (source.brandId && remaining() > 0) {
const brandWhere: Prisma.ProductWhereInput = { brandId: source.brandId };
const take = remaining();
const [inStock, outOfStock] = await Promise.all([
fetchPool({ AND: [brandWhere, inStockWhere] }, take),
fetchPool({ AND: [brandWhere, outOfStockWhere] }, take),
]);
append(inStock);
append(outOfStock);
}
return this.serializePublicListItems(businessId, collected);
}
/** Active store variant with unlimited stock (null) or quantity > 0. */
private publishedInStockWhere(): Prisma.ProductWhereInput {
return {
storeItem: {
some: {
isActive: true,
variants: {
some: {
isActive: true,
OR: [{ stockQuantity: null }, { stockQuantity: { gt: 0 } }],
},
},
},
},
};
}
private async loadStoreSummariesForProducts(
businessId: bigint,
productIds: bigint[],
+335
View File
@@ -0,0 +1,335 @@
import { BadRequestException } from '@nestjs/common';
import ExcelJS from 'exceljs';
export const STORE_ITEMS_SHEET_NAME = 'Store items';
export const STORE_ITEM_EXCEL_HEADERS = {
variantId: 'variant_id',
product: 'product',
variation: 'variation',
price: 'price',
stock: 'stock',
} as const;
type HeaderKey = keyof typeof STORE_ITEM_EXCEL_HEADERS;
const HEADER_ALIASES: Record<string, HeaderKey> = {
variant_id: 'variantId',
'variant id': 'variantId',
id: 'variantId',
'شناسه تنوع': 'variantId',
product: 'product',
product_en: 'product',
product_fa: 'product',
variation: 'variation',
price: 'price',
'قیمت': 'price',
stock: 'stock',
stock_quantity: 'stock',
'موجودی': 'stock',
};
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
export interface StoreItemExcelRow {
variantId: string;
productId: string;
productEn: string;
productFa: string;
variation: string;
price: number | null;
stock: number | null;
}
export interface ParsedStoreItemExcelRow {
rowNumber: number;
variantId: string;
price: number | null;
stock: number | null;
}
export async function buildStoreItemsExcel(rows: StoreItemExcelRow[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Meshkee';
workbook.created = new Date();
const sheet = workbook.addWorksheet(STORE_ITEMS_SHEET_NAME);
sheet.columns = [
{ header: STORE_ITEM_EXCEL_HEADERS.variantId, key: 'variantId', width: 10 },
{ header: STORE_ITEM_EXCEL_HEADERS.product, key: 'product', width: 52 },
{ header: STORE_ITEM_EXCEL_HEADERS.variation, key: 'variation', width: 36 },
{ header: STORE_ITEM_EXCEL_HEADERS.price, key: 'price', width: 14 },
{ header: STORE_ITEM_EXCEL_HEADERS.stock, key: 'stock', width: 8 },
{ width: 3 },
{ header: 'دستورالعمل', width: 48 },
{ header: 'Instructions', width: 48 },
];
const header = sheet.getRow(1);
header.font = { bold: true };
header.alignment = { vertical: 'middle' };
for (let col = 1; col <= 5; col += 1) {
header.getCell(col).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8EEF7' },
};
}
const groups = groupRowsByProduct(rows);
groups.forEach((group, index) => {
const first = group[0];
if (!first) return;
const title = sheet.addRow({
variantId: '',
product: formatProductName(first.productFa, first.productEn),
variation: '',
price: null,
stock: null,
});
title.height = 32;
for (let col = 1; col <= 5; col += 1) {
const cell = title.getCell(col);
cell.font = { bold: true, size: 14 };
cell.alignment = { vertical: 'middle', readingOrder: 'ltr' };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF3F3F3' },
};
}
for (const row of group) {
const added = sheet.addRow({
variantId: row.variantId,
product: '',
variation: row.variation,
price: row.price,
stock: row.stock,
});
added.getCell('variantId').numFmt = '@';
added.getCell('price').numFmt = '#,##0';
added.getCell('stock').numFmt = '0';
}
if (index < groups.length - 1) {
sheet.addRow([]);
}
});
writeInstructionColumns(sheet);
sheet.views = [{ state: 'frozen', ySplit: 1, rightToLeft: false }];
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
export async function parseStoreItemsExcel(buffer: Buffer): Promise<ParsedStoreItemExcelRow[]> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as unknown as Parameters<ExcelJS.Xlsx['load']>[0]);
const sheet =
workbook.getWorksheet(STORE_ITEMS_SHEET_NAME) ?? workbook.worksheets[0];
if (!sheet) {
throw new BadRequestException('Excel file has no worksheet.');
}
const headerMap = readHeaderMap(sheet);
const variantCol = headerMap.variantId;
const priceCol = headerMap.price;
const stockCol = headerMap.stock;
if (!variantCol || !priceCol || !stockCol) {
throw new BadRequestException(
'Excel is missing required columns: variant_id, price, stock.',
);
}
const rows: ParsedStoreItemExcelRow[] = [];
const seenIds = new Set<string>();
sheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return;
const variantId = cellText(row.getCell(variantCol).value);
if (!variantId) return;
if (seenIds.has(variantId)) {
throw new BadRequestException(
`Row ${rowNumber}: duplicate variant_id "${variantId}".`,
);
}
seenIds.add(variantId);
const price = parseOptionalNumber(row.getCell(priceCol).value, rowNumber, 'price');
const stock = parseOptionalInteger(row.getCell(stockCol).value, rowNumber, 'stock');
rows.push({ rowNumber, variantId, price, stock });
});
if (rows.length === 0) {
throw new BadRequestException('Excel has no store item rows to import.');
}
if (rows.length > 50_000) {
throw new BadRequestException('Excel has too many rows (max 50,000).');
}
return rows;
}
function writeInstructionColumns(sheet: ExcelJS.Worksheet) {
const faLines = [
'نام محصول، سپس تنوع‌ها، سپس یک ردیف خالی.',
'فقط ستون‌های price و stock را در ردیف تنوع ویرایش کنید.',
'ستون variant_id را تغییر ندهید.',
'قیمت خالی = تماس برای قیمت.',
'موجودی خالی = نامحدود. موجودی ۰ = ناموجود.',
'این فایل محصولی اضافه یا حذف نمی‌کند.',
];
const enLines = [
'Product name, then its variations, then a blank row.',
'Edit only price and stock on variation rows.',
'Do not change variant_id.',
'Empty price = call for price.',
'Empty stock = unlimited. Stock 0 = out of stock.',
'This file does not add or remove products.',
];
faLines.forEach((line, index) => {
const row = sheet.getRow(index + 2);
row.getCell(7).value = line;
row.getCell(7).alignment = { wrapText: true, vertical: 'top', readingOrder: 'rtl' };
row.getCell(8).value = enLines[index] ?? '';
row.getCell(8).alignment = { wrapText: true, vertical: 'top', readingOrder: 'ltr' };
});
}
function groupRowsByProduct(rows: StoreItemExcelRow[]): StoreItemExcelRow[][] {
const groups = new Map<string, StoreItemExcelRow[]>();
const order: string[] = [];
for (const row of rows) {
const key = row.productId || `${row.productEn}\n${row.productFa}`;
const group = groups.get(key);
if (!group) {
groups.set(key, [row]);
order.push(key);
} else {
group.push(row);
}
}
return order.map((key) => groups.get(key) ?? []);
}
export function storeItemsExcelFilename(now = new Date()) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now);
const pick = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((part) => part.type === type)?.value ?? '';
const date = `${pick('year')}-${pick('month')}-${pick('day')}`;
const time = `${pick('hour')}-${pick('minute')}`;
return `store items - ${date} - ${time}.xlsx`;
}
function formatProductName(productFa: string, productEn: string) {
const fa = productFa.trim();
const en = productEn.trim();
if (fa && en) return `\u200E${fa} - ${en}`;
return fa || en;
}
function readHeaderMap(sheet: ExcelJS.Worksheet): Partial<Record<HeaderKey, number>> {
const headerRow = sheet.getRow(1);
const map: Partial<Record<HeaderKey, number>> = {};
headerRow.eachCell((cell, colNumber) => {
const key = HEADER_ALIASES[normalizeHeader(cellText(cell.value))];
if (key && !map[key]) {
map[key] = colNumber;
}
});
return map;
}
function normalizeHeader(value: string) {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
function cellText(value: ExcelJS.CellValue): string {
if (value == null || value === '') return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
if (value instanceof Date) return '';
if (typeof value === 'object' && 'text' in value && typeof value.text === 'string') {
return value.text.trim();
}
if (typeof value === 'object' && 'richText' in value && Array.isArray(value.richText)) {
return value.richText.map((part) => part.text).join('').trim();
}
if (typeof value === 'object' && 'result' in value) {
return cellText(value.result as ExcelJS.CellValue);
}
return String(value).trim();
}
function parseOptionalNumber(
value: ExcelJS.CellValue,
rowNumber: number,
field: string,
): number | null {
if (value == null || value === '') return null;
if (typeof value === 'number') {
if (!Number.isFinite(value) || value < 0) {
throw new BadRequestException(`Row ${rowNumber}: ${field} must be 0 or greater.`);
}
return value;
}
const text = normalizeNumericText(cellText(value));
if (!text) return null;
const parsed = Number(text);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new BadRequestException(`Row ${rowNumber}: ${field} must be a number 0 or greater.`);
}
return parsed;
}
function parseOptionalInteger(
value: ExcelJS.CellValue,
rowNumber: number,
field: string,
): number | null {
const parsed = parseOptionalNumber(value, rowNumber, field);
if (parsed == null) return null;
if (!Number.isInteger(parsed)) {
throw new BadRequestException(`Row ${rowNumber}: ${field} must be a whole number.`);
}
return parsed;
}
function normalizeNumericText(value: string) {
const withLatinDigits = [...value]
.map((char) => {
const index = PERSIAN_DIGITS.indexOf(char);
return index >= 0 ? String(index) : char;
})
.join('');
return withLatinDigits.replace(/,/g, '').replace(/\s+/g, '').trim();
}
+53 -1
View File
@@ -1,4 +1,21 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { AuthUser } from '../auth/auth.types';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
@@ -14,6 +31,7 @@ import {
UpdateStoreItemVariantDto,
} from './dto/store-items.dto';
import { StoreItemsService } from './store-items.service';
import { storeItemsExcelFilename } from './store-items-excel';
@Controller('businesses/:businessId/store-items')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
@@ -30,6 +48,40 @@ export class StoreItemsController {
return this.service.list(businessId, query, user);
}
@Get('export')
@RequireBusinessPermission('products.read')
async exportExcel(
@Param('businessId') businessId: string,
@CurrentUser() user: AuthUser,
) {
const buffer = await this.service.exportExcel(businessId, user);
const filename = storeItemsExcelFilename();
return new StreamableFile(buffer, {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
disposition: `attachment; filename="${filename}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
length: buffer.length,
});
}
@Post('import')
@RequireBusinessPermission('products.update')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 8 * 1024 * 1024 },
}),
)
importExcel(
@Param('businessId') businessId: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUser,
) {
if (!file) {
throw new BadRequestException('Excel file is required.');
}
return this.service.importExcel(businessId, file, user);
}
@Get('by-product/:productId')
@RequireBusinessPermission('products.read')
getByProduct(
+137
View File
@@ -19,6 +19,7 @@ import {
SyncProductStoreItemVariantsDto,
UpdateStoreItemVariantDto,
} from './dto/store-items.dto';
import { buildStoreItemsExcel, parseStoreItemsExcel } from './store-items-excel';
const variantInclude = {
storeItem: {
@@ -42,6 +43,12 @@ type VariantWithRelations = Prisma.StoreItemVariantGetPayload<{
include: typeof variantInclude;
}>;
function sameNullableNumber(a: number | null, b: number | null) {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
return Math.abs(a - b) < 0.005;
}
@Injectable()
export class StoreItemsService {
constructor(
@@ -268,6 +275,136 @@ export class StoreItemsService {
};
}
async exportExcel(businessIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'products.read');
const variants = await this.prisma.storeItemVariant.findMany({
where: { businessId },
orderBy: [{ createdAt: 'desc' }],
include: variantInclude,
});
const rows = variants.map((variant) => {
const product = variant.storeItem.product;
const content = this.asRecord(product.content);
const variation = variant.selections
.map((selection) => `${selection.variation.name}: ${selection.option.label}`)
.join(' · ');
return {
variantId: variant.id.toString(),
productId: product.id.toString(),
productEn: product.title,
productFa: (content.nameFa as string | null | undefined) ?? '',
variation,
price: variant.price === null ? null : Number(variant.price),
stock: variant.stockQuantity,
};
});
return buildStoreItemsExcel(rows);
}
async importExcel(businessIdRaw: string, file: Express.Multer.File | undefined, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
if (!file?.buffer?.length) {
throw new BadRequestException('Excel file is required.');
}
const name = file.originalname?.toLowerCase() ?? '';
if (name && !name.endsWith('.xlsx')) {
throw new BadRequestException('Please upload an .xlsx Excel file.');
}
const rows = await parseStoreItemsExcel(file.buffer);
const ids = rows.map((row) => {
try {
return BigInt(row.variantId);
} catch {
throw new BadRequestException(
`Row ${row.rowNumber}: variant_id "${row.variantId}" is not valid.`,
);
}
});
const existing = await this.prisma.storeItemVariant.findMany({
where: { businessId, id: { in: ids } },
select: {
id: true,
price: true,
stockQuantity: true,
compareAtPrice: true,
},
});
const existingById = new Map(existing.map((item) => [item.id.toString(), item]));
const missing = rows.filter((row) => !existingById.has(row.variantId));
if (missing.length > 0) {
const preview = missing
.slice(0, 8)
.map((row) => `Row ${row.rowNumber}: variant_id "${row.variantId}" was not found.`)
.join(' ');
throw new BadRequestException(
missing.length === 1
? preview
: `${missing.length} unknown variant_id values. No changes applied. ${preview}`,
);
}
const changes = rows.flatMap((row) => {
const current = existingById.get(row.variantId);
if (!current) return [];
const currentPrice = current.price === null ? null : Number(current.price);
const priceChanged = !sameNullableNumber(currentPrice, row.price);
const stockChanged = current.stockQuantity !== row.stock;
if (!priceChanged && !stockChanged) return [];
const compareAt =
current.compareAtPrice === null ? null : Number(current.compareAtPrice);
const clearCompareAt =
compareAt !== null &&
(row.price === null || compareAt >= row.price);
return [
{
id: current.id,
price: row.price,
stockQuantity: row.stock,
clearCompareAt,
},
];
});
if (changes.length > 0) {
await this.prisma.$transaction(
async (tx) => {
for (const item of changes) {
await tx.storeItemVariant.update({
where: { id: item.id },
data: {
price: item.price,
stockQuantity: item.stockQuantity,
...(item.clearCompareAt ? { compareAtPrice: null } : {}),
},
});
}
},
{ timeout: 120_000, maxWait: 15_000 },
);
}
return {
message: 'Store item prices and stock updated from Excel',
total: rows.length,
updated: changes.length,
unchanged: rows.length - changes.length,
};
}
async batchCreateVariants(
businessIdRaw: string,
dto: BatchCreateStoreItemsDto,
+1 -1
View File
@@ -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`
2. Homepage: business-info, **static-images**, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items (`name` instant search: in-stock first, then `updatedAt`), **user-products** (customer stock listings)
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)
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`
@@ -1197,6 +1197,7 @@
"name": "Get published product by slug",
"request": {
"method": "GET",
"description": "Product details plus relatedProducts (same category then same brand; in-stock first within each group).",
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}"
}
},
+1 -1
View File
@@ -449,7 +449,7 @@
],
"responses": {
"200": {
"description": "{ product }"
"description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
}
}
}