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[]
|
blogs BlogPost[]
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
fulfillments OrderStoreFulfillment[]
|
fulfillments OrderStoreFulfillment[]
|
||||||
|
websiteSlots WebsiteSlotItem[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
id String @id @default(cuid())
|
id Int @id @default(autoincrement())
|
||||||
storeId String
|
storeId String
|
||||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
parentId String?
|
parentId Int?
|
||||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
|
||||||
children Category[] @relation("CategoryTree")
|
children Category[] @relation("CategoryTree")
|
||||||
nameFa String
|
nameFa String
|
||||||
nameEn String @default("")
|
nameEn String @default("")
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
colors String[] @default([])
|
colors String[] @default([])
|
||||||
sizes String[] @default([])
|
sizes String[] @default([])
|
||||||
packages String[] @default([])
|
packages String[] @default([])
|
||||||
products Product[]
|
/// Ordered form-builder defs: [{ id, labelFa, labelEn?, type: "text", required? }]
|
||||||
|
technicalFields Json @default("[]")
|
||||||
|
products Product[]
|
||||||
|
|
||||||
@@index([storeId])
|
@@index([storeId])
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
@@ -169,10 +172,10 @@ model Brand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
id String @id @default(cuid())
|
id Int @id @default(autoincrement())
|
||||||
storeId String
|
storeId String
|
||||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||||
categoryId String?
|
categoryId Int?
|
||||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
brandId String?
|
brandId String?
|
||||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||||
@@ -188,10 +191,14 @@ model Product {
|
|||||||
galleryKeys String[] @default([])
|
galleryKeys String[] @default([])
|
||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
published Boolean @default(true)
|
published Boolean @default(true)
|
||||||
|
/// Home carousel featured slot: promo-1 | promo-2 | promo-3
|
||||||
|
featuredSlot String?
|
||||||
vipEnabled Boolean @default(false)
|
vipEnabled Boolean @default(false)
|
||||||
vipOnly Boolean @default(false)
|
vipOnly Boolean @default(false)
|
||||||
vipSpecialPrice Boolean @default(false)
|
vipSpecialPrice Boolean @default(false)
|
||||||
vipPrice Int?
|
vipPrice Int?
|
||||||
|
/// Values keyed by category technical field id: { [fieldId]: string }
|
||||||
|
technicalData Json @default("{}")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
variants ProductVariant[]
|
variants ProductVariant[]
|
||||||
@@ -202,13 +209,14 @@ model Product {
|
|||||||
@@index([brandId])
|
@@index([brandId])
|
||||||
@@index([price])
|
@@index([price])
|
||||||
@@index([published])
|
@@index([published])
|
||||||
|
@@index([featuredSlot])
|
||||||
@@index([vipEnabled])
|
@@index([vipEnabled])
|
||||||
@@index([vipOnly])
|
@@index([vipOnly])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProductVariant {
|
model ProductVariant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
productId String
|
productId Int
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
colorId String @default("")
|
colorId String @default("")
|
||||||
sizeId String @default("")
|
sizeId String @default("")
|
||||||
@@ -289,7 +297,7 @@ model OrderItem {
|
|||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
storeId String
|
storeId String
|
||||||
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
store Store @relation(fields: [storeId], references: [id], onDelete: Restrict)
|
||||||
productId String?
|
productId Int?
|
||||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
nameFa String
|
nameFa String
|
||||||
variantLabel String?
|
variantLabel String?
|
||||||
@@ -313,3 +321,26 @@ model OrderStoreFulfillment {
|
|||||||
@@index([storeId])
|
@@index([storeId])
|
||||||
@@index([status])
|
@@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 { SmsModule } from './sms/sms.module';
|
||||||
import { StorageModule } from './storage/storage.module';
|
import { StorageModule } from './storage/storage.module';
|
||||||
import { StoresModule } from './stores/stores.module';
|
import { StoresModule } from './stores/stores.module';
|
||||||
|
import { WebsiteSlotsModule } from './website-slots/website-slots.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -36,6 +37,7 @@ import { StoresModule } from './stores/stores.module';
|
|||||||
CustomersModule,
|
CustomersModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
ProfileModule,
|
ProfileModule,
|
||||||
|
WebsiteSlotsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -40,14 +41,17 @@ export class CategoriesController {
|
|||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateCategoryDto,
|
@Body() dto: UpdateCategoryDto,
|
||||||
) {
|
) {
|
||||||
return this.categories.update(user, id, dto);
|
return this.categories.update(user, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@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);
|
return this.categories.remove(user, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { UserRole } from '@prisma/client';
|
import { Prisma, UserRole } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { StoreScopeService } from '../common/store-scope.service';
|
import { StoreScopeService } from '../common/store-scope.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -42,11 +42,13 @@ export class CategoriesService {
|
|||||||
colors: dto.colors ?? [],
|
colors: dto.colors ?? [],
|
||||||
sizes: dto.sizes ?? [],
|
sizes: dto.sizes ?? [],
|
||||||
packages: dto.packages ?? [],
|
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 } });
|
const existing = await this.prisma.category.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException('دستهبندی یافت نشد');
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
@@ -63,11 +65,17 @@ export class CategoriesService {
|
|||||||
...(dto.colors !== undefined ? { colors: dto.colors } : {}),
|
...(dto.colors !== undefined ? { colors: dto.colors } : {}),
|
||||||
...(dto.sizes !== undefined ? { sizes: dto.sizes } : {}),
|
...(dto.sizes !== undefined ? { sizes: dto.sizes } : {}),
|
||||||
...(dto.packages !== undefined ? { packages: dto.packages } : {}),
|
...(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 } });
|
const existing = await this.prisma.category.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException('دستهبندی یافت نشد');
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
@@ -77,10 +85,10 @@ export class CategoriesService {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildTree<
|
private buildTree<T extends { id: number; parentId: number | null }>(
|
||||||
T extends { id: string; parentId: string | null },
|
items: T[],
|
||||||
>(items: T[]) {
|
) {
|
||||||
const byParent = new Map<string | null, T[]>();
|
const byParent = new Map<number | null, T[]>();
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const key = item.parentId;
|
const key = item.parentId;
|
||||||
const list = byParent.get(key) ?? [];
|
const list = byParent.get(key) ?? [];
|
||||||
@@ -88,7 +96,9 @@ export class CategoriesService {
|
|||||||
byParent.set(key, list);
|
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) => ({
|
(byParent.get(parentId) ?? []).map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
children: walk(node.id),
|
children: walk(node.id),
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} 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 {
|
export class CreateCategoryDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@@ -17,8 +42,9 @@ export class CreateCategoryDto {
|
|||||||
nameEn?: string;
|
nameEn?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Type(() => Number)
|
||||||
parentId?: string;
|
@IsInt()
|
||||||
|
parentId?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -40,6 +66,12 @@ export class CreateCategoryDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
packages?: string[];
|
packages?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TechnicalFieldDto)
|
||||||
|
technicalFields?: TechnicalFieldDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
@@ -56,8 +88,9 @@ export class UpdateCategoryDto {
|
|||||||
nameEn?: string;
|
nameEn?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Type(() => Number)
|
||||||
parentId?: string | null;
|
@IsInt()
|
||||||
|
parentId?: number | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -78,4 +111,10 @@ export class UpdateCategoryDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
packages?: string[];
|
packages?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => TechnicalFieldDto)
|
||||||
|
technicalFields?: TechnicalFieldDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const MEDIA_KINDS = [
|
|||||||
'brand',
|
'brand',
|
||||||
'store',
|
'store',
|
||||||
'avatar',
|
'avatar',
|
||||||
|
'website',
|
||||||
'temp',
|
'temp',
|
||||||
] as const;
|
] as const;
|
||||||
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
storeMediaKey,
|
storeMediaKey,
|
||||||
tempMediaKey,
|
tempMediaKey,
|
||||||
userMediaKey,
|
userMediaKey,
|
||||||
|
websiteMediaKey,
|
||||||
} from '../storage/storage-keys';
|
} from '../storage/storage-keys';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
import { MediaKind } from './dto/media-upload-query.dto';
|
import { MediaKind } from './dto/media-upload-query.dto';
|
||||||
@@ -85,6 +86,8 @@ export class MediaService {
|
|||||||
return storeMediaKey(fileName);
|
return storeMediaKey(fileName);
|
||||||
case 'avatar':
|
case 'avatar':
|
||||||
return userMediaKey(fileName);
|
return userMediaKey(fileName);
|
||||||
|
case 'website':
|
||||||
|
return websiteMediaKey(fileName);
|
||||||
case 'temp':
|
case 'temp':
|
||||||
return tempMediaKey(fileName);
|
return tempMediaKey(fileName);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
@@ -69,8 +70,9 @@ export class CreateProductDto {
|
|||||||
discountPrice?: number;
|
discountPrice?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Type(() => Number)
|
||||||
categoryId?: string;
|
@IsInt()
|
||||||
|
categoryId?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -103,6 +105,10 @@ export class CreateProductDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredSlot?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
vipEnabled?: boolean;
|
vipEnabled?: boolean;
|
||||||
@@ -120,6 +126,10 @@ export class CreateProductDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
vipPrice?: number;
|
vipPrice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
technicalData?: Record<string, string>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@@ -161,8 +171,9 @@ export class UpdateProductDto {
|
|||||||
discountPrice?: number | null;
|
discountPrice?: number | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@Type(() => Number)
|
||||||
categoryId?: string | null;
|
@IsInt()
|
||||||
|
categoryId?: number | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -195,6 +206,10 @@ export class UpdateProductDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
published?: boolean;
|
published?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
featuredSlot?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
vipEnabled?: boolean;
|
vipEnabled?: boolean;
|
||||||
@@ -212,6 +227,10 @@ export class UpdateProductDto {
|
|||||||
@Min(0)
|
@Min(0)
|
||||||
vipPrice?: number | null;
|
vipPrice?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
technicalData?: Record<string, string>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -36,17 +37,27 @@ export class ProductsController {
|
|||||||
@Query('minPrice') minPrice?: number,
|
@Query('minPrice') minPrice?: number,
|
||||||
@Query('maxPrice') maxPrice?: number,
|
@Query('maxPrice') maxPrice?: number,
|
||||||
) {
|
) {
|
||||||
|
const parsedCategoryId =
|
||||||
|
categoryId !== undefined && categoryId !== ''
|
||||||
|
? Number(categoryId)
|
||||||
|
: undefined;
|
||||||
return this.products.list(user, {
|
return this.products.list(user, {
|
||||||
storeId,
|
storeId,
|
||||||
name,
|
name,
|
||||||
categoryId,
|
categoryId:
|
||||||
|
parsedCategoryId !== undefined && Number.isFinite(parsedCategoryId)
|
||||||
|
? parsedCategoryId
|
||||||
|
: undefined,
|
||||||
minPrice,
|
minPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@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);
|
return this.products.findOne(user, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,14 +69,17 @@ export class ProductsController {
|
|||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateProductDto,
|
@Body() dto: UpdateProductDto,
|
||||||
) {
|
) {
|
||||||
return this.products.update(user, id, dto);
|
return this.products.update(user, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@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);
|
return this.products.remove(user, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class ProductsService {
|
|||||||
query: {
|
query: {
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
categoryId?: string;
|
categoryId?: number;
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: 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({
|
const product = await this.prisma.product.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { variants: true, category: true, brand: true },
|
include: { variants: true, category: true, brand: true },
|
||||||
@@ -97,10 +97,13 @@ export class ProductsService {
|
|||||||
galleryKeys: dto.galleryKeys ?? [],
|
galleryKeys: dto.galleryKeys ?? [],
|
||||||
tags: dto.tags ?? [],
|
tags: dto.tags ?? [],
|
||||||
published: dto.published ?? true,
|
published: dto.published ?? true,
|
||||||
|
featuredSlot: dto.featuredSlot ?? null,
|
||||||
vipEnabled: dto.vipEnabled ?? false,
|
vipEnabled: dto.vipEnabled ?? false,
|
||||||
vipOnly: dto.vipOnly ?? false,
|
vipOnly: dto.vipOnly ?? false,
|
||||||
vipSpecialPrice: dto.vipSpecialPrice ?? false,
|
vipSpecialPrice: dto.vipSpecialPrice ?? false,
|
||||||
vipPrice: dto.vipPrice,
|
vipPrice: dto.vipPrice,
|
||||||
|
technicalData: (dto.technicalData ??
|
||||||
|
{}) as unknown as Prisma.InputJsonValue,
|
||||||
variants: dto.variants?.length
|
variants: dto.variants?.length
|
||||||
? {
|
? {
|
||||||
create: dto.variants.map((v) => ({
|
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 } });
|
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException('محصول یافت نشد');
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
@@ -170,6 +173,9 @@ export class ProductsService {
|
|||||||
: {}),
|
: {}),
|
||||||
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
...(dto.published !== undefined ? { published: dto.published } : {}),
|
...(dto.published !== undefined ? { published: dto.published } : {}),
|
||||||
|
...(dto.featuredSlot !== undefined
|
||||||
|
? { featuredSlot: dto.featuredSlot }
|
||||||
|
: {}),
|
||||||
...(dto.vipEnabled !== undefined
|
...(dto.vipEnabled !== undefined
|
||||||
? { vipEnabled: dto.vipEnabled }
|
? { vipEnabled: dto.vipEnabled }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -178,13 +184,19 @@ export class ProductsService {
|
|||||||
? { vipSpecialPrice: dto.vipSpecialPrice }
|
? { vipSpecialPrice: dto.vipSpecialPrice }
|
||||||
: {}),
|
: {}),
|
||||||
...(dto.vipPrice !== undefined ? { vipPrice: dto.vipPrice } : {}),
|
...(dto.vipPrice !== undefined ? { vipPrice: dto.vipPrice } : {}),
|
||||||
|
...(dto.technicalData !== undefined
|
||||||
|
? {
|
||||||
|
technicalData:
|
||||||
|
dto.technicalData as unknown as Prisma.InputJsonValue,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
include: { variants: true },
|
include: { variants: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(actor: AuthUser, id: string) {
|
async remove(actor: AuthUser, id: number) {
|
||||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException('محصول یافت نشد');
|
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';
|
import { PublicService } from './public.service';
|
||||||
|
|
||||||
@Controller('public')
|
@Controller('public')
|
||||||
@@ -19,23 +19,53 @@ export class PublicController {
|
|||||||
@Query('minPrice') minPrice?: number,
|
@Query('minPrice') minPrice?: number,
|
||||||
@Query('maxPrice') maxPrice?: number,
|
@Query('maxPrice') maxPrice?: number,
|
||||||
) {
|
) {
|
||||||
|
const parsedCategoryId =
|
||||||
|
categoryId !== undefined && categoryId !== ''
|
||||||
|
? Number(categoryId)
|
||||||
|
: undefined;
|
||||||
return this.publicService.listProducts({
|
return this.publicService.listProducts({
|
||||||
storeId,
|
storeId,
|
||||||
storeSlug,
|
storeSlug,
|
||||||
name,
|
name,
|
||||||
categoryId,
|
categoryId:
|
||||||
|
parsedCategoryId !== undefined && Number.isFinite(parsedCategoryId)
|
||||||
|
? parsedCategoryId
|
||||||
|
: undefined,
|
||||||
minPrice,
|
minPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('products/:id')
|
@Get('products/:id')
|
||||||
findProduct(@Param('id') id: string) {
|
findProduct(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.publicService.findProduct(id);
|
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')
|
@Get('blogs')
|
||||||
listBlogs(@Query('storeSlug') storeSlug?: string) {
|
listBlogs(@Query('storeSlug') storeSlug?: string) {
|
||||||
return this.publicService.listBlogs(storeSlug);
|
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 { Prisma, StoreStatus } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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()
|
@Injectable()
|
||||||
export class PublicService {
|
export class PublicService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -25,7 +35,7 @@ export class PublicService {
|
|||||||
storeId?: string;
|
storeId?: string;
|
||||||
storeSlug?: string;
|
storeSlug?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
categoryId?: string;
|
categoryId?: number;
|
||||||
minPrice?: number;
|
minPrice?: number;
|
||||||
maxPrice?: 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({
|
const product = await this.prisma.product.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
@@ -95,6 +105,119 @@ export class PublicService {
|
|||||||
return product;
|
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) {
|
listBlogs(storeSlug?: string) {
|
||||||
return this.prisma.blogPost.findMany({
|
return this.prisma.blogPost.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -110,4 +233,35 @@ export class PublicService {
|
|||||||
orderBy: { createdAt: 'desc' },
|
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}`;
|
return `${ROOT}/stores/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function websiteMediaKey(fileName: string): string {
|
||||||
|
return `${ROOT}/website/${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function tempMediaKey(fileName: string): string {
|
export function tempMediaKey(fileName: string): string {
|
||||||
return `${ROOT}/temp/${fileName}`;
|
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