Add SMS OTP auth, discounts, customer orders, and category slugs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:15:10 +03:30
co-authored by Cursor
parent 58380ab81d
commit d09eca8702
48 changed files with 1719 additions and 38 deletions
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OrderItem" ALTER COLUMN "quantity" SET DATA TYPE DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "Order" ALTER COLUMN "itemCount" SET DATA TYPE DOUBLE PRECISION;
@@ -0,0 +1,27 @@
-- Renumber existing low order numbers to start at 1000, then seed the sequence.
DO $$
DECLARE
seq_name text;
seed bigint;
r record;
i integer := 0;
BEGIN
-- Move current numbers out of the way to avoid unique collisions
UPDATE "Order" SET number = number + 1000000 WHERE number < 1000;
-- Assign contiguous numbers from 1000 for any that were shifted
FOR r IN
SELECT id FROM "Order" WHERE number >= 1000000 ORDER BY number ASC
LOOP
UPDATE "Order" SET number = 1000 + i WHERE id = r.id;
i := i + 1;
END LOOP;
seq_name := pg_get_serial_sequence('"Order"', 'number');
IF seq_name IS NULL THEN
RAISE EXCEPTION 'Order.number sequence not found';
END IF;
SELECT GREATEST(999, COALESCE(MAX(number), 999)) INTO seed FROM "Order";
PERFORM setval(seq_name, seed, true);
END $$;
@@ -0,0 +1,38 @@
-- CreateTable
CREATE TABLE "Discount" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"categoryId" TEXT,
"userId" TEXT NOT NULL,
"minOrderAmount" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"percent" INTEGER NOT NULL,
"maxValue" INTEGER NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdByAdminId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Discount_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Discount_code_key" ON "Discount"("code");
-- CreateIndex
CREATE INDEX "Discount_userId_idx" ON "Discount"("userId");
-- CreateIndex
CREATE INDEX "Discount_categoryId_idx" ON "Discount"("categoryId");
-- CreateIndex
CREATE INDEX "Discount_active_expiresAt_idx" ON "Discount"("active", "expiresAt");
-- AddForeignKey
ALTER TABLE "Discount" ADD CONSTRAINT "Discount_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Discount" ADD CONSTRAINT "Discount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Discount" ADD CONSTRAINT "Discount_createdByAdminId_fkey" FOREIGN KEY ("createdByAdminId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Discount" ALTER COLUMN "userId" DROP NOT NULL;
@@ -0,0 +1,38 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "slug" TEXT;
-- Backfill from nameEn (latin slug). Keep unique with id suffix on collision.
UPDATE "Category" AS c
SET "slug" = lower(
regexp_replace(
regexp_replace(trim(c."nameEn"), '[^a-zA-Z0-9]+', '-', 'g'),
'(^-+|-+$)',
'',
'g'
)
);
UPDATE "Category"
SET "slug" = 'category'
WHERE "slug" IS NULL OR "slug" = '';
UPDATE "Category" AS c
SET "slug" = c."slug" || '-' || right(c."id", 4)
WHERE c."id" IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY slug ORDER BY "createdAt", id) AS rn
FROM "Category"
) d
WHERE rn > 1
);
ALTER TABLE "Category" ALTER COLUMN "slug" SET NOT NULL;
-- Prefer readable Persian-romanized top-level slugs
UPDATE "Category" SET "slug" = 'shirini' WHERE "nameFa" = 'شیرینی' AND "parentId" IS NULL;
UPDATE "Category" SET "slug" = 'cake' WHERE "nameFa" = 'کیک' AND "parentId" IS NULL;
UPDATE "Category" SET "slug" = 'shirini-tar' WHERE "nameFa" = 'شیرینی تر';
UPDATE "Category" SET "slug" = 'shirini-khoshk' WHERE "nameFa" = 'شیرینی خشک';
UPDATE "Category" SET "slug" = 'cream-cake' WHERE "nameFa" = 'کیک خامه';
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "discountCode" TEXT;
ALTER TABLE "Order" ADD COLUMN "discountAmount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,22 @@
-- CreateEnum
CREATE TYPE "SmsOtpPurpose" AS ENUM ('register');
-- CreateTable
CREATE TABLE "SmsOtp" (
"id" TEXT NOT NULL,
"cellNumber" TEXT NOT NULL,
"purpose" "SmsOtpPurpose" NOT NULL,
"codeHash" TEXT NOT NULL,
"payload" JSONB,
"attempts" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsOtp_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SmsOtp_cellNumber_purpose_idx" ON "SmsOtp"("cellNumber", "purpose");
-- CreateIndex
CREATE INDEX "SmsOtp_expiresAt_idx" ON "SmsOtp"("expiresAt");
@@ -0,0 +1,3 @@
-- AlterEnum
ALTER TYPE "SmsOtpPurpose" ADD VALUE 'login';
ALTER TYPE "SmsOtpPurpose" ADD VALUE 'resetPassword';
+2
View File
@@ -1 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+51 -2
View File
@@ -49,6 +49,8 @@ model User {
refreshTokens RefreshToken[]
addresses UserAddress[]
orders Order[]
discounts Discount[] @relation("DiscountAssignee")
discountsCreated Discount[] @relation("DiscountCreatedBy")
@@index([role])
@@index([disabled])
@@ -80,6 +82,27 @@ model RefreshToken {
@@index([tokenHash])
}
enum SmsOtpPurpose {
register
login
resetPassword
}
model SmsOtp {
id String @id @default(cuid())
cellNumber String
purpose SmsOtpPurpose
codeHash String
/// Pending registration fields (firstName, lastName, title, passwordHash)
payload Json?
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([cellNumber, purpose])
@@index([expiresAt])
}
model Flavor {
id String @id @default(cuid())
nameFa String
@@ -94,6 +117,7 @@ model Category {
id String @id @default(cuid())
nameFa String
nameEn String
slug String @unique
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Category[] @relation("CategoryTree")
@@ -102,6 +126,7 @@ model Category {
updatedAt DateTime @updatedAt
optionBlocks CategoryOptionBlock[]
products Product[]
discounts Discount[]
@@index([parentId])
}
@@ -215,7 +240,9 @@ model Order {
shippingAddressLine String?
shippingLandline String?
note String @default("")
itemCount Int
discountCode String?
discountAmount Int @default(0)
itemCount Float
totalPrice Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -234,7 +261,7 @@ model OrderItem {
productId String?
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
nameFa String
quantity Int
quantity Float
unitPrice Int
sellUnit SellUnit
sortOrder Int @default(0)
@@ -254,3 +281,25 @@ model OrderItemOption {
@@index([orderItemId])
}
model Discount {
id String @id @default(cuid())
code String @unique
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
userId String?
user User? @relation("DiscountAssignee", fields: [userId], references: [id], onDelete: Cascade)
minOrderAmount Int @default(0)
expiresAt DateTime
percent Int
maxValue Int
active Boolean @default(true)
createdByAdminId String?
createdByAdmin User? @relation("DiscountCreatedBy", fields: [createdByAdminId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([categoryId])
@@index([active, expiresAt])
}