feat: product design surveys, cart expiry/buy-later, and catalog polish
Add like/dislike surveys with majority alerts for manufacturing decisions, clearer accounting mapping copy, and related cart/catalog admin-app improvements. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
-- Product detail opens in the customer app, for the admin «آمار» screen.
|
||||
CREATE TABLE "ProductView" (
|
||||
"id" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ProductView_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "ProductView_productId_createdAt_idx" ON "ProductView"("productId", "createdAt");
|
||||
CREATE INDEX "ProductView_createdAt_idx" ON "ProductView"("createdAt");
|
||||
|
||||
ALTER TABLE "ProductView" ADD CONSTRAINT "ProductView_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Product labels from the «برچسبها» list. IF NOT EXISTS: some databases
|
||||
-- already carry this column from an earlier, unmerged migration.
|
||||
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
UPDATE "Product" SET "tags" = ARRAY[]::TEXT[] WHERE "tags" IS NULL;
|
||||
ALTER TABLE "Product" ALTER COLUMN "tags" SET DEFAULT ARRAY[]::TEXT[];
|
||||
ALTER TABLE "Product" ALTER COLUMN "tags" SET NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Product_tags_idx" ON "Product" USING GIN ("tags");
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Server-side customer cart for daily expiry + reminder SMS (cart.expiry).
|
||||
CREATE TABLE "CustomerCart" (
|
||||
"customerId" TEXT NOT NULL,
|
||||
"items" JSONB NOT NULL DEFAULT '[]',
|
||||
"reminderSentForClearAt" TIMESTAMP(3),
|
||||
"clearedAt" TIMESTAMP(3),
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CustomerCart_pkey" PRIMARY KEY ("customerId")
|
||||
);
|
||||
|
||||
ALTER TABLE "CustomerCart" ADD CONSTRAINT "CustomerCart_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Deferred «خرید بعدی» list — separate from CustomerCart so daily expiry
|
||||
-- and order-clear never touch it, and stock is never reserved.
|
||||
CREATE TABLE "CustomerBuyLater" (
|
||||
"customerId" TEXT NOT NULL,
|
||||
"items" JSONB NOT NULL DEFAULT '[]',
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CustomerBuyLater_pkey" PRIMARY KEY ("customerId")
|
||||
);
|
||||
|
||||
ALTER TABLE "CustomerBuyLater" ADD CONSTRAINT "CustomerBuyLater_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Product design survey (like/dislike before manufacturing).
|
||||
CREATE TYPE "ProductSurveyStatus" AS ENUM ('PENDING_REVIEW', 'OPEN', 'CLOSED', 'APPROVED', 'REJECTED');
|
||||
CREATE TYPE "ProductSurveyVoteValue" AS ENUM ('LIKE', 'DISLIKE');
|
||||
|
||||
CREATE TABLE "ProductSurvey" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT,
|
||||
"imageUrl" TEXT NOT NULL,
|
||||
"suggestedWeightGrams" DECIMAL(14,4) NOT NULL,
|
||||
"status" "ProductSurveyStatus" NOT NULL DEFAULT 'PENDING_REVIEW',
|
||||
"createdByCustomerId" TEXT,
|
||||
"createdByAdminId" TEXT,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"decidedByAdminId" TEXT,
|
||||
"decisionNote" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductSurvey_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "ProductSurveyVote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"surveyId" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"value" "ProductSurveyVoteValue" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductSurveyVote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "ProductSurvey_status_idx" ON "ProductSurvey"("status");
|
||||
CREATE INDEX "ProductSurvey_createdByCustomerId_idx" ON "ProductSurvey"("createdByCustomerId");
|
||||
CREATE INDEX "ProductSurvey_createdAt_idx" ON "ProductSurvey"("createdAt");
|
||||
CREATE INDEX "ProductSurveyVote_customerId_idx" ON "ProductSurveyVote"("customerId");
|
||||
CREATE UNIQUE INDEX "ProductSurveyVote_surveyId_customerId_key" ON "ProductSurveyVote"("surveyId", "customerId");
|
||||
|
||||
ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_createdByCustomerId_fkey" FOREIGN KEY ("createdByCustomerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_createdByAdminId_fkey" FOREIGN KEY ("createdByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_decidedByAdminId_fkey" FOREIGN KEY ("decidedByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "ProductSurveyVote" ADD CONSTRAINT "ProductSurveyVote_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "ProductSurvey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "ProductSurveyVote" ADD CONSTRAINT "ProductSurveyVote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT 'product.survey', 85
|
||||
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -181,6 +181,8 @@ model Admin {
|
||||
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
|
||||
sentNotifications Notification[] @relation("NotificationAuthor")
|
||||
uploadedFiles UploadedFile[] @relation("FileUploader")
|
||||
createdProductSurveys ProductSurvey[] @relation("SurveyAdminCreator")
|
||||
decidedProductSurveys ProductSurvey[] @relation("SurveyDecider")
|
||||
|
||||
/// Issued admin-panel tokens, recorded only while session tracking is on
|
||||
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
|
||||
@@ -564,12 +566,51 @@ model Customer {
|
||||
activityLogs CustomerActivityLog[]
|
||||
otpChallenges CustomerOtp[]
|
||||
favorites CustomerFavorite[]
|
||||
productSurveys ProductSurvey[] @relation("SurveyCustomerCreator")
|
||||
productSurveyVotes ProductSurveyVote[]
|
||||
/// App shopping cart synced from user-app-v2 so daily expiry + reminder SMS
|
||||
/// can run on the server (localStorage alone cannot).
|
||||
cart CustomerCart?
|
||||
/// Deferred «خرید بعدی» list — never reserved against stock and never
|
||||
/// cleared by the daily cart-expiry worker.
|
||||
buyLater CustomerBuyLater?
|
||||
|
||||
@@index([referredByAgentId])
|
||||
@@index([firstName])
|
||||
@@index([lastName])
|
||||
}
|
||||
|
||||
/// Server-side mirror of the customer-app cart (`appCart` in localStorage).
|
||||
/// Empty `items` means no basket; the daily expiry worker clears it and may
|
||||
/// SMS a reminder before the configured clear time (Setting `cart.expiry`).
|
||||
model CustomerCart {
|
||||
customerId String @id
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Cart lines as JSON — same shape the app stores (pid, vid, qty, …).
|
||||
items Json @default("[]")
|
||||
|
||||
/// Which clear-deadline this cart already got a reminder SMS for (ISO of
|
||||
/// that deadline). Null = never reminded for the current cycle.
|
||||
reminderSentForClearAt DateTime?
|
||||
|
||||
clearedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
/// «سبد خرید بعدی» — wish/defer list. Same line shape as the cart, but:
|
||||
/// never decrements stock, never participates in checkout, never cleared by
|
||||
/// cart.expiry. Moving a line here only removes it from the active cart.
|
||||
model CustomerBuyLater {
|
||||
customerId String @id
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
items Json @default("[]")
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
/// Legacy `fav` (251 rows) — saved products. The target had no model for this,
|
||||
/// so every customer's favourites would have been lost in the migration; the
|
||||
/// owner confirmed on 2026-10-06 that they matter.
|
||||
@@ -694,6 +735,9 @@ model Product {
|
||||
showType Int @default(3)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
/// Labels picked from the «ثابتها → برچسبها» list (Setting `__list__tags`).
|
||||
/// Shown on the app's product cards; tagged products get their own home section.
|
||||
tags String[] @default([])
|
||||
|
||||
/// Stock of the linked کارساخته as last read from the accounting software
|
||||
/// (D27). Weight for gram-type items, count for count-type ones — whichever
|
||||
@@ -710,6 +754,7 @@ model Product {
|
||||
promotions Promotion[]
|
||||
allocationItems BranchAllocationItem[]
|
||||
favoritedBy CustomerFavorite[]
|
||||
views ProductView[]
|
||||
|
||||
// Storefront paging (owner: the real catalogue reaches ~16k products, so
|
||||
// GET /catalog/products must page rather than return everything). This model
|
||||
@@ -722,6 +767,19 @@ model Product {
|
||||
@@index([isActive, createdAt])
|
||||
@@index([categoryId])
|
||||
@@index([brandId])
|
||||
@@index([tags], type: Gin)
|
||||
}
|
||||
|
||||
/// One opening of a product's detail screen in the customer app. Read by the
|
||||
/// admin «آمار» screen for the most-viewed products and brands.
|
||||
model ProductView {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([productId, createdAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
@@ -1097,6 +1155,69 @@ model Comment {
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Product design survey — before manufacturing, a design (photo + suggested
|
||||
// weight) is shown for like/dislike. Admin or customer can create; customer
|
||||
// submissions need admin publish. Admin decides APPROVED/REJECTED by hand.
|
||||
// Not linked to Product / PreOrder.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum ProductSurveyStatus {
|
||||
PENDING_REVIEW
|
||||
OPEN
|
||||
CLOSED
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ProductSurveyVoteValue {
|
||||
LIKE
|
||||
DISLIKE
|
||||
}
|
||||
|
||||
model ProductSurvey {
|
||||
id String @id @default(cuid())
|
||||
title String?
|
||||
imageUrl String
|
||||
suggestedWeightGrams Decimal @db.Decimal(14, 4)
|
||||
status ProductSurveyStatus @default(PENDING_REVIEW)
|
||||
|
||||
createdByCustomerId String?
|
||||
createdByCustomer Customer? @relation("SurveyCustomerCreator", fields: [createdByCustomerId], references: [id])
|
||||
createdByAdminId String?
|
||||
createdByAdmin Admin? @relation("SurveyAdminCreator", fields: [createdByAdminId], references: [id])
|
||||
|
||||
publishedAt DateTime?
|
||||
decidedAt DateTime?
|
||||
decidedByAdminId String?
|
||||
decidedByAdmin Admin? @relation("SurveyDecider", fields: [decidedByAdminId], references: [id])
|
||||
decisionNote String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
votes ProductSurveyVote[]
|
||||
|
||||
@@index([status])
|
||||
@@index([createdByCustomerId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ProductSurveyVote {
|
||||
id String @id @default(cuid())
|
||||
surveyId String
|
||||
survey ProductSurvey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
value ProductSurveyVoteValue
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([surveyId, customerId])
|
||||
@@index([customerId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Settings — replaces legacy `config_static`, a key/value table the admin
|
||||
// panel read and wrote through endpoints like /wallet/config and
|
||||
|
||||
Reference in New Issue
Block a user