Add dock category seed helper and product tag/prep fields.

Ensures chocolate and baklava exist for the website categories dock, and supports tag-filtered product lists for home carousels.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-23 15:44:59 +03:30
co-authored by Cursor
parent d09eca8702
commit 5d1f8991f9
8 changed files with 89 additions and 18 deletions
+2 -1
View File
@@ -24,7 +24,8 @@
"db:up": "docker compose up -d", "db:up": "docker compose up -d",
"db:down": "docker compose down", "db:down": "docker compose down",
"create-super-admin": "ts-node -r tsconfig-paths/register scripts/create-super-admin.ts", "create-super-admin": "ts-node -r tsconfig-paths/register scripts/create-super-admin.ts",
"send-sms": "ts-node -r tsconfig-paths/register scripts/send-sms.ts" "send-sms": "ts-node -r tsconfig-paths/register scripts/send-sms.ts",
"ensure:dock-categories": "ts-node --transpile-only scripts/ensure-dock-categories.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1101.0", "@aws-sdk/client-s3": "^3.1101.0",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "preparationHours" INTEGER;
+1
View File
@@ -165,6 +165,7 @@ model Product {
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict) category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
intro String @default("") intro String @default("")
description String @default("") description String @default("")
preparationHours Int?
mainImageUrl String? mainImageUrl String?
mainImageKey String? mainImageKey String?
tags String[] @default([]) tags String[] @default([])
+38
View File
@@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const cats = [
{ nameFa: 'شکلات', nameEn: 'Chocolate', slug: 'chocolate', sortOrder: 10 },
{ nameFa: 'باقلوا', nameEn: 'Baklava', slug: 'baklava', sortOrder: 11 },
]
async function main() {
for (const cat of cats) {
await prisma.category.upsert({
where: { slug: cat.slug },
create: cat,
update: {
nameFa: cat.nameFa,
nameEn: cat.nameEn,
sortOrder: cat.sortOrder,
},
})
}
const roots = await prisma.category.findMany({
where: { parentId: null },
select: { slug: true, nameFa: true, nameEn: true },
orderBy: { sortOrder: 'asc' },
})
console.log(JSON.stringify(roots, null, 2))
}
main()
.catch((err) => {
console.error(err)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})
+9
View File
@@ -8,6 +8,7 @@ import {
IsString, IsString,
Min, Min,
MinLength, MinLength,
ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
@@ -61,6 +62,14 @@ export class CreateProductDto {
@IsString() @IsString()
description?: string; description?: string;
/** null = ready immediately (no prep time) */
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== undefined)
@Type(() => Number)
@IsInt()
@Min(1)
preparationHours?: number | null;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
@@ -14,6 +14,10 @@ export class ListProductsQueryDto {
@IsString() @IsString()
categorySlug?: string; categorySlug?: string;
@IsOptional()
@IsString()
tag?: string;
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
+9
View File
@@ -8,6 +8,7 @@ import {
IsString, IsString,
Min, Min,
MinLength, MinLength,
ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { import {
@@ -47,6 +48,14 @@ export class UpdateProductDto {
@IsString() @IsString()
description?: string; description?: string;
/** null = ready immediately (no prep time) */
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== undefined)
@Type(() => Number)
@IsInt()
@Min(1)
preparationHours?: number | null;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
+7
View File
@@ -35,6 +35,9 @@ export class ProductsService {
const where = { const where = {
...(categoryIds ? { categoryId: { in: categoryIds } } : {}), ...(categoryIds ? { categoryId: { in: categoryIds } } : {}),
...(query.tag?.trim()
? { tags: { has: query.tag.trim() } }
: {}),
...(query.minPrice !== undefined || query.maxPrice !== undefined ...(query.minPrice !== undefined || query.maxPrice !== undefined
? { ? {
price: { price: {
@@ -100,6 +103,7 @@ export class ProductsService {
sellUnit: dto.sellUnit, sellUnit: dto.sellUnit,
intro: dto.intro ?? '', intro: dto.intro ?? '',
description: dto.description ?? '', description: dto.description ?? '',
preparationHours: dto.preparationHours ?? null,
tags: dto.tags ?? [], tags: dto.tags ?? [],
mainImageUrl: dto.mainImageUrl, mainImageUrl: dto.mainImageUrl,
mainImageKey: dto.mainImageKey, mainImageKey: dto.mainImageKey,
@@ -178,6 +182,9 @@ export class ProductsService {
...(dto.sellUnit !== undefined ? { sellUnit: dto.sellUnit } : {}), ...(dto.sellUnit !== undefined ? { sellUnit: dto.sellUnit } : {}),
...(dto.intro !== undefined ? { intro: dto.intro } : {}), ...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.description !== undefined ? { description: dto.description } : {}), ...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.preparationHours !== undefined
? { preparationHours: dto.preparationHours }
: {}),
...(dto.tags !== undefined ? { tags: dto.tags } : {}), ...(dto.tags !== undefined ? { tags: dto.tags } : {}),
...(dto.mainImageUrl !== undefined ...(dto.mainImageUrl !== undefined
? { mainImageUrl: dto.mainImageUrl } ? { mainImageUrl: dto.mainImageUrl }