Add public catalog APIs, website slots, featured slots, and int category/product IDs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
15dd3597f0
commit
835c8a0ac1
@@ -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 '{}';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "featuredSlot" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_featuredSlot_idx" ON "Product"("featuredSlot");
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
+41
-10
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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<string, Array<[string, string]>> = {
|
||||
// پالتو و کت
|
||||
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());
|
||||
@@ -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());
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | null, T[]>();
|
||||
private buildTree<T extends { id: number; parentId: number | null }>(
|
||||
items: T[],
|
||||
) {
|
||||
const byParent = new Map<number | null, T[]>();
|
||||
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<T & { children: unknown[] }> =>
|
||||
const walk = (
|
||||
parentId: number | null,
|
||||
): Array<T & { children: unknown[] }> =>
|
||||
(byParent.get(parentId) ?? []).map((node) => ({
|
||||
...node,
|
||||
children: walk(node.id),
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const MEDIA_KINDS = [
|
||||
'brand',
|
||||
'store',
|
||||
'avatar',
|
||||
'website',
|
||||
'temp',
|
||||
] as const;
|
||||
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
@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<string, string>;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('محصول یافت نشد');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<number | null, typeof categories>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('آیتم یافت نشد');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user