From 835c8a0ac12fd66e7e433866e9fe2fce38a2303d Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 27 Sep 2026 18:30:36 +0330 Subject: [PATCH] Add public catalog APIs, website slots, featured slots, and int category/product IDs. Co-authored-by: Cursor --- .../migration.sql | 5 + .../migration.sql | 30 +++ .../migration.sql | 5 + .../migration.sql | 92 +++++++++ .../migration.sql | 154 +++++++++++++++ prisma/schema.prisma | 51 ++++- scripts/fill-category-children.ts | 130 +++++++++++++ scripts/fill-women-categories.ts | 113 +++++++++++ src/app.module.ts | 2 + src/categories/categories.controller.ts | 8 +- src/categories/categories.service.ts | 26 ++- src/categories/dto/category.dto.ts | 47 ++++- src/media/dto/media-upload-query.dto.ts | 1 + src/media/media.service.ts | 3 + src/products/dto/create-product.dto.ts | 27 ++- src/products/products.controller.ts | 22 ++- src/products/products.service.ts | 20 +- src/public/public.controller.ts | 36 +++- src/public/public.service.ts | 158 +++++++++++++++- src/storage/storage-keys.ts | 4 + src/website-slots/dto/website-slot.dto.ts | 85 +++++++++ src/website-slots/website-slot.catalog.ts | 174 +++++++++++++++++ src/website-slots/website-slots.controller.ts | 59 ++++++ src/website-slots/website-slots.module.ts | 11 ++ src/website-slots/website-slots.service.ts | 177 ++++++++++++++++++ 25 files changed, 1399 insertions(+), 41 deletions(-) create mode 100644 prisma/migrations/20260926120000_category_technical_fields/migration.sql create mode 100644 prisma/migrations/20260926150000_website_slot_items/migration.sql create mode 100644 prisma/migrations/20260927170000_product_featured_slot/migration.sql create mode 100644 prisma/migrations/20260927180000_category_int_id/migration.sql create mode 100644 prisma/migrations/20260927190000_product_int_id/migration.sql create mode 100644 scripts/fill-category-children.ts create mode 100644 scripts/fill-women-categories.ts create mode 100644 src/website-slots/dto/website-slot.dto.ts create mode 100644 src/website-slots/website-slot.catalog.ts create mode 100644 src/website-slots/website-slots.controller.ts create mode 100644 src/website-slots/website-slots.module.ts create mode 100644 src/website-slots/website-slots.service.ts diff --git a/prisma/migrations/20260926120000_category_technical_fields/migration.sql b/prisma/migrations/20260926120000_category_technical_fields/migration.sql new file mode 100644 index 0000000..00d7298 --- /dev/null +++ b/prisma/migrations/20260926120000_category_technical_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Category" ADD COLUMN "technicalFields" JSONB NOT NULL DEFAULT '[]'; + +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "technicalData" JSONB NOT NULL DEFAULT '{}'; diff --git a/prisma/migrations/20260926150000_website_slot_items/migration.sql b/prisma/migrations/20260926150000_website_slot_items/migration.sql new file mode 100644 index 0000000..2373d4f --- /dev/null +++ b/prisma/migrations/20260926150000_website_slot_items/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "WebsiteSlotItem" ( + "id" TEXT NOT NULL, + "storeId" TEXT NOT NULL, + "slotKey" TEXT NOT NULL, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "nameFa" TEXT NOT NULL DEFAULT '', + "nameEn" TEXT NOT NULL DEFAULT '', + "subtext" TEXT NOT NULL DEFAULT '', + "linkText" TEXT NOT NULL DEFAULT '', + "link" TEXT NOT NULL DEFAULT '', + "imageUrl" TEXT, + "imageKey" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "WebsiteSlotItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "WebsiteSlotItem_storeId_idx" ON "WebsiteSlotItem"("storeId"); + +-- CreateIndex +CREATE INDEX "WebsiteSlotItem_storeId_slotKey_idx" ON "WebsiteSlotItem"("storeId", "slotKey"); + +-- CreateIndex +CREATE INDEX "WebsiteSlotItem_storeId_slotKey_sortOrder_idx" ON "WebsiteSlotItem"("storeId", "slotKey", "sortOrder"); + +-- AddForeignKey +ALTER TABLE "WebsiteSlotItem" ADD CONSTRAINT "WebsiteSlotItem_storeId_fkey" FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260927170000_product_featured_slot/migration.sql b/prisma/migrations/20260927170000_product_featured_slot/migration.sql new file mode 100644 index 0000000..05959b5 --- /dev/null +++ b/prisma/migrations/20260927170000_product_featured_slot/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Product" ADD COLUMN "featuredSlot" TEXT; + +-- CreateIndex +CREATE INDEX "Product_featuredSlot_idx" ON "Product"("featuredSlot"); diff --git a/prisma/migrations/20260927180000_category_int_id/migration.sql b/prisma/migrations/20260927180000_category_int_id/migration.sql new file mode 100644 index 0000000..32cbb3d --- /dev/null +++ b/prisma/migrations/20260927180000_category_int_id/migration.sql @@ -0,0 +1,92 @@ +-- Convert Category.id (cuid text) → serial integer; remap Product.categoryId + parentId. + +ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_categoryId_fkey"; +ALTER TABLE "Category" DROP CONSTRAINT IF EXISTS "Category_parentId_fkey"; +ALTER TABLE "Category" DROP CONSTRAINT IF EXISTS "Category_storeId_fkey"; + +DROP INDEX IF EXISTS "Category_storeId_idx"; +DROP INDEX IF EXISTS "Category_parentId_idx"; +DROP INDEX IF EXISTS "Category_sortOrder_idx"; +DROP INDEX IF EXISTS "Product_categoryId_idx"; + +CREATE TABLE "_category_id_map" ( + "old_id" TEXT PRIMARY KEY, + "new_id" INTEGER NOT NULL UNIQUE +); + +INSERT INTO "_category_id_map" ("old_id", "new_id") +SELECT "id", ROW_NUMBER() OVER (ORDER BY "id")::INTEGER +FROM "Category"; + +CREATE TABLE "Category_new" ( + "id" INTEGER NOT NULL, + "storeId" TEXT NOT NULL, + "parentId" INTEGER, + "nameFa" TEXT NOT NULL, + "nameEn" TEXT NOT NULL DEFAULT '', + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "colors" TEXT[] DEFAULT ARRAY[]::TEXT[], + "sizes" TEXT[] DEFAULT ARRAY[]::TEXT[], + "packages" TEXT[] DEFAULT ARRAY[]::TEXT[], + "technicalFields" JSONB NOT NULL DEFAULT '[]', + CONSTRAINT "Category_new_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "Category_new" ( + "id", "storeId", "parentId", "nameFa", "nameEn", "sortOrder", + "colors", "sizes", "packages", "technicalFields" +) +SELECT + m."new_id", + c."storeId", + mp."new_id", + c."nameFa", + c."nameEn", + c."sortOrder", + c."colors", + c."sizes", + c."packages", + COALESCE(c."technicalFields", '[]'::jsonb) +FROM "Category" c +JOIN "_category_id_map" m ON m."old_id" = c."id" +LEFT JOIN "_category_id_map" mp ON mp."old_id" = c."parentId"; + +ALTER TABLE "Product" ADD COLUMN "categoryId_new" INTEGER; + +UPDATE "Product" AS p +SET "categoryId_new" = m."new_id" +FROM "_category_id_map" AS m +WHERE p."categoryId" = m."old_id"; + +ALTER TABLE "Product" DROP COLUMN "categoryId"; +ALTER TABLE "Product" RENAME COLUMN "categoryId_new" TO "categoryId"; + +DROP TABLE "Category"; +ALTER TABLE "Category_new" RENAME TO "Category"; +ALTER TABLE "Category" RENAME CONSTRAINT "Category_new_pkey" TO "Category_pkey"; + +CREATE SEQUENCE "Category_id_seq" OWNED BY "Category"."id"; +SELECT setval( + '"Category_id_seq"', + COALESCE((SELECT MAX("id") FROM "Category"), 1) +); +ALTER TABLE "Category" ALTER COLUMN "id" SET DEFAULT nextval('"Category_id_seq"'); + +CREATE INDEX "Category_storeId_idx" ON "Category"("storeId"); +CREATE INDEX "Category_parentId_idx" ON "Category"("parentId"); +CREATE INDEX "Category_sortOrder_idx" ON "Category"("sortOrder"); +CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId"); + +ALTER TABLE "Category" + ADD CONSTRAINT "Category_storeId_fkey" + FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Category" + ADD CONSTRAINT "Category_parentId_fkey" + FOREIGN KEY ("parentId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "Product" + ADD CONSTRAINT "Product_categoryId_fkey" + FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +DROP TABLE "_category_id_map"; diff --git a/prisma/migrations/20260927190000_product_int_id/migration.sql b/prisma/migrations/20260927190000_product_int_id/migration.sql new file mode 100644 index 0000000..240bb8c --- /dev/null +++ b/prisma/migrations/20260927190000_product_int_id/migration.sql @@ -0,0 +1,154 @@ +-- Convert Product.id (cuid text) → serial integer; remap ProductVariant + OrderItem FKs. + +ALTER TABLE "ProductVariant" DROP CONSTRAINT IF EXISTS "ProductVariant_productId_fkey"; +ALTER TABLE "OrderItem" DROP CONSTRAINT IF EXISTS "OrderItem_productId_fkey"; +ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_storeId_fkey"; +ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_categoryId_fkey"; +ALTER TABLE "Product" DROP CONSTRAINT IF EXISTS "Product_brandId_fkey"; + +DROP INDEX IF EXISTS "Product_storeId_idx"; +DROP INDEX IF EXISTS "Product_categoryId_idx"; +DROP INDEX IF EXISTS "Product_brandId_idx"; +DROP INDEX IF EXISTS "Product_price_idx"; +DROP INDEX IF EXISTS "Product_published_idx"; +DROP INDEX IF EXISTS "Product_featuredSlot_idx"; +DROP INDEX IF EXISTS "Product_vipEnabled_idx"; +DROP INDEX IF EXISTS "Product_vipOnly_idx"; +DROP INDEX IF EXISTS "ProductVariant_productId_idx"; +DROP INDEX IF EXISTS "OrderItem_productId_idx"; + +CREATE TABLE "_product_id_map" ( + "old_id" TEXT PRIMARY KEY, + "new_id" INTEGER NOT NULL UNIQUE +); + +INSERT INTO "_product_id_map" ("old_id", "new_id") +SELECT "id", ROW_NUMBER() OVER (ORDER BY "createdAt", "id")::INTEGER +FROM "Product"; + +CREATE TABLE "Product_new" ( + "id" INTEGER NOT NULL, + "storeId" TEXT NOT NULL, + "categoryId" INTEGER, + "brandId" TEXT, + "nameFa" TEXT NOT NULL, + "nameEn" TEXT NOT NULL DEFAULT '', + "summary" TEXT NOT NULL DEFAULT '', + "description" TEXT NOT NULL DEFAULT '', + "price" INTEGER NOT NULL DEFAULT 0, + "discountPrice" INTEGER, + "imageUrl" TEXT, + "imageKey" TEXT, + "galleryUrls" TEXT[] DEFAULT ARRAY[]::TEXT[], + "galleryKeys" TEXT[] DEFAULT ARRAY[]::TEXT[], + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "published" BOOLEAN NOT NULL DEFAULT true, + "featuredSlot" TEXT, + "vipEnabled" BOOLEAN NOT NULL DEFAULT false, + "vipOnly" BOOLEAN NOT NULL DEFAULT false, + "vipSpecialPrice" BOOLEAN NOT NULL DEFAULT false, + "vipPrice" INTEGER, + "technicalData" JSONB NOT NULL DEFAULT '{}', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Product_new_pkey" PRIMARY KEY ("id") +); + +INSERT INTO "Product_new" ( + "id", "storeId", "categoryId", "brandId", "nameFa", "nameEn", "summary", "description", + "price", "discountPrice", "imageUrl", "imageKey", "galleryUrls", "galleryKeys", "tags", + "published", "featuredSlot", "vipEnabled", "vipOnly", "vipSpecialPrice", "vipPrice", + "technicalData", "createdAt", "updatedAt" +) +SELECT + m."new_id", + p."storeId", + p."categoryId", + p."brandId", + p."nameFa", + p."nameEn", + p."summary", + p."description", + p."price", + p."discountPrice", + p."imageUrl", + p."imageKey", + p."galleryUrls", + p."galleryKeys", + p."tags", + p."published", + p."featuredSlot", + p."vipEnabled", + p."vipOnly", + p."vipSpecialPrice", + p."vipPrice", + COALESCE(p."technicalData", '{}'::jsonb), + p."createdAt", + p."updatedAt" +FROM "Product" p +JOIN "_product_id_map" m ON m."old_id" = p."id"; + +ALTER TABLE "ProductVariant" ADD COLUMN "productId_new" INTEGER; + +UPDATE "ProductVariant" AS v +SET "productId_new" = m."new_id" +FROM "_product_id_map" AS m +WHERE v."productId" = m."old_id"; + +ALTER TABLE "ProductVariant" DROP COLUMN "productId"; +ALTER TABLE "ProductVariant" RENAME COLUMN "productId_new" TO "productId"; +ALTER TABLE "ProductVariant" ALTER COLUMN "productId" SET NOT NULL; + +ALTER TABLE "OrderItem" ADD COLUMN "productId_new" INTEGER; + +UPDATE "OrderItem" AS oi +SET "productId_new" = m."new_id" +FROM "_product_id_map" AS m +WHERE oi."productId" = m."old_id"; + +ALTER TABLE "OrderItem" DROP COLUMN "productId"; +ALTER TABLE "OrderItem" RENAME COLUMN "productId_new" TO "productId"; + +DROP TABLE "Product"; +ALTER TABLE "Product_new" RENAME TO "Product"; +ALTER TABLE "Product" RENAME CONSTRAINT "Product_new_pkey" TO "Product_pkey"; + +CREATE SEQUENCE "Product_id_seq" OWNED BY "Product"."id"; +SELECT setval( + '"Product_id_seq"', + COALESCE((SELECT MAX("id") FROM "Product"), 1) +); +ALTER TABLE "Product" ALTER COLUMN "id" SET DEFAULT nextval('"Product_id_seq"'); + +CREATE INDEX "Product_storeId_idx" ON "Product"("storeId"); +CREATE INDEX "Product_categoryId_idx" ON "Product"("categoryId"); +CREATE INDEX "Product_brandId_idx" ON "Product"("brandId"); +CREATE INDEX "Product_price_idx" ON "Product"("price"); +CREATE INDEX "Product_published_idx" ON "Product"("published"); +CREATE INDEX "Product_featuredSlot_idx" ON "Product"("featuredSlot"); +CREATE INDEX "Product_vipEnabled_idx" ON "Product"("vipEnabled"); +CREATE INDEX "Product_vipOnly_idx" ON "Product"("vipOnly"); +CREATE INDEX "ProductVariant_productId_idx" ON "ProductVariant"("productId"); +CREATE INDEX "OrderItem_productId_idx" ON "OrderItem"("productId"); + +ALTER TABLE "Product" + ADD CONSTRAINT "Product_storeId_fkey" + FOREIGN KEY ("storeId") REFERENCES "Store"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Product" + ADD CONSTRAINT "Product_categoryId_fkey" + FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "Product" + ADD CONSTRAINT "Product_brandId_fkey" + FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "ProductVariant" + ADD CONSTRAINT "ProductVariant_productId_fkey" + FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "OrderItem" + ADD CONSTRAINT "OrderItem_productId_fkey" + FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +DROP TABLE "_product_id_map"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dafb6b2..c1af31c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -131,24 +131,27 @@ model Store { blogs BlogPost[] orderItems OrderItem[] fulfillments OrderStoreFulfillment[] + websiteSlots WebsiteSlotItem[] @@index([status]) } model Category { - id String @id @default(cuid()) + id Int @id @default(autoincrement()) storeId String store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - parentId String? + parentId Int? parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull) children Category[] @relation("CategoryTree") nameFa String nameEn String @default("") sortOrder Int @default(0) - colors String[] @default([]) - sizes String[] @default([]) - packages String[] @default([]) - products Product[] + colors String[] @default([]) + sizes String[] @default([]) + packages String[] @default([]) + /// Ordered form-builder defs: [{ id, labelFa, labelEn?, type: "text", required? }] + technicalFields Json @default("[]") + products Product[] @@index([storeId]) @@index([parentId]) @@ -169,10 +172,10 @@ model Brand { } model Product { - id String @id @default(cuid()) + id Int @id @default(autoincrement()) storeId String store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) - categoryId String? + categoryId Int? category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) brandId String? brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull) @@ -188,10 +191,14 @@ model Product { galleryKeys String[] @default([]) tags String[] @default([]) published Boolean @default(true) + /// Home carousel featured slot: promo-1 | promo-2 | promo-3 + featuredSlot String? vipEnabled Boolean @default(false) vipOnly Boolean @default(false) vipSpecialPrice Boolean @default(false) vipPrice Int? + /// Values keyed by category technical field id: { [fieldId]: string } + technicalData Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt variants ProductVariant[] @@ -202,13 +209,14 @@ model Product { @@index([brandId]) @@index([price]) @@index([published]) + @@index([featuredSlot]) @@index([vipEnabled]) @@index([vipOnly]) } model ProductVariant { id String @id @default(cuid()) - productId String + productId Int product Product @relation(fields: [productId], references: [id], onDelete: Cascade) colorId String @default("") sizeId String @default("") @@ -289,7 +297,7 @@ model OrderItem { order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) storeId String store Store @relation(fields: [storeId], references: [id], onDelete: Restrict) - productId String? + productId Int? product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) nameFa String variantLabel String? @@ -313,3 +321,26 @@ model OrderStoreFulfillment { @@index([storeId]) @@index([status]) } + +/// Homepage / storefront image slots (hero slides, banners, category thumbs, …) +model WebsiteSlotItem { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + /// e.g. home.hero | home.category.shirts | home.banner.leather + slotKey String + sortOrder Int @default(0) + nameFa String @default("") + nameEn String @default("") + subtext String @default("") + linkText String @default("") + link String @default("") + imageUrl String? + imageKey String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([storeId]) + @@index([storeId, slotKey]) + @@index([storeId, slotKey, sortOrder]) +} diff --git a/scripts/fill-category-children.ts b/scripts/fill-category-children.ts new file mode 100644 index 0000000..cb0dbe8 --- /dev/null +++ b/scripts/fill-category-children.ts @@ -0,0 +1,130 @@ +/** + * Add subcategories under remaining women categories + men clothes. + * Run: npx tsx scripts/fill-category-children.ts + */ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const SUBS: Record> = { + // پالتو و کت + cmujwdqxd0001q9g7gbkdzp90: [ + ["پالتو بلند", "Long Coat"], + ["کت کوتاه", "Short Coat"], + ["پالتو فوتر", "Wool Coat"], + ], + // پیراهن + cmujwdqxh0003q9g7ywj3xtkg: [ + ["پیراهن کوتاه", "Short Dress"], + ["پیراهن بلند", "Long Dress"], + ["پیراهن مجلسی", "Party Dress"], + ], + // شومیز + cmujwdqxj0005q9g7dmrujmar: [ + ["شومیز رسمی", "Formal Blouse"], + ["شومیز روزانه", "Casual Blouse"], + ["شومیز آستین کوتاه", "Short Sleeve"], + ], + // تی‌شرت + cmujwdqxl0007q9g72fa9i1u6: [ + ["تی‌شرت ساده", "Basic Tee"], + ["تی‌شرت طرح‌دار", "Printed Tee"], + ["تی‌شرت یقه‌دار", "Collar Tee"], + ], + // دامن + cmujwdqxm0009q9g7dygpixfl: [ + ["دامن کوتاه", "Mini Skirt"], + ["دامن متوسط", "Midi Skirt"], + ["دامن بلند", "Maxi Skirt"], + ], + // لباس مجلسی + cmujwdqxn000bq9g769r3oaq2: [ + ["شب", "Evening"], + ["عروس", "Bridal"], + ["مهمانی", "Party"], + ], + // کت و شلوار + cmujwdqxo000dq9g7syqycf39: [ + ["رسمی", "Formal"], + ["نیمه‌رسمی", "Semi Formal"], + ["کت تک", "Blazer"], + ], + // کیف + cmujwdqxp000fq9g7pww4264v: [ + ["کیف دستی", "Handbag"], + ["کیف دوشی", "Shoulder Bag"], + ["کیف پول", "Wallet"], + ], + // عینک + cmujwdqxq000hq9g7hsyx1c6p: [ + ["آفتابی", "Sunglasses"], + ["طبی", "Optical"], + ], + // جوراب + cmujwdqxr000jq9g7kesb73gy: [ + ["کوتاه", "Ankle"], + ["بلند", "Knee High"], + ["ورزشی", "Sport"], + ], +}; + +const MEN_ID = "cmtrlp2zu000dqmyewzr566tk"; +const MEN_CHILDREN: Array<[string, string]> = [ + ["پیراهن", "Shirt"], + ["شلوار", "Pants"], + ["کت", "Jacket"], + ["تی‌شرت", "T-Shirt"], + ["کت و شلوار", "Suit"], +]; + +async function ensureChild( + storeId: string, + parentId: string, + nameFa: string, + nameEn: string, + sortOrder: number, +) { + const existing = await prisma.category.findFirst({ + where: { storeId, parentId, nameFa }, + }); + if (existing) { + return prisma.category.update({ + where: { id: existing.id }, + data: { nameEn, sortOrder }, + }); + } + return prisma.category.create({ + data: { storeId, parentId, nameFa, nameEn, sortOrder }, + }); +} + +async function main() { + for (const [parentId, children] of Object.entries(SUBS)) { + const parent = await prisma.category.findUnique({ where: { id: parentId } }); + if (!parent) { + console.warn("skip missing", parentId); + continue; + } + for (let i = 0; i < children.length; i++) { + const [nameFa, nameEn] = children[i]!; + await ensureChild(parent.storeId, parentId, nameFa, nameEn, i); + } + console.log(`${parent.nameFa}: +${children.length}`); + } + + const men = await prisma.category.findUnique({ where: { id: MEN_ID } }); + if (men) { + for (let i = 0; i < MEN_CHILDREN.length; i++) { + const [nameFa, nameEn] = MEN_CHILDREN[i]!; + await ensureChild(men.storeId, MEN_ID, nameFa, nameEn, i); + } + console.log(`لباس مردانه: +${MEN_CHILDREN.length}`); + } +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/fill-women-categories.ts b/scripts/fill-women-categories.ts new file mode 100644 index 0000000..ad4d027 --- /dev/null +++ b/scripts/fill-women-categories.ts @@ -0,0 +1,113 @@ +/** + * One-off: fill Women Clothes category tree for Mana Collection. + * Run: npx tsx scripts/fill-women-categories.ts + */ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +const WOMEN_ID = "cmtrlmy1r0003qmyebijmqbqt"; +const PANTS_ID = "cmtrlo4dn0009qmye6ere4659"; +const MANTEL_ID = "cmtrln6d80005qmye97lzhc01"; +const JEANS_ID = "cmtrlonpc000bqmyexzvqd500"; + +async function ensureChild( + storeId: string, + parentId: string, + nameFa: string, + nameEn: string, + sortOrder: number, +) { + const existing = await prisma.category.findFirst({ + where: { storeId, parentId, nameFa }, + }); + if (existing) { + return prisma.category.update({ + where: { id: existing.id }, + data: { nameEn, sortOrder }, + }); + } + return prisma.category.create({ + data: { storeId, parentId, nameFa, nameEn, sortOrder }, + }); +} + +async function main() { + const women = await prisma.category.findUnique({ where: { id: WOMEN_ID } }); + if (!women) throw new Error("Women category not found"); + const { storeId } = women; + + await prisma.category.update({ + where: { id: WOMEN_ID }, + data: { nameEn: "Women Clothes", sortOrder: 0 }, + }); + + const tops: Array<{ + nameFa: string; + nameEn: string; + sortOrder: number; + id?: string; + }> = [ + { nameFa: "شلوار", nameEn: "Pants", sortOrder: 0, id: PANTS_ID }, + { nameFa: "مانتو", nameEn: "Mantel", sortOrder: 1, id: MANTEL_ID }, + { nameFa: "پالتو و کت", nameEn: "Coats", sortOrder: 2 }, + { nameFa: "پیراهن", nameEn: "Dress", sortOrder: 3 }, + { nameFa: "شومیز", nameEn: "Blouse", sortOrder: 4 }, + { nameFa: "تی‌شرت", nameEn: "T-Shirt", sortOrder: 5 }, + { nameFa: "دامن", nameEn: "Skirt", sortOrder: 6 }, + { nameFa: "لباس مجلسی", nameEn: "Formal Dress", sortOrder: 7 }, + { nameFa: "کت و شلوار", nameEn: "Suit", sortOrder: 8 }, + { nameFa: "کیف", nameEn: "Bag", sortOrder: 9 }, + { nameFa: "عینک", nameEn: "Glasses", sortOrder: 10 }, + { nameFa: "جوراب", nameEn: "Socks", sortOrder: 11 }, + ]; + + for (const row of tops) { + if (row.id) { + await prisma.category.update({ + where: { id: row.id }, + data: { + nameFa: row.nameFa, + nameEn: row.nameEn, + sortOrder: row.sortOrder, + }, + }); + } else { + await ensureChild( + storeId, + WOMEN_ID, + row.nameFa, + row.nameEn, + row.sortOrder, + ); + } + } + + await prisma.category.update({ + where: { id: JEANS_ID }, + data: { nameFa: "شلوار جین", nameEn: "Jean Pants", sortOrder: 0 }, + }); + await ensureChild(storeId, PANTS_ID, "شلوار پارچه‌ای", "Fabric Pants", 1); + await ensureChild(storeId, PANTS_ID, "شلوارک", "Shorts", 2); + + await ensureChild(storeId, MANTEL_ID, "بهاره", "Spring", 0); + await ensureChild(storeId, MANTEL_ID, "پاییزه", "Autumn", 1); + await ensureChild(storeId, MANTEL_ID, "مجلسی", "Formal", 2); + + const children = await prisma.category.findMany({ + where: { parentId: WOMEN_ID }, + orderBy: { sortOrder: "asc" }, + select: { nameFa: true, nameEn: true, sortOrder: true }, + }); + console.log("Women children:", children.length); + for (const c of children) { + console.log(` ${c.sortOrder}. ${c.nameFa} / ${c.nameEn}`); + } +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/app.module.ts b/src/app.module.ts index 42f7cf7..4c1348c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { PublicModule } from './public/public.module'; import { SmsModule } from './sms/sms.module'; import { StorageModule } from './storage/storage.module'; import { StoresModule } from './stores/stores.module'; +import { WebsiteSlotsModule } from './website-slots/website-slots.module'; @Module({ imports: [ @@ -36,6 +37,7 @@ import { StoresModule } from './stores/stores.module'; CustomersModule, OrdersModule, ProfileModule, + WebsiteSlotsModule, ], }) export class AppModule {} diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts index 688f6d6..1e2aeff 100644 --- a/src/categories/categories.controller.ts +++ b/src/categories/categories.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, Param, + ParseIntPipe, Patch, Post, Query, @@ -40,14 +41,17 @@ export class CategoriesController { @Patch(':id') update( @CurrentUser() user: AuthUser, - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCategoryDto, ) { return this.categories.update(user, id, dto); } @Delete(':id') - remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + remove( + @CurrentUser() user: AuthUser, + @Param('id', ParseIntPipe) id: number, + ) { return this.categories.remove(user, id); } } diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts index 4884905..518cca9 100644 --- a/src/categories/categories.service.ts +++ b/src/categories/categories.service.ts @@ -1,5 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { UserRole } from '@prisma/client'; +import { Prisma, UserRole } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { StoreScopeService } from '../common/store-scope.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -42,11 +42,13 @@ export class CategoriesService { colors: dto.colors ?? [], sizes: dto.sizes ?? [], packages: dto.packages ?? [], + technicalFields: (dto.technicalFields ?? + []) as unknown as Prisma.InputJsonValue, }, }); } - async update(actor: AuthUser, id: string, dto: UpdateCategoryDto) { + async update(actor: AuthUser, id: number, dto: UpdateCategoryDto) { const existing = await this.prisma.category.findUnique({ where: { id } }); if (!existing) { throw new NotFoundException('دسته‌بندی یافت نشد'); @@ -63,11 +65,17 @@ export class CategoriesService { ...(dto.colors !== undefined ? { colors: dto.colors } : {}), ...(dto.sizes !== undefined ? { sizes: dto.sizes } : {}), ...(dto.packages !== undefined ? { packages: dto.packages } : {}), + ...(dto.technicalFields !== undefined + ? { + technicalFields: + dto.technicalFields as unknown as Prisma.InputJsonValue, + } + : {}), }, }); } - async remove(actor: AuthUser, id: string) { + async remove(actor: AuthUser, id: number) { const existing = await this.prisma.category.findUnique({ where: { id } }); if (!existing) { throw new NotFoundException('دسته‌بندی یافت نشد'); @@ -77,10 +85,10 @@ export class CategoriesService { return { ok: true }; } - private buildTree< - T extends { id: string; parentId: string | null }, - >(items: T[]) { - const byParent = new Map(); + private buildTree( + items: T[], + ) { + const byParent = new Map(); for (const item of items) { const key = item.parentId; const list = byParent.get(key) ?? []; @@ -88,7 +96,9 @@ export class CategoriesService { byParent.set(key, list); } - const walk = (parentId: string | null): Array => + const walk = ( + parentId: number | null, + ): Array => (byParent.get(parentId) ?? []).map((node) => ({ ...node, children: walk(node.id), diff --git a/src/categories/dto/category.dto.ts b/src/categories/dto/category.dto.ts index 6fa0623..6cda5eb 100644 --- a/src/categories/dto/category.dto.ts +++ b/src/categories/dto/category.dto.ts @@ -1,12 +1,37 @@ +import { Type } from 'class-transformer'; import { IsArray, + IsBoolean, + IsIn, IsInt, IsOptional, IsString, Min, MinLength, + ValidateNested, } from 'class-validator'; +export class TechnicalFieldDto { + @IsString() + @MinLength(1) + id!: string; + + @IsString() + @MinLength(1) + labelFa!: string; + + @IsOptional() + @IsString() + labelEn?: string; + + @IsIn(['text']) + type!: 'text'; + + @IsOptional() + @IsBoolean() + required?: boolean; +} + export class CreateCategoryDto { @IsString() @MinLength(1) @@ -17,8 +42,9 @@ export class CreateCategoryDto { nameEn?: string; @IsOptional() - @IsString() - parentId?: string; + @Type(() => Number) + @IsInt() + parentId?: number; @IsOptional() @IsInt() @@ -40,6 +66,12 @@ export class CreateCategoryDto { @IsString({ each: true }) packages?: string[]; + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TechnicalFieldDto) + technicalFields?: TechnicalFieldDto[]; + @IsOptional() @IsString() storeId?: string; @@ -56,8 +88,9 @@ export class UpdateCategoryDto { nameEn?: string; @IsOptional() - @IsString() - parentId?: string | null; + @Type(() => Number) + @IsInt() + parentId?: number | null; @IsOptional() @IsInt() @@ -78,4 +111,10 @@ export class UpdateCategoryDto { @IsArray() @IsString({ each: true }) packages?: string[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TechnicalFieldDto) + technicalFields?: TechnicalFieldDto[]; } diff --git a/src/media/dto/media-upload-query.dto.ts b/src/media/dto/media-upload-query.dto.ts index a43a12c..4ab6e3a 100644 --- a/src/media/dto/media-upload-query.dto.ts +++ b/src/media/dto/media-upload-query.dto.ts @@ -7,6 +7,7 @@ export const MEDIA_KINDS = [ 'brand', 'store', 'avatar', + 'website', 'temp', ] as const; export type MediaKind = (typeof MEDIA_KINDS)[number]; diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 815109e..b649958 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -14,6 +14,7 @@ import { storeMediaKey, tempMediaKey, userMediaKey, + websiteMediaKey, } from '../storage/storage-keys'; import { StorageService } from '../storage/storage.service'; import { MediaKind } from './dto/media-upload-query.dto'; @@ -85,6 +86,8 @@ export class MediaService { return storeMediaKey(fileName); case 'avatar': return userMediaKey(fileName); + case 'website': + return websiteMediaKey(fileName); case 'temp': return tempMediaKey(fileName); default: diff --git a/src/products/dto/create-product.dto.ts b/src/products/dto/create-product.dto.ts index 1a8da7b..27b2f8e 100644 --- a/src/products/dto/create-product.dto.ts +++ b/src/products/dto/create-product.dto.ts @@ -3,6 +3,7 @@ import { IsArray, IsBoolean, IsInt, + IsObject, IsOptional, IsString, Min, @@ -69,8 +70,9 @@ export class CreateProductDto { discountPrice?: number; @IsOptional() - @IsString() - categoryId?: string; + @Type(() => Number) + @IsInt() + categoryId?: number; @IsOptional() @IsString() @@ -103,6 +105,10 @@ export class CreateProductDto { @IsBoolean() published?: boolean; + @IsOptional() + @IsString() + featuredSlot?: string | null; + @IsOptional() @IsBoolean() vipEnabled?: boolean; @@ -120,6 +126,10 @@ export class CreateProductDto { @Min(0) vipPrice?: number; + @IsOptional() + @IsObject() + technicalData?: Record; + @IsOptional() @IsArray() @ValidateNested({ each: true }) @@ -161,8 +171,9 @@ export class UpdateProductDto { discountPrice?: number | null; @IsOptional() - @IsString() - categoryId?: string | null; + @Type(() => Number) + @IsInt() + categoryId?: number | null; @IsOptional() @IsString() @@ -195,6 +206,10 @@ export class UpdateProductDto { @IsBoolean() published?: boolean; + @IsOptional() + @IsString() + featuredSlot?: string | null; + @IsOptional() @IsBoolean() vipEnabled?: boolean; @@ -212,6 +227,10 @@ export class UpdateProductDto { @Min(0) vipPrice?: number | null; + @IsOptional() + @IsObject() + technicalData?: Record; + @IsOptional() @IsArray() @ValidateNested({ each: true }) diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 4808b74..c2d2a17 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, Param, + ParseIntPipe, Patch, Post, Query, @@ -36,17 +37,27 @@ export class ProductsController { @Query('minPrice') minPrice?: number, @Query('maxPrice') maxPrice?: number, ) { + const parsedCategoryId = + categoryId !== undefined && categoryId !== '' + ? Number(categoryId) + : undefined; return this.products.list(user, { storeId, name, - categoryId, + categoryId: + parsedCategoryId !== undefined && Number.isFinite(parsedCategoryId) + ? parsedCategoryId + : undefined, minPrice, maxPrice, }); } @Get(':id') - findOne(@CurrentUser() user: AuthUser, @Param('id') id: string) { + findOne( + @CurrentUser() user: AuthUser, + @Param('id', ParseIntPipe) id: number, + ) { return this.products.findOne(user, id); } @@ -58,14 +69,17 @@ export class ProductsController { @Patch(':id') update( @CurrentUser() user: AuthUser, - @Param('id') id: string, + @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateProductDto, ) { return this.products.update(user, id, dto); } @Delete(':id') - remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + remove( + @CurrentUser() user: AuthUser, + @Param('id', ParseIntPipe) id: number, + ) { return this.products.remove(user, id); } } diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 03dcda7..3f277bc 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -20,7 +20,7 @@ export class ProductsService { query: { storeId?: string; name?: string; - categoryId?: string; + categoryId?: number; minPrice?: number; maxPrice?: number; }, @@ -62,7 +62,7 @@ export class ProductsService { }); } - async findOne(actor: AuthUser, id: string) { + async findOne(actor: AuthUser, id: number) { const product = await this.prisma.product.findUnique({ where: { id }, include: { variants: true, category: true, brand: true }, @@ -97,10 +97,13 @@ export class ProductsService { galleryKeys: dto.galleryKeys ?? [], tags: dto.tags ?? [], published: dto.published ?? true, + featuredSlot: dto.featuredSlot ?? null, vipEnabled: dto.vipEnabled ?? false, vipOnly: dto.vipOnly ?? false, vipSpecialPrice: dto.vipSpecialPrice ?? false, vipPrice: dto.vipPrice, + technicalData: (dto.technicalData ?? + {}) as unknown as Prisma.InputJsonValue, variants: dto.variants?.length ? { create: dto.variants.map((v) => ({ @@ -118,7 +121,7 @@ export class ProductsService { }); } - async update(actor: AuthUser, id: string, dto: UpdateProductDto) { + async update(actor: AuthUser, id: number, dto: UpdateProductDto) { const existing = await this.prisma.product.findUnique({ where: { id } }); if (!existing) { throw new NotFoundException('محصول یافت نشد'); @@ -170,6 +173,9 @@ export class ProductsService { : {}), ...(dto.tags !== undefined ? { tags: dto.tags } : {}), ...(dto.published !== undefined ? { published: dto.published } : {}), + ...(dto.featuredSlot !== undefined + ? { featuredSlot: dto.featuredSlot } + : {}), ...(dto.vipEnabled !== undefined ? { vipEnabled: dto.vipEnabled } : {}), @@ -178,13 +184,19 @@ export class ProductsService { ? { vipSpecialPrice: dto.vipSpecialPrice } : {}), ...(dto.vipPrice !== undefined ? { vipPrice: dto.vipPrice } : {}), + ...(dto.technicalData !== undefined + ? { + technicalData: + dto.technicalData as unknown as Prisma.InputJsonValue, + } + : {}), }, include: { variants: true }, }); }); } - async remove(actor: AuthUser, id: string) { + async remove(actor: AuthUser, id: number) { const existing = await this.prisma.product.findUnique({ where: { id } }); if (!existing) { throw new NotFoundException('محصول یافت نشد'); diff --git a/src/public/public.controller.ts b/src/public/public.controller.ts index f1a2943..11625ce 100644 --- a/src/public/public.controller.ts +++ b/src/public/public.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common'; import { PublicService } from './public.service'; @Controller('public') @@ -19,23 +19,53 @@ export class PublicController { @Query('minPrice') minPrice?: number, @Query('maxPrice') maxPrice?: number, ) { + const parsedCategoryId = + categoryId !== undefined && categoryId !== '' + ? Number(categoryId) + : undefined; return this.publicService.listProducts({ storeId, storeSlug, name, - categoryId, + categoryId: + parsedCategoryId !== undefined && Number.isFinite(parsedCategoryId) + ? parsedCategoryId + : undefined, minPrice, maxPrice, }); } @Get('products/:id') - findProduct(@Param('id') id: string) { + findProduct(@Param('id', ParseIntPipe) id: number) { return this.publicService.findProduct(id); } + @Get('categories') + listCategories(@Query('storeSlug') storeSlug?: string) { + return this.publicService.listCategories(storeSlug); + } + + @Get('search') + search( + @Query('q') q?: string, + @Query('storeSlug') storeSlug?: string, + @Query('limit') limit?: number, + ) { + return this.publicService.search({ + q, + storeSlug, + limit: limit !== undefined ? Number(limit) : undefined, + }); + } + @Get('blogs') listBlogs(@Query('storeSlug') storeSlug?: string) { return this.publicService.listBlogs(storeSlug); } + + @Get('website-slots') + listWebsiteSlots(@Query('storeSlug') storeSlug?: string) { + return this.publicService.listWebsiteSlots(storeSlug); + } } diff --git a/src/public/public.service.ts b/src/public/public.service.ts index 6533f75..c531518 100644 --- a/src/public/public.service.ts +++ b/src/public/public.service.ts @@ -2,6 +2,16 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma, StoreStatus } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +export type PublicCategoryTreeNode = { + id: number; + parentId: number | null; + nameFa: string; + nameEn: string; + sortOrder: number; + imageUrl: string | null; + children: PublicCategoryTreeNode[]; +}; + @Injectable() export class PublicService { constructor(private readonly prisma: PrismaService) {} @@ -25,7 +35,7 @@ export class PublicService { storeId?: string; storeSlug?: string; name?: string; - categoryId?: string; + categoryId?: number; minPrice?: number; maxPrice?: number; }) { @@ -67,7 +77,7 @@ export class PublicService { }); } - async findProduct(id: string) { + async findProduct(id: number) { const product = await this.prisma.product.findFirst({ where: { id, @@ -95,6 +105,119 @@ export class PublicService { return product; } + async listCategories(storeSlug?: string) { + const store = await this.prisma.store.findFirst({ + where: { + status: StoreStatus.active, + ...(storeSlug ? { slug: storeSlug } : { slug: 'mana-collection' }), + }, + select: { id: true, slug: true }, + }); + if (!store) { + return { storeSlug: storeSlug ?? 'mana-collection', categories: [] }; + } + + const categories = await this.prisma.category.findMany({ + where: { storeId: store.id }, + orderBy: [{ sortOrder: 'asc' }, { nameFa: 'asc' }], + select: { + id: true, + parentId: true, + nameFa: true, + nameEn: true, + sortOrder: true, + products: { + where: { published: true }, + take: 1, + orderBy: { updatedAt: 'desc' }, + select: { imageUrl: true, galleryUrls: true }, + }, + }, + }); + + const byParent = new Map(); + for (const cat of categories) { + const key = cat.parentId; + const list = byParent.get(key) ?? []; + list.push(cat); + byParent.set(key, list); + } + + const walk = (parentId: number | null): PublicCategoryTreeNode[] => + (byParent.get(parentId) ?? []).map((node) => { + const cover = + node.products[0]?.imageUrl || + node.products[0]?.galleryUrls?.[0] || + null; + return { + id: node.id, + parentId: node.parentId, + nameFa: node.nameFa, + nameEn: node.nameEn, + sortOrder: node.sortOrder, + imageUrl: cover, + children: walk(node.id), + }; + }); + + return { storeSlug: store.slug, categories: walk(null) }; + } + + search(query: { + q?: string; + storeSlug?: string; + limit?: number; + }) { + const q = query.q?.trim() ?? ''; + if (!q) { + return { products: [], categories: [] }; + } + + const storeFilter = query.storeSlug + ? { store: { slug: query.storeSlug, status: StoreStatus.active } } + : { store: { status: StoreStatus.active } }; + + const take = Math.min(Math.max(query.limit ?? 12, 1), 40); + + return Promise.all([ + this.prisma.product.findMany({ + where: { + published: true, + ...storeFilter, + OR: [ + { nameFa: { contains: q, mode: 'insensitive' } }, + { nameEn: { contains: q, mode: 'insensitive' } }, + { summary: { contains: q, mode: 'insensitive' } }, + { tags: { has: q } }, + ], + }, + take, + orderBy: { updatedAt: 'desc' }, + include: { + category: { select: { id: true, nameFa: true, nameEn: true } }, + brand: { select: { id: true, nameFa: true } }, + }, + }), + this.prisma.category.findMany({ + where: { + ...storeFilter, + OR: [ + { nameFa: { contains: q, mode: 'insensitive' } }, + { nameEn: { contains: q, mode: 'insensitive' } }, + ], + }, + take: 8, + orderBy: { sortOrder: 'asc' }, + select: { + id: true, + nameFa: true, + nameEn: true, + parentId: true, + }, + }), + ]).then(([products, categories]) => ({ products, categories })); + } + listBlogs(storeSlug?: string) { return this.prisma.blogPost.findMany({ where: { @@ -110,4 +233,35 @@ export class PublicService { orderBy: { createdAt: 'desc' }, }); } + + async listWebsiteSlots(storeSlug?: string) { + const store = await this.prisma.store.findFirst({ + where: { + status: StoreStatus.active, + ...(storeSlug ? { slug: storeSlug } : { slug: 'mana-collection' }), + }, + select: { id: true, slug: true }, + }); + if (!store) { + return { storeSlug: storeSlug ?? 'mana-collection', items: [] }; + } + + const items = await this.prisma.websiteSlotItem.findMany({ + where: { storeId: store.id }, + orderBy: [{ slotKey: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], + select: { + id: true, + slotKey: true, + sortOrder: true, + nameFa: true, + nameEn: true, + subtext: true, + linkText: true, + link: true, + imageUrl: true, + }, + }); + + return { storeSlug: store.slug, items }; + } } diff --git a/src/storage/storage-keys.ts b/src/storage/storage-keys.ts index dbd9432..36eff0e 100644 --- a/src/storage/storage-keys.ts +++ b/src/storage/storage-keys.ts @@ -36,6 +36,10 @@ export function storeMediaKey(fileName: string): string { return `${ROOT}/stores/${fileName}`; } +export function websiteMediaKey(fileName: string): string { + return `${ROOT}/website/${fileName}`; +} + export function tempMediaKey(fileName: string): string { return `${ROOT}/temp/${fileName}`; } diff --git a/src/website-slots/dto/website-slot.dto.ts b/src/website-slots/dto/website-slot.dto.ts new file mode 100644 index 0000000..8530a44 --- /dev/null +++ b/src/website-slots/dto/website-slot.dto.ts @@ -0,0 +1,85 @@ +import { + IsInt, + IsOptional, + IsString, + Min, + MinLength, +} from 'class-validator'; + +export class CreateWebsiteSlotItemDto { + @IsString() + @MinLength(1) + slotKey!: string; + + @IsOptional() + @IsString() + nameFa?: string; + + @IsOptional() + @IsString() + nameEn?: string; + + @IsOptional() + @IsString() + subtext?: string; + + @IsOptional() + @IsString() + linkText?: string; + + @IsOptional() + @IsString() + link?: string; + + @IsOptional() + @IsString() + imageUrl?: string; + + @IsOptional() + @IsString() + imageKey?: string; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsString() + storeId?: string; +} + +export class UpdateWebsiteSlotItemDto { + @IsOptional() + @IsString() + nameFa?: string; + + @IsOptional() + @IsString() + nameEn?: string; + + @IsOptional() + @IsString() + subtext?: string; + + @IsOptional() + @IsString() + linkText?: string; + + @IsOptional() + @IsString() + link?: string; + + @IsOptional() + @IsString() + imageUrl?: string | null; + + @IsOptional() + @IsString() + imageKey?: string | null; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; +} diff --git a/src/website-slots/website-slot.catalog.ts b/src/website-slots/website-slot.catalog.ts new file mode 100644 index 0000000..1bc61fa --- /dev/null +++ b/src/website-slots/website-slot.catalog.ts @@ -0,0 +1,174 @@ +export type WebsiteSlotKind = 'collection' | 'fixed' | 'fixed-set'; + +export type WebsiteSlotDef = { + /** Stable slug used as slotKey in DB */ + slug: string; + labelFa: string; + labelEn: string; + kind: WebsiteSlotKind; + /** Group several fixed slots under one admin section */ + sectionKey?: string; + sectionLabelFa?: string; + sectionLabelEn?: string; + /** Exact cell count for fixed-set (e.g. 4 portraits) */ + itemCount?: number; + /** width / height for upload preview box */ + aspectRatio: number; + aspectLabel: string; + /** Optional labels for fixed-set cells (per-cell AR when images differ) */ + cellLabels?: Array<{ + labelFa: string; + labelEn: string; + aspectRatio?: number; + aspectLabel?: string; + /** Relative preview width in rem (matches on-site scale) */ + previewWidthRem?: number; + }>; +}; + +export const WEBSITE_SLOT_DEFS: WebsiteSlotDef[] = [ + { + slug: 'hero-slider', + labelFa: 'اسلایدر', + labelEn: 'Hero slider', + kind: 'collection', + aspectRatio: 1920 / 1080, + aspectLabel: '1920×1080', + }, + { + slug: 'category-strip', + labelFa: 'دسته‌بندی‌ها', + labelEn: 'Categories', + kind: 'collection', + aspectRatio: 68 / 80, + aspectLabel: '68×80', + }, + { + slug: 'promo-1', + labelFa: 'پرومو ۱', + labelEn: 'Promo 1', + kind: 'fixed', + sectionKey: 'promos', + sectionLabelFa: 'پروموها', + sectionLabelEn: 'Promos', + aspectRatio: 300 / 491, + aspectLabel: '300×491', + }, + { + slug: 'promo-2', + labelFa: 'پرومو ۲', + labelEn: 'Promo 2', + kind: 'fixed', + sectionKey: 'promos', + sectionLabelFa: 'پروموها', + sectionLabelEn: 'Promos', + aspectRatio: 300 / 491, + aspectLabel: '300×491', + }, + { + slug: 'promo-3', + labelFa: 'پرومو ۳', + labelEn: 'Promo 3', + kind: 'fixed', + sectionKey: 'promos', + sectionLabelFa: 'پروموها', + sectionLabelEn: 'Promos', + aspectRatio: 300 / 491, + aspectLabel: '300×491', + }, + { + slug: 'leather-banner', + labelFa: 'بنر چرم', + labelEn: 'Leather banner', + kind: 'fixed', + aspectRatio: 1920 / 585, + aspectLabel: '1920×585', + }, + { + slug: 'four-portrait-banner', + labelFa: 'کالکشن مشکی', + labelEn: 'Black collection', + kind: 'fixed-set', + itemCount: 4, + aspectRatio: 410 / 717, + aspectLabel: '410×717', + cellLabels: [ + { labelFa: 'اسلات ۱', labelEn: 'Slot 1' }, + { labelFa: 'اسلات ۲', labelEn: 'Slot 2' }, + { labelFa: 'اسلات ۳', labelEn: 'Slot 3' }, + { labelFa: 'اسلات ۴', labelEn: 'Slot 4' }, + ], + }, + { + slug: 'about', + labelFa: 'درباره ما', + labelEn: 'About', + kind: 'fixed-set', + itemCount: 2, + aspectRatio: 4 / 5, + aspectLabel: '4×5 · 3×4', + cellLabels: [ + { + labelFa: 'تصویر اصلی', + labelEn: 'Primary', + aspectRatio: 4 / 5, + aspectLabel: '4×5', + // On site: w-[78%] vs secondary w-[48%] ≈ 1.625× + previewWidthRem: 13, + }, + { + labelFa: 'تصویر دوم', + labelEn: 'Secondary', + aspectRatio: 3 / 4, + aspectLabel: '3×4', + previewWidthRem: 8, + }, + ], + }, + { + slug: 'mega-menu', + labelFa: 'مگامنو', + labelEn: 'Mega menu', + kind: 'fixed-set', + itemCount: 6, + aspectRatio: 160 / 220, + aspectLabel: '160×220', + cellLabels: [ + { labelFa: 'پالتو و کت', labelEn: 'Coats' }, + { labelFa: 'پیراهن', labelEn: 'Shirts' }, + { labelFa: 'شلوار', labelEn: 'Pants' }, + { labelFa: 'دامن', labelEn: 'Skirts' }, + { labelFa: 'کیف', labelEn: 'Bags' }, + { labelFa: 'چرم', labelEn: 'Leather' }, + ], + }, +]; + +export const WEBSITE_SLOT_SLUGS = WEBSITE_SLOT_DEFS.map((d) => d.slug); + +export function findSlotDef(slug: string): WebsiteSlotDef | undefined { + return WEBSITE_SLOT_DEFS.find((d) => d.slug === slug); +} + +export function isCollectionSlot(slug: string): boolean { + return findSlotDef(slug)?.kind === 'collection'; +} + +/** @deprecated use isCollectionSlot */ +export function isDuplicatableSlot(slug: string): boolean { + return isCollectionSlot(slug); +} + +/** Compat: old keys still accepted when reading historical rows */ +export const LEGACY_SLOT_ALIASES: Record = { + 'home.hero': 'hero-slider', + 'home.banner.leather': 'leather-banner', + 'home.promo.sale': 'promo-1', + 'home.promo.autumn': 'promo-3', + 'promo-sale': 'promo-1', + 'promo-discount': 'promo-2', + 'promo-autumn': 'promo-3', + 'home.about.primary': 'about-primary', + 'home.about.secondary': 'about-secondary', + 'home.footer.payment_badges': 'payment-badges', +}; diff --git a/src/website-slots/website-slots.controller.ts b/src/website-slots/website-slots.controller.ts new file mode 100644 index 0000000..7d8778d --- /dev/null +++ b/src/website-slots/website-slots.controller.ts @@ -0,0 +1,59 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import type { AuthUser } from '../auth/auth.types'; +import { + CreateWebsiteSlotItemDto, + UpdateWebsiteSlotItemDto, +} from './dto/website-slot.dto'; +import { WebsiteSlotsService } from './website-slots.service'; + +@Controller('website-slots') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.STORE_ADMIN, UserRole.SUPER_ADMIN) +export class WebsiteSlotsController { + constructor(private readonly slots: WebsiteSlotsService) {} + + @Get() + list( + @CurrentUser() user: AuthUser, + @Query('storeId') storeId?: string, + ) { + return this.slots.list(user, storeId); + } + + @Post() + create( + @CurrentUser() user: AuthUser, + @Body() dto: CreateWebsiteSlotItemDto, + ) { + return this.slots.create(user, dto); + } + + @Patch(':id') + update( + @CurrentUser() user: AuthUser, + @Param('id') id: string, + @Body() dto: UpdateWebsiteSlotItemDto, + ) { + return this.slots.update(user, id, dto); + } + + @Delete(':id') + remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { + return this.slots.remove(user, id); + } +} diff --git a/src/website-slots/website-slots.module.ts b/src/website-slots/website-slots.module.ts new file mode 100644 index 0000000..b0db736 --- /dev/null +++ b/src/website-slots/website-slots.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { StoreScopeService } from '../common/store-scope.service'; +import { WebsiteSlotsController } from './website-slots.controller'; +import { WebsiteSlotsService } from './website-slots.service'; + +@Module({ + controllers: [WebsiteSlotsController], + providers: [WebsiteSlotsService, StoreScopeService], + exports: [WebsiteSlotsService], +}) +export class WebsiteSlotsModule {} diff --git a/src/website-slots/website-slots.service.ts b/src/website-slots/website-slots.service.ts new file mode 100644 index 0000000..7ba36e5 --- /dev/null +++ b/src/website-slots/website-slots.service.ts @@ -0,0 +1,177 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { UserRole } from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { StoreScopeService } from '../common/store-scope.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateWebsiteSlotItemDto, + UpdateWebsiteSlotItemDto, +} from './dto/website-slot.dto'; +import { WEBSITE_SLOT_DEFS, findSlotDef } from './website-slot.catalog'; + +@Injectable() +export class WebsiteSlotsService { + constructor( + private readonly prisma: PrismaService, + private readonly storeScope: StoreScopeService, + ) {} + + async list(actor: AuthUser, storeIdQuery?: string) { + const storeId = + actor.role === UserRole.SUPER_ADMIN + ? storeIdQuery + : (await this.storeScope.requireActiveStore(actor)).id; + + const items = await this.prisma.websiteSlotItem.findMany({ + where: storeId ? { storeId } : undefined, + orderBy: [{ slotKey: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + + return { catalog: WEBSITE_SLOT_DEFS, items }; + } + + async create(actor: AuthUser, dto: CreateWebsiteSlotItemDto) { + const def = findSlotDef(dto.slotKey); + if (!def) { + throw new BadRequestException('اسلات نامعتبر است'); + } + + const store = await this.storeScope.requireActiveStore( + actor, + dto.storeId, + ); + + if (def.kind === 'fixed') { + const existing = await this.prisma.websiteSlotItem.findFirst({ + where: { storeId: store.id, slotKey: dto.slotKey }, + }); + if (existing) { + return this.prisma.websiteSlotItem.update({ + where: { id: existing.id }, + data: this.mergeCreateOntoExisting(existing, dto), + }); + } + } + + if (def.kind === 'fixed-set') { + const max = def.itemCount ?? 1; + const count = await this.prisma.websiteSlotItem.count({ + where: { storeId: store.id, slotKey: dto.slotKey }, + }); + if (count >= max && dto.sortOrder === undefined) { + throw new BadRequestException( + `حداکثر ${max} آیتم برای این اسلات مجاز است`, + ); + } + if (dto.sortOrder !== undefined) { + const atIndex = await this.prisma.websiteSlotItem.findFirst({ + where: { + storeId: store.id, + slotKey: dto.slotKey, + sortOrder: dto.sortOrder, + }, + }); + if (atIndex) { + return this.prisma.websiteSlotItem.update({ + where: { id: atIndex.id }, + data: this.mergeCreateOntoExisting(atIndex, dto), + }); + } + } + } + + let sortOrder = dto.sortOrder; + if (sortOrder === undefined) { + const last = await this.prisma.websiteSlotItem.findFirst({ + where: { storeId: store.id, slotKey: dto.slotKey }, + orderBy: { sortOrder: 'desc' }, + }); + sortOrder = (last?.sortOrder ?? -1) + 1; + } + + return this.prisma.websiteSlotItem.create({ + data: { + storeId: store.id, + slotKey: dto.slotKey, + sortOrder, + nameFa: dto.nameFa ?? '', + nameEn: dto.nameEn ?? '', + subtext: dto.subtext ?? '', + linkText: dto.linkText ?? '', + link: dto.link ?? '', + imageUrl: dto.imageUrl, + imageKey: dto.imageKey, + }, + }); + } + + async update(actor: AuthUser, id: string, dto: UpdateWebsiteSlotItemDto) { + const existing = await this.prisma.websiteSlotItem.findUnique({ + where: { id }, + }); + if (!existing) { + throw new NotFoundException('آیتم یافت نشد'); + } + await this.assertStoreAccess(actor, existing.storeId); + + return this.prisma.websiteSlotItem.update({ + where: { id }, + data: { + ...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}), + ...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}), + ...(dto.subtext !== undefined ? { subtext: dto.subtext } : {}), + ...(dto.linkText !== undefined ? { linkText: dto.linkText } : {}), + ...(dto.link !== undefined ? { link: dto.link } : {}), + ...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}), + ...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + }, + }); + } + + async remove(actor: AuthUser, id: string) { + const existing = await this.prisma.websiteSlotItem.findUnique({ + where: { id }, + }); + if (!existing) { + throw new NotFoundException('آیتم یافت نشد'); + } + await this.assertStoreAccess(actor, existing.storeId); + await this.prisma.websiteSlotItem.delete({ where: { id } }); + return { ok: true }; + } + + private mergeCreateOntoExisting( + existing: { + nameFa: string; + nameEn: string; + subtext: string; + linkText: string; + link: string; + }, + dto: CreateWebsiteSlotItemDto, + ) { + return { + nameFa: dto.nameFa ?? existing.nameFa, + nameEn: dto.nameEn ?? existing.nameEn, + subtext: dto.subtext ?? existing.subtext, + linkText: dto.linkText ?? existing.linkText, + link: dto.link ?? existing.link, + ...(dto.imageUrl !== undefined ? { imageUrl: dto.imageUrl } : {}), + ...(dto.imageKey !== undefined ? { imageKey: dto.imageKey } : {}), + ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), + }; + } + + private async assertStoreAccess(actor: AuthUser, storeId: string) { + if (actor.role === UserRole.SUPER_ADMIN) return; + const store = await this.storeScope.requireActiveStore(actor); + if (store.id !== storeId) { + throw new NotFoundException('آیتم یافت نشد'); + } + } +}