mirror of
https://git.meshkee.com/BaloutPastry/backend.git
synced 2026-08-11 22:31:00 +04:30
Add SMS OTP auth, discounts, customer orders, and category slugs.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
58380ab81d
commit
d09eca8702
+7
-1
@@ -9,7 +9,7 @@ DATABASE_URL=postgresql://balout:balout_secret@localhost:5434/balout_pastry
|
|||||||
|
|
||||||
# API
|
# API
|
||||||
PORT=3100
|
PORT=3100
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGIN=http://baloutpastry.com:5173,http://admin.baloutpastry.com:5173,http://customer.baloutpastry.com:5173,http://localhost:5173,http://127.0.0.1:5173,http://localhost:5174,http://127.0.0.1:5174,http://baloutpastry.com:5174,http://www.baloutpastry.com:5174
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_ACCESS_SECRET=change-me-balout-access-secret-min-32-chars
|
JWT_ACCESS_SECRET=change-me-balout-access-secret-min-32-chars
|
||||||
@@ -28,3 +28,9 @@ S3_ACCESS_KEY_ID=
|
|||||||
S3_SECRET_ACCESS_KEY=
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
|
||||||
MEDIA_MAX_FILE_SIZE_MB=10
|
MEDIA_MAX_FILE_SIZE_MB=10
|
||||||
|
|
||||||
|
# Meshkee SMS (server-side only — never expose to frontend)
|
||||||
|
MESHKEE_SMS_URL=https://api.meshkee.com/api/v1/public/sms/send
|
||||||
|
MESHKEE_SMS_API_KEY=
|
||||||
|
MESHKEE_SMS_DOMAIN=baloutpastry.com
|
||||||
|
|
||||||
|
|||||||
+21
-3
@@ -46,8 +46,12 @@ Health check: `GET http://localhost:3100/api/v1/auth/me` → `401` without token
|
|||||||
|
|
||||||
1. Keep this API on **3100**
|
1. Keep this API on **3100**
|
||||||
2. In dashboards: `VITE_API_BASE_URL=http://localhost:3100/api/v1`
|
2. In dashboards: `VITE_API_BASE_URL=http://localhost:3100/api/v1`
|
||||||
3. Backend `CORS_ORIGIN` must include `http://localhost:5173`
|
3. Backend `CORS_ORIGIN` must include local dashboard origins, e.g.
|
||||||
4. Log in with the super-admin phone/password you created
|
`http://baloutpastry.com:5173,http://admin.baloutpastry.com:5173,http://customer.baloutpastry.com:5173,http://localhost:5173`
|
||||||
|
4. Add local DNS in `/etc/hosts` for `baloutpastry.com`, `admin.baloutpastry.com`, `customer.baloutpastry.com` → `127.0.0.1`
|
||||||
|
5. Log in with the super-admin phone/password you created
|
||||||
|
- Admin: `http://admin.baloutpastry.com:5173`
|
||||||
|
- Customer: `http://customer.baloutpastry.com:5173`
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@@ -62,6 +66,7 @@ Health check: `GET http://localhost:3100/api/v1/auth/me` → `401` without token
|
|||||||
| `npm run prisma:deploy` | Apply existing migrations (CI / new device) |
|
| `npm run prisma:deploy` | Apply existing migrations (CI / new device) |
|
||||||
| `npm run prisma:generate` | Regenerate Prisma Client |
|
| `npm run prisma:generate` | Regenerate Prisma Client |
|
||||||
| `npm run create-super-admin` | Create first `superAdmin` user |
|
| `npm run create-super-admin` | Create first `superAdmin` user |
|
||||||
|
| `npm run send-sms` | Send SMS via Meshkee (`--to` / `--message`) |
|
||||||
| `npm run lint` | ESLint |
|
| `npm run lint` | ESLint |
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
@@ -79,12 +84,24 @@ Copy from `.env.example`. Do **not** commit `.env`.
|
|||||||
| `STORAGE_DISK` | `s3` for Parspack |
|
| `STORAGE_DISK` | `s3` for Parspack |
|
||||||
| `S3_*` | Endpoint, bucket, public URL, keys |
|
| `S3_*` | Endpoint, bucket, public URL, keys |
|
||||||
| `MEDIA_MAX_FILE_SIZE_MB` | Upload size cap |
|
| `MEDIA_MAX_FILE_SIZE_MB` | Upload size cap |
|
||||||
|
| `MESHKEE_SMS_URL` | Meshkee public SMS send endpoint |
|
||||||
|
| `MESHKEE_SMS_API_KEY` | Partner API key (`X-Api-Key`) — server only |
|
||||||
|
| `MESHKEE_SMS_DOMAIN` | Partner domain, e.g. `baloutpastry.com` |
|
||||||
|
|
||||||
|
Inject `SmsService` from `SmsModule` to send SMS from the backend (never from the frontend). Limits: 30/partner/min, 5/destination/min.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run send-sms -- --to 09127004945 --message "متن پیام"
|
||||||
|
```
|
||||||
|
|
||||||
Ports are intentional vs Meshkee: API **3100**, Postgres host **5434** (Meshkee uses 3000 / 5432).
|
Ports are intentional vs Meshkee: API **3100**, Postgres host **5434** (Meshkee uses 3000 / 5432).
|
||||||
|
|
||||||
## Auth rules
|
## Auth rules
|
||||||
|
|
||||||
- Login: `POST /auth/login` with `{ "cellNumber": "09…", "password": "…" }`
|
- Login: `POST /auth/login` with `{ "cellNumber": "09…", "password": "…" }`
|
||||||
|
- Login with SMS: `POST /auth/login/send-code` then `POST /auth/login/verify`
|
||||||
|
- Forgot password: `POST /auth/forgot-password/send-code` → `/verify` → `/reset`
|
||||||
|
- Register: `POST /auth/register/send-code` then `POST /auth/register/verify` (SMS OTP → create `customer`)
|
||||||
- Only `admin` and `superAdmin` can log in to the admin API/dashboard
|
- Only `admin` and `superAdmin` can log in to the admin API/dashboard
|
||||||
- Only `superAdmin` can assign `admin` or `superAdmin` roles
|
- Only `superAdmin` can assign `admin` or `superAdmin` roles
|
||||||
- `customer` users exist for orders / future customer UI
|
- `customer` users exist for orders / future customer UI
|
||||||
@@ -94,7 +111,7 @@ Ports are intentional vs Meshkee: API **3100**, Postgres host **5434** (Meshkee
|
|||||||
|
|
||||||
| Area | Methods |
|
| Area | Methods |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Auth | `POST /auth/login`, `/auth/refresh`, `/auth/logout`, `GET /auth/me` |
|
| Auth | `POST /auth/login`, `/auth/login/send-code`, `/auth/login/verify`, `/auth/forgot-password/send-code`, `/auth/forgot-password/verify`, `/auth/forgot-password/reset`, `/auth/register/send-code`, `/auth/register/verify`, `/auth/refresh`, `/auth/logout`, `GET /auth/me` |
|
||||||
| Users | CRUD + `PATCH /users/:id/role`, `/password` + addresses under `/users/:id/addresses` |
|
| Users | CRUD + `PATCH /users/:id/role`, `/password` + addresses under `/users/:id/addresses` |
|
||||||
| Flavors | CRUD |
|
| Flavors | CRUD |
|
||||||
| Categories | tree CRUD + `GET\|PUT /categories/:id/options` |
|
| Categories | tree CRUD + `GET\|PUT /categories/:id/options` |
|
||||||
@@ -175,4 +192,5 @@ Ensure Postgres is reachable via `DATABASE_URL` and `CORS_ORIGIN` lists the real
|
|||||||
## Related
|
## Related
|
||||||
|
|
||||||
- Dashboards setup: clone `BaloutPastry/dashboards` and read `CONTEXT.md`
|
- Dashboards setup: clone `BaloutPastry/dashboards` and read `CONTEXT.md`
|
||||||
|
- Website (storefront): clone `BaloutPastry/website` and read `CONTEXT.md` (dev port **5174**)
|
||||||
- Short API overview also in `README.md`
|
- Short API overview also in `README.md`
|
||||||
|
|||||||
+2
-1
@@ -23,7 +23,8 @@
|
|||||||
"prisma:deploy": "prisma migrate deploy",
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
"db:up": "docker compose up -d",
|
"db:up": "docker compose up -d",
|
||||||
"db:down": "docker compose down",
|
"db:down": "docker compose down",
|
||||||
"create-super-admin": "ts-node -r tsconfig-paths/register scripts/create-super-admin.ts"
|
"create-super-admin": "ts-node -r tsconfig-paths/register scripts/create-super-admin.ts",
|
||||||
|
"send-sms": "ts-node -r tsconfig-paths/register scripts/send-sms.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1101.0",
|
"@aws-sdk/client-s3": "^3.1101.0",
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -1 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
|||||||
+51
-2
@@ -49,6 +49,8 @@ model User {
|
|||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
addresses UserAddress[]
|
addresses UserAddress[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
discounts Discount[] @relation("DiscountAssignee")
|
||||||
|
discountsCreated Discount[] @relation("DiscountCreatedBy")
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
@@index([disabled])
|
@@index([disabled])
|
||||||
@@ -80,6 +82,27 @@ model RefreshToken {
|
|||||||
@@index([tokenHash])
|
@@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 {
|
model Flavor {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
nameFa String
|
nameFa String
|
||||||
@@ -94,6 +117,7 @@ model Category {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
nameFa String
|
nameFa String
|
||||||
nameEn String
|
nameEn String
|
||||||
|
slug String @unique
|
||||||
parentId String?
|
parentId String?
|
||||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||||
children Category[] @relation("CategoryTree")
|
children Category[] @relation("CategoryTree")
|
||||||
@@ -102,6 +126,7 @@ model Category {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
optionBlocks CategoryOptionBlock[]
|
optionBlocks CategoryOptionBlock[]
|
||||||
products Product[]
|
products Product[]
|
||||||
|
discounts Discount[]
|
||||||
|
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
}
|
}
|
||||||
@@ -215,7 +240,9 @@ model Order {
|
|||||||
shippingAddressLine String?
|
shippingAddressLine String?
|
||||||
shippingLandline String?
|
shippingLandline String?
|
||||||
note String @default("")
|
note String @default("")
|
||||||
itemCount Int
|
discountCode String?
|
||||||
|
discountAmount Int @default(0)
|
||||||
|
itemCount Float
|
||||||
totalPrice Int
|
totalPrice Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -234,7 +261,7 @@ model OrderItem {
|
|||||||
productId String?
|
productId String?
|
||||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
nameFa String
|
nameFa String
|
||||||
quantity Int
|
quantity Float
|
||||||
unitPrice Int
|
unitPrice Int
|
||||||
sellUnit SellUnit
|
sellUnit SellUnit
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
@@ -254,3 +281,25 @@ model OrderItemOption {
|
|||||||
|
|
||||||
@@index([orderItemId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Send an SMS via Meshkee (backend-only; never call from frontend).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npm run send-sms -- --to 09127004945 --message "متن پیام"
|
||||||
|
*/
|
||||||
|
import { config as loadEnv } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
loadEnv({ path: resolve(__dirname, '../.env') });
|
||||||
|
|
||||||
|
function arg(name: string, fallback?: string): string {
|
||||||
|
const idx = process.argv.indexOf(`--${name}`);
|
||||||
|
if (idx >= 0 && process.argv[idx + 1]) return process.argv[idx + 1];
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
throw new Error(`Missing --${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const to = arg('to');
|
||||||
|
const message = arg('message');
|
||||||
|
const apiUrl =
|
||||||
|
process.env.MESHKEE_SMS_URL ??
|
||||||
|
'https://api.meshkee.com/api/v1/public/sms/send';
|
||||||
|
const apiKey = process.env.MESHKEE_SMS_API_KEY;
|
||||||
|
const domain = process.env.MESHKEE_SMS_DOMAIN ?? 'baloutpastry.com';
|
||||||
|
|
||||||
|
if (!apiKey) throw new Error('MESHKEE_SMS_API_KEY is not set in .env');
|
||||||
|
if (!/^09\d{9}$/.test(to)) throw new Error('to must match 09xxxxxxxxx');
|
||||||
|
if (!message.trim()) throw new Error('message is empty');
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ domain, to, message }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !body?.success) {
|
||||||
|
console.error('SMS send failed', { status: response.status, body });
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SMS sent', { to, serverId: body.serverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CategoriesModule } from './categories/categories.module';
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
|
import { DiscountsModule } from './discounts/discounts.module';
|
||||||
import { FlavorsModule } from './flavors/flavors.module';
|
import { FlavorsModule } from './flavors/flavors.module';
|
||||||
import { MediaModule } from './media/media.module';
|
import { MediaModule } from './media/media.module';
|
||||||
import { OrdersModule } from './orders/orders.module';
|
import { OrdersModule } from './orders/orders.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
import { SmsModule } from './sms/sms.module';
|
||||||
import { StorageModule } from './storage/storage.module';
|
import { StorageModule } from './storage/storage.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
|
||||||
@@ -16,12 +18,14 @@ import { UsersModule } from './users/users.module';
|
|||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
|
SmsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
FlavorsModule,
|
FlavorsModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
|
DiscountsModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import type { AuthUser } from './auth.types';
|
import type { AuthUser } from './auth.types';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CurrentUser } from './decorators/current-user.decorator';
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
import { Roles } from './decorators/roles.decorator';
|
import { CellNumberDto } from './dto/cell-number.dto';
|
||||||
|
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||||
|
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './guards/roles.guard';
|
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -18,6 +20,41 @@ export class AuthController {
|
|||||||
return this.auth.login(dto);
|
return this.auth.login(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('login/send-code')
|
||||||
|
loginSendCode(@Body() dto: CellNumberDto) {
|
||||||
|
return this.auth.loginSendCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login/verify')
|
||||||
|
loginVerify(@Body() dto: OtpVerifyDto) {
|
||||||
|
return this.auth.loginVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('forgot-password/send-code')
|
||||||
|
forgotPasswordSendCode(@Body() dto: CellNumberDto) {
|
||||||
|
return this.auth.forgotPasswordSendCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('forgot-password/verify')
|
||||||
|
forgotPasswordVerify(@Body() dto: OtpVerifyDto) {
|
||||||
|
return this.auth.forgotPasswordVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('forgot-password/reset')
|
||||||
|
forgotPasswordReset(@Body() dto: ForgotPasswordResetDto) {
|
||||||
|
return this.auth.forgotPasswordReset(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register/send-code')
|
||||||
|
registerSendCode(@Body() dto: RegisterSendCodeDto) {
|
||||||
|
return this.auth.registerSendCode(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('register/verify')
|
||||||
|
registerVerify(@Body() dto: RegisterVerifyDto) {
|
||||||
|
return this.auth.registerVerify(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
refresh(@Body() dto: RefreshTokenDto) {
|
refresh(@Body() dto: RefreshTokenDto) {
|
||||||
return this.auth.refresh(dto.refreshToken);
|
return this.auth.refresh(dto.refreshToken);
|
||||||
@@ -29,8 +66,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
|
||||||
me(@CurrentUser() user: AuthUser) {
|
me(@CurrentUser() user: AuthUser) {
|
||||||
return this.auth.me(user);
|
return this.auth.me(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { PassportModule } from '@nestjs/passport';
|
import { PassportModule } from '@nestjs/passport';
|
||||||
|
import { SmsModule } from '../sms/sms.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
SmsModule,
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
@@ -28,4 +30,4 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
providers: [AuthService, JwtStrategy],
|
providers: [AuthService, JwtStrategy],
|
||||||
exports: [AuthService, JwtModule],
|
exports: [AuthService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
+291
-7
@@ -1,15 +1,43 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { SmsOtp, SmsOtpPurpose, UserRole } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { createHash, randomBytes } from 'crypto';
|
import { createHash, randomBytes, randomInt } from 'crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuthUser, displayName, isElevatedRole } from './auth.types';
|
import { SmsService } from '../sms/sms.service';
|
||||||
|
import { AuthUser, displayName } from './auth.types';
|
||||||
|
import { CellNumberDto } from './dto/cell-number.dto';
|
||||||
|
import { ForgotPasswordResetDto } from './dto/forgot-password-reset.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { OtpVerifyDto } from './dto/otp-verify.dto';
|
||||||
|
import { RegisterSendCodeDto } from './dto/register-send-code.dto';
|
||||||
|
import { RegisterVerifyDto } from './dto/register-verify.dto';
|
||||||
|
|
||||||
|
const DEFAULT_CUSTOMER_TITLE = 'جناب آقای';
|
||||||
|
const OTP_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const OTP_RESEND_COOLDOWN_MS = 60 * 1000;
|
||||||
|
const OTP_MAX_ATTEMPTS = 5;
|
||||||
|
const OTP_LENGTH = 6;
|
||||||
|
|
||||||
|
type RegisterOtpPayload = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
title: string;
|
||||||
|
passwordHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OtpSendResult = {
|
||||||
|
ok: true;
|
||||||
|
expiresInSeconds: number;
|
||||||
|
resendAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -17,6 +45,7 @@ export class AuthService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(dto: LoginDto) {
|
async login(dto: LoginDto) {
|
||||||
@@ -32,10 +61,6 @@ export class AuthService {
|
|||||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isElevatedRole(user.role)) {
|
|
||||||
throw new ForbiddenException('فقط ادمین میتواند وارد پنل شود');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||||
@@ -44,6 +69,146 @@ export class AuthService {
|
|||||||
return this.issueTokens(user);
|
return this.issueTokens(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loginSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||||
|
const user = await this.requireActiveUser(dto.cellNumber);
|
||||||
|
return this.createAndSendOtp({
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.login,
|
||||||
|
message: (code) => `کد ورود بلوط: ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginVerify(dto: OtpVerifyDto) {
|
||||||
|
await this.requireActiveUser(dto.cellNumber);
|
||||||
|
await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.login,
|
||||||
|
code: dto.code,
|
||||||
|
consume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUniqueOrThrow({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
});
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPasswordSendCode(dto: CellNumberDto): Promise<OtpSendResult> {
|
||||||
|
const user = await this.requireActiveUser(dto.cellNumber);
|
||||||
|
return this.createAndSendOtp({
|
||||||
|
cellNumber: user.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.resetPassword,
|
||||||
|
message: (code) => `کد بازیابی رمز بلوط: ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPasswordVerify(dto: OtpVerifyDto) {
|
||||||
|
await this.requireActiveUser(dto.cellNumber);
|
||||||
|
await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.resetPassword,
|
||||||
|
code: dto.code,
|
||||||
|
consume: false,
|
||||||
|
});
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
async forgotPasswordReset(dto: ForgotPasswordResetDto) {
|
||||||
|
await this.requireActiveUser(dto.cellNumber);
|
||||||
|
await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.resetPassword,
|
||||||
|
code: dto.code,
|
||||||
|
consume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
data: { passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate existing sessions after password change
|
||||||
|
await this.prisma.refreshToken.deleteMany({ where: { userId: user.id } });
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerSendCode(dto: RegisterSendCodeDto): Promise<OtpSendResult> {
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
const payload: RegisterOtpPayload = {
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
title: DEFAULT_CUSTOMER_TITLE,
|
||||||
|
passwordHash,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createAndSendOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.register,
|
||||||
|
payload,
|
||||||
|
message: (code) => `کد تأیید بلوط: ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerVerify(dto: RegisterVerifyDto) {
|
||||||
|
const otp = await this.assertValidOtp({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
purpose: SmsOtpPurpose.register,
|
||||||
|
code: dto.code,
|
||||||
|
consume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = otp.payload as RegisterOtpPayload | null;
|
||||||
|
if (
|
||||||
|
!payload?.firstName ||
|
||||||
|
!payload?.lastName ||
|
||||||
|
!payload?.passwordHash ||
|
||||||
|
!payload?.title
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'اطلاعات ثبتنام نامعتبر است. دوباره شروع کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
title: payload.title,
|
||||||
|
firstName: payload.firstName,
|
||||||
|
lastName: payload.lastName,
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
passwordHash: payload.passwordHash,
|
||||||
|
role: UserRole.customer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.issueTokens(user);
|
||||||
|
}
|
||||||
|
|
||||||
async refresh(refreshToken: string) {
|
async refresh(refreshToken: string) {
|
||||||
const tokenHash = this.hashToken(refreshToken);
|
const tokenHash = this.hashToken(refreshToken);
|
||||||
const stored = await this.prisma.refreshToken.findFirst({
|
const stored = await this.prisma.refreshToken.findFirst({
|
||||||
@@ -59,7 +224,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = stored.user;
|
const user = stored.user;
|
||||||
if (user.disabled || !isElevatedRole(user.role)) {
|
if (user.disabled) {
|
||||||
throw new UnauthorizedException('نشست نامعتبر است');
|
throw new UnauthorizedException('نشست نامعتبر است');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +255,125 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async requireActiveUser(cellNumber: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { cellNumber } });
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException('کاربری با این شماره یافت نشد');
|
||||||
|
}
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createAndSendOtp(input: {
|
||||||
|
cellNumber: string;
|
||||||
|
purpose: SmsOtpPurpose;
|
||||||
|
payload?: RegisterOtpPayload;
|
||||||
|
message: (code: string) => string;
|
||||||
|
}): Promise<OtpSendResult> {
|
||||||
|
const latest = await this.prisma.smsOtp.findFirst({
|
||||||
|
where: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
latest &&
|
||||||
|
Date.now() - latest.createdAt.getTime() < OTP_RESEND_COOLDOWN_MS
|
||||||
|
) {
|
||||||
|
const waitSec = Math.ceil(
|
||||||
|
(OTP_RESEND_COOLDOWN_MS - (Date.now() - latest.createdAt.getTime())) /
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
throw new BadRequestException(
|
||||||
|
`لطفاً ${waitSec} ثانیه دیگر دوباره تلاش کنید`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = String(randomInt(0, 10 ** OTP_LENGTH)).padStart(
|
||||||
|
OTP_LENGTH,
|
||||||
|
'0',
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.smsOtp.deleteMany({
|
||||||
|
where: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.smsOtp.create({
|
||||||
|
data: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
codeHash: this.hashToken(code),
|
||||||
|
payload: input.payload ?? undefined,
|
||||||
|
expiresAt: new Date(Date.now() + OTP_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sms.send({
|
||||||
|
to: input.cellNumber,
|
||||||
|
message: input.message(code),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
expiresInSeconds: Math.floor(OTP_TTL_MS / 1000),
|
||||||
|
resendAfterSeconds: Math.floor(OTP_RESEND_COOLDOWN_MS / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates OTP. When consume=true, deletes it on success. */
|
||||||
|
private async assertValidOtp(input: {
|
||||||
|
cellNumber: string;
|
||||||
|
purpose: SmsOtpPurpose;
|
||||||
|
code: string;
|
||||||
|
consume: boolean;
|
||||||
|
}): Promise<SmsOtp> {
|
||||||
|
const otp = await this.prisma.smsOtp.findFirst({
|
||||||
|
where: {
|
||||||
|
cellNumber: input.cellNumber,
|
||||||
|
purpose: input.purpose,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!otp || otp.expiresAt < new Date()) {
|
||||||
|
if (otp) {
|
||||||
|
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||||
|
}
|
||||||
|
throw new BadRequestException(
|
||||||
|
'کد تأیید منقضی شده است. دوباره درخواست کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otp.attempts >= OTP_MAX_ATTEMPTS) {
|
||||||
|
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||||
|
throw new BadRequestException(
|
||||||
|
'تعداد تلاشها بیش از حد مجاز است. دوباره درخواست کنید',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeHash = this.hashToken(input.code.trim());
|
||||||
|
if (codeHash !== otp.codeHash) {
|
||||||
|
await this.prisma.smsOtp.update({
|
||||||
|
where: { id: otp.id },
|
||||||
|
data: { attempts: { increment: 1 } },
|
||||||
|
});
|
||||||
|
throw new BadRequestException('کد تأیید نادرست است');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.consume) {
|
||||||
|
await this.prisma.smsOtp.delete({ where: { id: otp.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return otp;
|
||||||
|
}
|
||||||
|
|
||||||
private async issueTokens(user: {
|
private async issueTokens(user: {
|
||||||
id: string;
|
id: string;
|
||||||
cellNumber: string;
|
cellNumber: string;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class CellNumberDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsString, Length, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ForgotPasswordResetDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||||
|
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||||
|
newPassword!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class OtpVerifyDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||||
|
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterSendCodeDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'نام الزامی است' })
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1, { message: 'نام خانوادگی الزامی است' })
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(4, { message: 'رمز عبور باید حداقل ۴ کاراکتر باشد' })
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, Matches, Length } from 'class-validator';
|
||||||
|
|
||||||
|
export class RegisterVerifyDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(4, 8, { message: 'کد تأیید نامعتبر است' })
|
||||||
|
@Matches(/^\d+$/, { message: 'کد تأیید نامعتبر است' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { AuthUser, isElevatedRole } from '../auth.types';
|
import { AuthUser } from '../auth.types';
|
||||||
|
|
||||||
type JwtPayload = {
|
type JwtPayload = {
|
||||||
sub: string;
|
sub: string;
|
||||||
@@ -29,7 +29,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
where: { id: payload.sub },
|
where: { id: payload.sub },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || user.disabled || !isElevatedRole(user.role)) {
|
if (!user || user.disabled) {
|
||||||
throw new UnauthorizedException('نشست نامعتبر است');
|
throw new UnauthorizedException('نشست نامعتبر است');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,37 +19,46 @@ import { ReplaceCategoryOptionsDto } from './dto/replace-category-options.dto';
|
|||||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
|
|
||||||
@Controller('categories')
|
@Controller('categories')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
|
||||||
export class CategoriesController {
|
export class CategoriesController {
|
||||||
constructor(private readonly categories: CategoriesService) {}
|
constructor(private readonly categories: CategoriesService) {}
|
||||||
|
|
||||||
|
/** Public category tree for storefront filters */
|
||||||
@Get()
|
@Get()
|
||||||
listTree() {
|
listTree() {
|
||||||
return this.categories.listTree();
|
return this.categories.listTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
create(@Body() dto: CreateCategoryDto) {
|
create(@Body() dto: CreateCategoryDto) {
|
||||||
return this.categories.create(dto);
|
return this.categories.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
||||||
return this.categories.update(id, dto);
|
return this.categories.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.categories.remove(id);
|
return this.categories.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/options')
|
@Get(':id/options')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
getOptions(@Param('id') id: string) {
|
getOptions(@Param('id') id: string) {
|
||||||
return this.categories.getOptions(id);
|
return this.categories.getOptions(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/options')
|
@Put(':id/options')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
replaceOptions(
|
replaceOptions(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: ReplaceCategoryOptionsDto,
|
@Body() dto: ReplaceCategoryOptionsDto,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { slugify } from '../common/slugify';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { buildCategoryTree } from './categories.utils';
|
import { buildCategoryTree } from './categories.utils';
|
||||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
@@ -26,10 +27,13 @@ export class CategoriesService {
|
|||||||
await this.ensureExists(dto.parentId);
|
await this.ensureExists(dto.parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slug = await this.ensureUniqueSlug(dto.slug?.trim() || slugify(dto.nameEn));
|
||||||
|
|
||||||
return this.prisma.category.create({
|
return this.prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
nameFa: dto.nameFa,
|
nameFa: dto.nameFa,
|
||||||
nameEn: dto.nameEn,
|
nameEn: dto.nameEn,
|
||||||
|
slug,
|
||||||
parentId: dto.parentId ?? null,
|
parentId: dto.parentId ?? null,
|
||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
},
|
},
|
||||||
@@ -46,11 +50,19 @@ export class CategoriesService {
|
|||||||
await this.ensureExists(dto.parentId);
|
await this.ensureExists(dto.parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slug =
|
||||||
|
dto.slug !== undefined
|
||||||
|
? await this.ensureUniqueSlug(dto.slug.trim() || slugify(dto.nameEn || ''), id)
|
||||||
|
: dto.nameEn !== undefined
|
||||||
|
? await this.ensureUniqueSlug(slugify(dto.nameEn), id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return this.prisma.category.update({
|
return this.prisma.category.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||||
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||||
|
...(slug !== undefined ? { slug } : {}),
|
||||||
...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}),
|
...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}),
|
||||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
},
|
},
|
||||||
@@ -134,4 +146,21 @@ export class CategoriesService {
|
|||||||
}
|
}
|
||||||
return category;
|
return category;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureUniqueSlug(base: string, excludeId?: string) {
|
||||||
|
const normalized = slugify(base);
|
||||||
|
let candidate = normalized;
|
||||||
|
let suffix = 2;
|
||||||
|
while (true) {
|
||||||
|
const existing = await this.prisma.category.findUnique({
|
||||||
|
where: { slug: candidate },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing || existing.id === excludeId) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
candidate = `${normalized}-${suffix}`;
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
import { IsInt, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class CreateCategoryDto {
|
export class CreateCategoryDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -9,6 +9,13 @@ export class CreateCategoryDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
nameEn!: string;
|
nameEn!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message: 'slug باید فقط شامل حروف انگلیسی کوچک، عدد و خط تیره باشد',
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
import { IsInt, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateCategoryDto {
|
export class UpdateCategoryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -11,6 +11,13 @@ export class UpdateCategoryDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
nameEn?: string;
|
nameEn?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message: 'slug باید فقط شامل حروف انگلیسی کوچک، عدد و خط تیره باشد',
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Build a URL-safe slug from English (or latin) text. */
|
||||||
|
export function slugify(input: string) {
|
||||||
|
const base = input
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/['']/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
return base || 'category'
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserRole } from '@prisma/client';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
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 { CreateDiscountDto } from './dto/create-discount.dto';
|
||||||
|
import { ListDiscountsQueryDto } from './dto/list-discounts-query.dto';
|
||||||
|
import { UpdateDiscountDto } from './dto/update-discount.dto';
|
||||||
|
import { DiscountsService } from './discounts.service';
|
||||||
|
|
||||||
|
@Controller('discounts')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
|
export class DiscountsController {
|
||||||
|
constructor(private readonly discounts: DiscountsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: ListDiscountsQueryDto) {
|
||||||
|
return this.discounts.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('mine')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
listMine(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Query() query: ListDiscountsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.discounts.listMine(actor.id, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() actor: AuthUser, @Body() dto: CreateDiscountDto) {
|
||||||
|
return this.discounts.create(actor.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateDiscountDto) {
|
||||||
|
return this.discounts.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.discounts.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DiscountsController } from './discounts.controller';
|
||||||
|
import { DiscountsService } from './discounts.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DiscountsController],
|
||||||
|
providers: [DiscountsService],
|
||||||
|
exports: [DiscountsService],
|
||||||
|
})
|
||||||
|
export class DiscountsModule {}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { displayName } from '../auth/auth.types';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateDiscountDto } from './dto/create-discount.dto';
|
||||||
|
import { ListDiscountsQueryDto } from './dto/list-discounts-query.dto';
|
||||||
|
import { UpdateDiscountDto } from './dto/update-discount.dto';
|
||||||
|
|
||||||
|
const discountInclude = {
|
||||||
|
category: { select: { id: true, nameFa: true, nameEn: true } },
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
cellNumber: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.DiscountInclude;
|
||||||
|
|
||||||
|
type DiscountRow = Prisma.DiscountGetPayload<{ include: typeof discountInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DiscountsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: ListDiscountsQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const code = query.code?.trim() || query.q?.trim();
|
||||||
|
const userFilter = query.user?.trim();
|
||||||
|
const expiresRange = this.expiresOnRange(query.expiresOn);
|
||||||
|
|
||||||
|
const where: Prisma.DiscountWhereInput = {
|
||||||
|
...(query.generalOnly ? { userId: null } : {}),
|
||||||
|
...(query.userId ? { userId: query.userId } : {}),
|
||||||
|
...(query.active !== undefined ? { active: query.active } : {}),
|
||||||
|
...(code
|
||||||
|
? { code: { contains: code, mode: 'insensitive' as const } }
|
||||||
|
: {}),
|
||||||
|
...(expiresRange ? { expiresAt: expiresRange } : {}),
|
||||||
|
...(userFilter
|
||||||
|
? userFilter === 'عمومی'
|
||||||
|
? { userId: null }
|
||||||
|
: {
|
||||||
|
user: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
firstName: {
|
||||||
|
contains: userFilter,
|
||||||
|
mode: 'insensitive' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lastName: {
|
||||||
|
contains: userFilter,
|
||||||
|
mode: 'insensitive' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ cellNumber: { contains: userFilter } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.discount.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: discountInclude,
|
||||||
|
}),
|
||||||
|
this.prisma.discount.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => this.serialize(row)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMine(userId: string, query: ListDiscountsQueryDto) {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const where: Prisma.DiscountWhereInput = {
|
||||||
|
OR: [{ userId: null }, { userId }],
|
||||||
|
...(query.active !== undefined ? { active: query.active } : {}),
|
||||||
|
...(query.q
|
||||||
|
? {
|
||||||
|
code: { contains: query.q, mode: 'insensitive' as const },
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows, total] = await Promise.all([
|
||||||
|
this.prisma.discount.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: discountInclude,
|
||||||
|
}),
|
||||||
|
this.prisma.discount.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => this.serialize(row)),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(actorId: string, dto: CreateDiscountDto) {
|
||||||
|
const userId = dto.userId?.trim() ? dto.userId.trim() : null;
|
||||||
|
if (userId) {
|
||||||
|
await this.ensureUser(userId);
|
||||||
|
}
|
||||||
|
if (dto.categoryId) {
|
||||||
|
await this.ensureCategory(dto.categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = dto.code.trim().toUpperCase();
|
||||||
|
await this.ensureCodeAvailable(code);
|
||||||
|
this.assertExpiresAt(dto.expiresAt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.discount.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
userId,
|
||||||
|
categoryId: dto.categoryId ?? null,
|
||||||
|
minOrderAmount: dto.minOrderAmount,
|
||||||
|
expiresAt: new Date(dto.expiresAt),
|
||||||
|
percent: dto.percent,
|
||||||
|
maxValue: dto.maxValue,
|
||||||
|
active: dto.active ?? true,
|
||||||
|
createdByAdminId: actorId,
|
||||||
|
},
|
||||||
|
include: discountInclude,
|
||||||
|
});
|
||||||
|
return this.serialize(row);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException('این کد تخفیف قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateDiscountDto) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
|
||||||
|
const nextUserId =
|
||||||
|
dto.userId === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.userId?.trim()
|
||||||
|
? dto.userId.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (nextUserId) {
|
||||||
|
await this.ensureUser(nextUserId);
|
||||||
|
}
|
||||||
|
if (dto.categoryId) {
|
||||||
|
await this.ensureCategory(dto.categoryId);
|
||||||
|
}
|
||||||
|
if (dto.expiresAt) {
|
||||||
|
this.assertExpiresAt(dto.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = dto.code?.trim().toUpperCase();
|
||||||
|
if (code) {
|
||||||
|
await this.ensureCodeAvailable(code, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.discount.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(code ? { code } : {}),
|
||||||
|
...(nextUserId !== undefined ? { userId: nextUserId } : {}),
|
||||||
|
...(dto.categoryId !== undefined
|
||||||
|
? { categoryId: dto.categoryId }
|
||||||
|
: {}),
|
||||||
|
...(dto.minOrderAmount !== undefined
|
||||||
|
? { minOrderAmount: dto.minOrderAmount }
|
||||||
|
: {}),
|
||||||
|
...(dto.expiresAt !== undefined
|
||||||
|
? { expiresAt: new Date(dto.expiresAt) }
|
||||||
|
: {}),
|
||||||
|
...(dto.percent !== undefined ? { percent: dto.percent } : {}),
|
||||||
|
...(dto.maxValue !== undefined ? { maxValue: dto.maxValue } : {}),
|
||||||
|
...(dto.active !== undefined ? { active: dto.active } : {}),
|
||||||
|
},
|
||||||
|
include: discountInclude,
|
||||||
|
});
|
||||||
|
return this.serialize(row);
|
||||||
|
} catch (err) {
|
||||||
|
if (
|
||||||
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
err.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException('این کد تخفیف قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
await this.ensureExists(id);
|
||||||
|
await this.prisma.discount.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(row: DiscountRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
category: row.category
|
||||||
|
? {
|
||||||
|
id: row.category.id,
|
||||||
|
nameFa: row.category.nameFa,
|
||||||
|
nameEn: row.category.nameEn,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
userId: row.userId,
|
||||||
|
user: row.user
|
||||||
|
? {
|
||||||
|
id: row.user.id,
|
||||||
|
title: row.user.title,
|
||||||
|
firstName: row.user.firstName,
|
||||||
|
lastName: row.user.lastName,
|
||||||
|
cellNumber: row.user.cellNumber,
|
||||||
|
role: row.user.role,
|
||||||
|
name: displayName(row.user),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
minOrderAmount: row.minOrderAmount,
|
||||||
|
expiresAt: row.expiresAt.toISOString(),
|
||||||
|
percent: row.percent,
|
||||||
|
maxValue: row.maxValue,
|
||||||
|
active: row.active,
|
||||||
|
createdByAdminId: row.createdByAdminId,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
expired: row.expiresAt.getTime() < Date.now(),
|
||||||
|
general: row.userId === null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private expiresOnRange(value?: string) {
|
||||||
|
if (!value?.trim()) return undefined;
|
||||||
|
const day = new Date(value);
|
||||||
|
if (Number.isNaN(day.getTime())) return undefined;
|
||||||
|
const start = new Date(day);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(day);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
return { gte: start, lte: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertExpiresAt(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new BadRequestException('تاریخ انقضا نامعتبر است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCodeAvailable(code: string, excludeId?: string) {
|
||||||
|
const existing = await this.prisma.discount.findFirst({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
...(excludeId ? { NOT: { id: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('این کد تخفیف قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureUser(id: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('کاربر یافت نشد');
|
||||||
|
}
|
||||||
|
if (user.disabled) {
|
||||||
|
throw new BadRequestException('کاربر غیرفعال است');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCategory(id: string) {
|
||||||
|
const category = await this.prisma.category.findUnique({ where: { id } });
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExists(id: string) {
|
||||||
|
const discount = await this.prisma.discount.findUnique({ where: { id } });
|
||||||
|
if (!discount) {
|
||||||
|
throw new NotFoundException('کد تخفیف یافت نشد');
|
||||||
|
}
|
||||||
|
return discount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CreateDiscountDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/, {
|
||||||
|
message: 'کد تخفیف فقط میتواند شامل حروف، عدد، - و _ باشد',
|
||||||
|
})
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
/** null/omitted = general code usable by all users */
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== undefined && value !== '')
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
userId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== undefined)
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string | null;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
minOrderAmount!: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
expiresAt!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
percent!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxValue!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ListDiscountsQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
/** Match discounts expiring on this day (ISO date or datetime). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiresOn?: string;
|
||||||
|
|
||||||
|
/** User name/phone search, or «عمومی» for general codes. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
user?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
/** When true, only discounts with no assigned user (usable by everyone). */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === 'true' || value === true) return true;
|
||||||
|
if (value === 'false' || value === false) return false;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
generalOnly?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => {
|
||||||
|
if (value === 'true' || value === true) return true;
|
||||||
|
if (value === 'false' || value === false) return false;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class UpdateDiscountDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@Matches(/^[A-Za-z0-9_-]+$/, {
|
||||||
|
message: 'کد تخفیف فقط میتواند شامل حروف، عدد، - و _ باشد',
|
||||||
|
})
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== undefined && value !== '')
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
userId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== undefined)
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
minOrderAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
expiresAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
percent?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
maxValue?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { DeliveryType } from '@prisma/client';
|
||||||
|
import { CreateOrderItemDto } from './create-order.dto';
|
||||||
|
|
||||||
|
/** Storefront checkout — customerId comes from JWT. */
|
||||||
|
export class CreateMyOrderDto {
|
||||||
|
@IsEnum(DeliveryType)
|
||||||
|
deliveryType!: DeliveryType;
|
||||||
|
|
||||||
|
@ValidateIf((dto: CreateMyOrderDto) => dto.deliveryType === DeliveryType.pickup)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
branchId?: string;
|
||||||
|
|
||||||
|
@ValidateIf(
|
||||||
|
(dto: CreateMyOrderDto) => dto.deliveryType === DeliveryType.shipping,
|
||||||
|
)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
shippingAddressId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
discountCode?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CreateOrderItemDto)
|
||||||
|
items!: CreateOrderItemDto[];
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Min,
|
Min,
|
||||||
@@ -18,8 +18,9 @@ export class CreateOrderItemDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
productId!: string;
|
productId!: string;
|
||||||
|
|
||||||
@IsInt()
|
@Type(() => Number)
|
||||||
@Min(1)
|
@IsNumber({ maxDecimalPlaces: 3 })
|
||||||
|
@Min(0.1)
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -52,6 +53,10 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
discountCode?: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
|
import type { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { CreateMyOrderDto } from './dto/create-my-order.dto';
|
||||||
import { CreateOrderDto } from './dto/create-order.dto';
|
import { CreateOrderDto } from './dto/create-order.dto';
|
||||||
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
|
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
|
||||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||||
@@ -28,6 +31,38 @@ export class OrdersController {
|
|||||||
return this.orders.list(query);
|
return this.orders.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('mine')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
listMine(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Query() query: ListOrdersQueryDto,
|
||||||
|
) {
|
||||||
|
return this.orders.list({
|
||||||
|
...query,
|
||||||
|
customerId: actor.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mine')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
createMine(@CurrentUser() actor: AuthUser, @Body() dto: CreateMyOrderDto) {
|
||||||
|
return this.orders.create({
|
||||||
|
customerId: actor.id,
|
||||||
|
deliveryType: dto.deliveryType,
|
||||||
|
branchId: dto.branchId,
|
||||||
|
shippingAddressId: dto.shippingAddressId,
|
||||||
|
note: dto.note?.trim() || undefined,
|
||||||
|
discountCode: dto.discountCode?.trim() || undefined,
|
||||||
|
items: dto.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('mine/:id')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
findMine(@CurrentUser() actor: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.orders.findOneForCustomer(actor.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.orders.findOne(id);
|
return this.orders.findOne(id);
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export class OrdersService {
|
|||||||
return this.serializeOrder(order);
|
return this.serializeOrder(order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findOneForCustomer(customerId: string, id: string) {
|
||||||
|
const order = await this.prisma.order.findFirst({
|
||||||
|
where: { id, customerId },
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: { options: { orderBy: { sortOrder: 'asc' } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.serializeOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreateOrderDto) {
|
async create(dto: CreateOrderDto) {
|
||||||
const customer = await this.prisma.user.findUnique({
|
const customer = await this.prisma.user.findUnique({
|
||||||
where: { id: dto.customerId },
|
where: { id: dto.customerId },
|
||||||
@@ -110,10 +128,18 @@ export class OrdersService {
|
|||||||
const lineInputs = await this.resolveItems(dto.items);
|
const lineInputs = await this.resolveItems(dto.items);
|
||||||
|
|
||||||
const itemCount = lineInputs.reduce((sum, item) => sum + item.quantity, 0);
|
const itemCount = lineInputs.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
const totalPrice = lineInputs.reduce(
|
const itemsSubtotal = Math.round(
|
||||||
(sum, item) => sum + item.quantity * item.unitTotal,
|
lineInputs.reduce(
|
||||||
0,
|
(sum, item) => sum + item.quantity * item.unitTotal,
|
||||||
|
0,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
const discount = await this.resolveDiscount(
|
||||||
|
dto.discountCode,
|
||||||
|
customer.id,
|
||||||
|
itemsSubtotal,
|
||||||
|
);
|
||||||
|
const totalPrice = Math.max(0, itemsSubtotal - discount.discountAmount);
|
||||||
|
|
||||||
const order = await this.prisma.$transaction(async (tx) => {
|
const order = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.order.create({
|
const created = await tx.order.create({
|
||||||
@@ -130,6 +156,8 @@ export class OrdersService {
|
|||||||
shippingAddressLine: delivery.shippingAddressLine,
|
shippingAddressLine: delivery.shippingAddressLine,
|
||||||
shippingLandline: delivery.shippingLandline,
|
shippingLandline: delivery.shippingLandline,
|
||||||
note: dto.note?.trim() ?? '',
|
note: dto.note?.trim() ?? '',
|
||||||
|
discountCode: discount.discountCode,
|
||||||
|
discountAmount: discount.discountAmount,
|
||||||
itemCount,
|
itemCount,
|
||||||
totalPrice,
|
totalPrice,
|
||||||
items: {
|
items: {
|
||||||
@@ -252,6 +280,18 @@ export class OrdersService {
|
|||||||
throw new NotFoundException(`محصول یافت نشد: ${item.productId}`);
|
throw new NotFoundException(`محصول یافت نشد: ${item.productId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (product.sellUnit === 'unit') {
|
||||||
|
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`تعداد «${product.nameFa}» باید عدد صحیح و حداقل ۱ باشد`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!(item.quantity >= 0.1)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`وزن «${product.nameFa}» باید حداقل ۰٫۱ کیلو باشد`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const selectedIds = item.optionIds ?? [];
|
const selectedIds = item.optionIds ?? [];
|
||||||
const optionsById = new Map(
|
const optionsById = new Map(
|
||||||
product.options.map((option) => [option.id, option]),
|
product.options.map((option) => [option.id, option]),
|
||||||
@@ -314,6 +354,8 @@ export class OrdersService {
|
|||||||
totalPrice: order.totalPrice,
|
totalPrice: order.totalPrice,
|
||||||
status: order.status as OrderStatus,
|
status: order.status as OrderStatus,
|
||||||
note: order.note || undefined,
|
note: order.note || undefined,
|
||||||
|
discountCode: order.discountCode || undefined,
|
||||||
|
discountAmount: order.discountAmount || 0,
|
||||||
delivery,
|
delivery,
|
||||||
items: order.items.map((item) => ({
|
items: order.items.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
@@ -333,4 +375,37 @@ export class OrdersService {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveDiscount(
|
||||||
|
code: string | undefined,
|
||||||
|
customerId: string,
|
||||||
|
itemsSubtotal: number,
|
||||||
|
) {
|
||||||
|
const trimmed = code?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { discountCode: null as string | null, discountAmount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const discount = await this.prisma.discount.findFirst({
|
||||||
|
where: {
|
||||||
|
code: { equals: trimmed, mode: 'insensitive' },
|
||||||
|
active: true,
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
OR: [{ userId: null }, { userId: customerId }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!discount) {
|
||||||
|
throw new BadRequestException('کد تخفیف معتبر نیست');
|
||||||
|
}
|
||||||
|
if (itemsSubtotal < discount.minOrderAmount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`حداقل مبلغ سفارش برای این کد ${discount.minOrderAmount.toLocaleString('fa-IR')} تومان است`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = Math.round((itemsSubtotal * discount.percent) / 100);
|
||||||
|
const discountAmount = Math.min(raw, discount.maxValue);
|
||||||
|
return { discountCode: discount.code, discountAmount };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export class ListProductsQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categorySlug?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -19,32 +19,38 @@ import { UpdateProductDto } from './dto/update-product.dto';
|
|||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
@Controller('products')
|
@Controller('products')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
||||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
|
||||||
export class ProductsController {
|
export class ProductsController {
|
||||||
constructor(private readonly products: ProductsService) {}
|
constructor(private readonly products: ProductsService) {}
|
||||||
|
|
||||||
|
/** Public storefront + admin catalog list */
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: ListProductsQueryDto) {
|
list(@Query() query: ListProductsQueryDto) {
|
||||||
return this.products.list(query);
|
return this.products.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Public storefront + admin product detail */
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.products.findOne(id);
|
return this.products.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
create(@Body() dto: CreateProductDto) {
|
create(@Body() dto: CreateProductDto) {
|
||||||
return this.products.create(dto);
|
return this.products.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||||
return this.products.update(id, dto);
|
return this.products.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.products.remove(id);
|
return this.products.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,13 @@ export class ProductsService {
|
|||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const categoryIds = await this.resolveCategoryFilter(
|
||||||
|
query.categoryId,
|
||||||
|
query.categorySlug,
|
||||||
|
);
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
...(query.categoryId ? { categoryId: query.categoryId } : {}),
|
...(categoryIds ? { categoryId: { in: categoryIds } } : {}),
|
||||||
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
||||||
? {
|
? {
|
||||||
price: {
|
price: {
|
||||||
@@ -54,7 +59,10 @@ export class ProductsService {
|
|||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: { category: true },
|
include: {
|
||||||
|
category: true,
|
||||||
|
options: { include: { flavor: true } },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.product.count({ where }),
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
@@ -242,6 +250,58 @@ export class ProductsService {
|
|||||||
return category;
|
return category;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveCategoryFilter(
|
||||||
|
categoryId?: string,
|
||||||
|
categorySlug?: string,
|
||||||
|
) {
|
||||||
|
if (categoryId) {
|
||||||
|
return this.collectCategoryIds(categoryId);
|
||||||
|
}
|
||||||
|
if (!categorySlug?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const category = await this.prisma.category.findUnique({
|
||||||
|
where: { slug: categorySlug.trim() },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
return this.collectCategoryIds(category.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Include the category and all nested descendants for storefront filters. */
|
||||||
|
private async collectCategoryIds(rootId: string) {
|
||||||
|
const rows = await this.prisma.category.findMany({
|
||||||
|
select: { id: true, parentId: true },
|
||||||
|
});
|
||||||
|
const childrenByParent = new Map<string | null, string[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = childrenByParent.get(row.parentId) ?? [];
|
||||||
|
list.push(row.id);
|
||||||
|
childrenByParent.set(row.parentId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const stack = [rootId];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (stack.length) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
if (seen.has(current)) continue;
|
||||||
|
seen.add(current);
|
||||||
|
ids.push(current);
|
||||||
|
for (const childId of childrenByParent.get(current) ?? []) {
|
||||||
|
stack.push(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seen.has(rootId) && !rows.some((row) => row.id === rootId)) {
|
||||||
|
throw new NotFoundException('دستهبندی یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
private async validateProductOptions(
|
private async validateProductOptions(
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
options: ProductOptionInput[] | undefined,
|
options: ProductOptionInput[] | undefined,
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ export class SettingsController {
|
|||||||
constructor(private readonly settings: SettingsService) {}
|
constructor(private readonly settings: SettingsService) {}
|
||||||
|
|
||||||
@Get('districts')
|
@Get('districts')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
getDistricts() {
|
getDistricts() {
|
||||||
return this.settings.getDistricts();
|
return this.settings.getDistricts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('shipping')
|
@Get('shipping')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
listShipping() {
|
listShipping() {
|
||||||
return this.settings.listShippingExceptions();
|
return this.settings.listShippingExceptions();
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,7 @@ export class SettingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('branches')
|
@Get('branches')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
listBranches() {
|
listBranches() {
|
||||||
return this.settings.listBranches();
|
return this.settings.listBranches();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SmsService],
|
||||||
|
exports: [SmsService],
|
||||||
|
})
|
||||||
|
export class SmsModule {}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SendSmsInput, SendSmsResult } from './sms.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsService {
|
||||||
|
private readonly logger = new Logger(SmsService.name);
|
||||||
|
private readonly apiUrl: string;
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly domain: string;
|
||||||
|
|
||||||
|
constructor(private readonly config: ConfigService) {
|
||||||
|
this.apiUrl = this.config.getOrThrow<string>('MESHKEE_SMS_URL');
|
||||||
|
this.apiKey = this.config.getOrThrow<string>('MESHKEE_SMS_API_KEY');
|
||||||
|
this.domain = this.config.getOrThrow<string>('MESHKEE_SMS_DOMAIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(input: SendSmsInput): Promise<SendSmsResult> {
|
||||||
|
const to = input.to.trim();
|
||||||
|
const message = input.message.trim();
|
||||||
|
|
||||||
|
if (!/^09\d{9}$/.test(to)) {
|
||||||
|
throw new InternalServerErrorException('Invalid SMS destination');
|
||||||
|
}
|
||||||
|
if (!message) {
|
||||||
|
throw new InternalServerErrorException('SMS message is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(this.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Api-Key': this.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: this.domain,
|
||||||
|
to,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error('Meshkee SMS request failed', err);
|
||||||
|
throw new ServiceUnavailableException('SMS provider unreachable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| { success?: boolean; serverId?: string; message?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (!response.ok || !body?.success || !body.serverId) {
|
||||||
|
this.logger.error(
|
||||||
|
`Meshkee SMS rejected: status=${response.status} body=${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException('SMS send failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, serverId: body.serverId };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export type SendSmsInput = {
|
||||||
|
to: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SendSmsResult = {
|
||||||
|
success: true;
|
||||||
|
serverId: string;
|
||||||
|
};
|
||||||
@@ -30,6 +30,52 @@ import { UsersService } from './users.service';
|
|||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly users: UsersService) {}
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
me(@CurrentUser() actor: AuthUser) {
|
||||||
|
return this.users.findOne(actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
updateMe(@CurrentUser() actor: AuthUser, @Body() dto: UpdateUserDto) {
|
||||||
|
return this.users.updateMe(actor, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me/addresses')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
listMyAddresses(@CurrentUser() actor: AuthUser) {
|
||||||
|
return this.users.listAddresses(actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('me/addresses')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
createMyAddress(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Body() dto: CreateUserAddressDto,
|
||||||
|
) {
|
||||||
|
return this.users.createAddress(actor.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('me/addresses/:addressId')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
updateMyAddress(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Param('addressId') addressId: string,
|
||||||
|
@Body() dto: UpdateUserAddressDto,
|
||||||
|
) {
|
||||||
|
return this.users.updateAddress(actor.id, addressId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('me/addresses/:addressId')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
|
removeMyAddress(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
|
@Param('addressId') addressId: string,
|
||||||
|
) {
|
||||||
|
return this.users.removeAddress(actor.id, addressId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: ListUsersQueryDto) {
|
list(@Query() query: ListUsersQueryDto) {
|
||||||
return this.users.list(query);
|
return this.users.list(query);
|
||||||
@@ -88,11 +134,13 @@ export class UsersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/password')
|
@Patch(':id/password')
|
||||||
|
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||||
updatePassword(
|
updatePassword(
|
||||||
|
@CurrentUser() actor: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateUserPasswordDto,
|
@Body() dto: UpdateUserPasswordDto,
|
||||||
) {
|
) {
|
||||||
return this.users.updatePassword(id, dto);
|
return this.users.updatePassword(actor, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ export class UsersService {
|
|||||||
return this.serializeUser(user);
|
return this.serializeUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateMe(actor: AuthUser, dto: UpdateUserDto) {
|
||||||
|
// Self-service: never allow flipping disabled via /me.
|
||||||
|
return this.update(actor.id, {
|
||||||
|
title: dto.title,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
|
category: dto.category,
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updateRole(actor: AuthUser, id: string, dto: UpdateUserRoleDto) {
|
async updateRole(actor: AuthUser, id: string, dto: UpdateUserRoleDto) {
|
||||||
if (actor.role !== UserRole.superAdmin) {
|
if (actor.role !== UserRole.superAdmin) {
|
||||||
throw new ForbiddenException('فقط سوپرادمین میتواند نقش را تغییر دهد');
|
throw new ForbiddenException('فقط سوپرادمین میتواند نقش را تغییر دهد');
|
||||||
@@ -137,7 +148,15 @@ export class UsersService {
|
|||||||
return this.serializeUser(user);
|
return this.serializeUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePassword(id: string, dto: UpdateUserPasswordDto) {
|
async updatePassword(
|
||||||
|
actor: AuthUser,
|
||||||
|
id: string,
|
||||||
|
dto: UpdateUserPasswordDto,
|
||||||
|
) {
|
||||||
|
if (actor.role === UserRole.customer && actor.id !== id) {
|
||||||
|
throw new ForbiddenException('فقط میتوانید رمز عبور خود را تغییر دهید');
|
||||||
|
}
|
||||||
|
|
||||||
await this.ensureExists(id);
|
await this.ensureExists(id);
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
|||||||
Reference in New Issue
Block a user