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
|
||||
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_ACCESS_SECRET=change-me-balout-access-secret-min-32-chars
|
||||
@@ -28,3 +28,9 @@ S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
|
||||
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**
|
||||
2. In dashboards: `VITE_API_BASE_URL=http://localhost:3100/api/v1`
|
||||
3. Backend `CORS_ORIGIN` must include `http://localhost:5173`
|
||||
4. Log in with the super-admin phone/password you created
|
||||
3. Backend `CORS_ORIGIN` must include local dashboard origins, e.g.
|
||||
`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
|
||||
|
||||
@@ -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:generate` | Regenerate Prisma Client |
|
||||
| `npm run create-super-admin` | Create first `superAdmin` user |
|
||||
| `npm run send-sms` | Send SMS via Meshkee (`--to` / `--message`) |
|
||||
| `npm run lint` | ESLint |
|
||||
|
||||
## Environment
|
||||
@@ -79,12 +84,24 @@ Copy from `.env.example`. Do **not** commit `.env`.
|
||||
| `STORAGE_DISK` | `s3` for Parspack |
|
||||
| `S3_*` | Endpoint, bucket, public URL, keys |
|
||||
| `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).
|
||||
|
||||
## Auth rules
|
||||
|
||||
- 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 `superAdmin` can assign `admin` or `superAdmin` roles
|
||||
- `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 |
|
||||
|------|---------|
|
||||
| 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` |
|
||||
| Flavors | CRUD |
|
||||
| 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
|
||||
|
||||
- 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`
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"db:up": "docker compose up -d",
|
||||
"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": {
|
||||
"@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"
|
||||
|
||||
+51
-2
@@ -49,6 +49,8 @@ model User {
|
||||
refreshTokens RefreshToken[]
|
||||
addresses UserAddress[]
|
||||
orders Order[]
|
||||
discounts Discount[] @relation("DiscountAssignee")
|
||||
discountsCreated Discount[] @relation("DiscountCreatedBy")
|
||||
|
||||
@@index([role])
|
||||
@@index([disabled])
|
||||
@@ -80,6 +82,27 @@ model RefreshToken {
|
||||
@@index([tokenHash])
|
||||
}
|
||||
|
||||
enum SmsOtpPurpose {
|
||||
register
|
||||
login
|
||||
resetPassword
|
||||
}
|
||||
|
||||
model SmsOtp {
|
||||
id String @id @default(cuid())
|
||||
cellNumber String
|
||||
purpose SmsOtpPurpose
|
||||
codeHash String
|
||||
/// Pending registration fields (firstName, lastName, title, passwordHash)
|
||||
payload Json?
|
||||
attempts Int @default(0)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([cellNumber, purpose])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Flavor {
|
||||
id String @id @default(cuid())
|
||||
nameFa String
|
||||
@@ -94,6 +117,7 @@ model Category {
|
||||
id String @id @default(cuid())
|
||||
nameFa String
|
||||
nameEn String
|
||||
slug String @unique
|
||||
parentId String?
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children Category[] @relation("CategoryTree")
|
||||
@@ -102,6 +126,7 @@ model Category {
|
||||
updatedAt DateTime @updatedAt
|
||||
optionBlocks CategoryOptionBlock[]
|
||||
products Product[]
|
||||
discounts Discount[]
|
||||
|
||||
@@index([parentId])
|
||||
}
|
||||
@@ -215,7 +240,9 @@ model Order {
|
||||
shippingAddressLine String?
|
||||
shippingLandline String?
|
||||
note String @default("")
|
||||
itemCount Int
|
||||
discountCode String?
|
||||
discountAmount Int @default(0)
|
||||
itemCount Float
|
||||
totalPrice Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -234,7 +261,7 @@ model OrderItem {
|
||||
productId String?
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
nameFa String
|
||||
quantity Int
|
||||
quantity Float
|
||||
unitPrice Int
|
||||
sellUnit SellUnit
|
||||
sortOrder Int @default(0)
|
||||
@@ -254,3 +281,25 @@ model OrderItemOption {
|
||||
|
||||
@@index([orderItemId])
|
||||
}
|
||||
|
||||
model Discount {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
userId String?
|
||||
user User? @relation("DiscountAssignee", fields: [userId], references: [id], onDelete: Cascade)
|
||||
minOrderAmount Int @default(0)
|
||||
expiresAt DateTime
|
||||
percent Int
|
||||
maxValue Int
|
||||
active Boolean @default(true)
|
||||
createdByAdminId String?
|
||||
createdByAdmin User? @relation("DiscountCreatedBy", fields: [createdByAdminId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([categoryId])
|
||||
@@index([active, expiresAt])
|
||||
}
|
||||
|
||||
@@ -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 { AuthModule } from './auth/auth.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { DiscountsModule } from './discounts/discounts.module';
|
||||
import { FlavorsModule } from './flavors/flavors.module';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { OrdersModule } from './orders/orders.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { SmsModule } from './sms/sms.module';
|
||||
import { StorageModule } from './storage/storage.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@@ -16,12 +18,14 @@ import { UsersModule } from './users/users.module';
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
SmsModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
FlavorsModule,
|
||||
CategoriesModule,
|
||||
ProductsModule,
|
||||
OrdersModule,
|
||||
DiscountsModule,
|
||||
MediaModule,
|
||||
SettingsModule,
|
||||
],
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { UserRole } from '@prisma/client';
|
||||
import type { AuthUser } from './auth.types';
|
||||
import { AuthService } from './auth.service';
|
||||
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 { OtpVerifyDto } from './dto/otp-verify.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 { RolesGuard } from './guards/roles.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -18,6 +20,41 @@ export class AuthController {
|
||||
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')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.auth.refresh(dto.refreshToken);
|
||||
@@ -29,8 +66,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
return this.auth.me(user);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
+291
-7
@@ -1,15 +1,43 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SmsOtp, SmsOtpPurpose, UserRole } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { createHash, randomBytes, randomInt } from 'crypto';
|
||||
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 { 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()
|
||||
export class AuthService {
|
||||
@@ -17,6 +45,7 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async login(dto: LoginDto) {
|
||||
@@ -32,10 +61,6 @@ export class AuthService {
|
||||
throw new ForbiddenException('حساب کاربری غیرفعال است');
|
||||
}
|
||||
|
||||
if (!isElevatedRole(user.role)) {
|
||||
throw new ForbiddenException('فقط ادمین میتواند وارد پنل شود');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('شماره یا رمز عبور اشتباه است');
|
||||
@@ -44,6 +69,146 @@ export class AuthService {
|
||||
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) {
|
||||
const tokenHash = this.hashToken(refreshToken);
|
||||
const stored = await this.prisma.refreshToken.findFirst({
|
||||
@@ -59,7 +224,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const user = stored.user;
|
||||
if (user.disabled || !isElevatedRole(user.role)) {
|
||||
if (user.disabled) {
|
||||
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: {
|
||||
id: 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 { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuthUser, isElevatedRole } from '../auth.types';
|
||||
import { AuthUser } from '../auth.types';
|
||||
|
||||
type JwtPayload = {
|
||||
sub: string;
|
||||
@@ -29,7 +29,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || user.disabled || !isElevatedRole(user.role)) {
|
||||
if (!user || user.disabled) {
|
||||
throw new UnauthorizedException('نشست نامعتبر است');
|
||||
}
|
||||
|
||||
|
||||
@@ -19,37 +19,46 @@ import { ReplaceCategoryOptionsDto } from './dto/replace-category-options.dto';
|
||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||
|
||||
@Controller('categories')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categories: CategoriesService) {}
|
||||
|
||||
/** Public category tree for storefront filters */
|
||||
@Get()
|
||||
listTree() {
|
||||
return this.categories.listTree();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
create(@Body() dto: CreateCategoryDto) {
|
||||
return this.categories.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) {
|
||||
return this.categories.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categories.remove(id);
|
||||
}
|
||||
|
||||
@Get(':id/options')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
getOptions(@Param('id') id: string) {
|
||||
return this.categories.getOptions(id);
|
||||
}
|
||||
|
||||
@Put(':id/options')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
replaceOptions(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ReplaceCategoryOptionsDto,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { slugify } from '../common/slugify';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { buildCategoryTree } from './categories.utils';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
@@ -26,10 +27,13 @@ export class CategoriesService {
|
||||
await this.ensureExists(dto.parentId);
|
||||
}
|
||||
|
||||
const slug = await this.ensureUniqueSlug(dto.slug?.trim() || slugify(dto.nameEn));
|
||||
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
nameFa: dto.nameFa,
|
||||
nameEn: dto.nameEn,
|
||||
slug,
|
||||
parentId: dto.parentId ?? null,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
@@ -46,11 +50,19 @@ export class CategoriesService {
|
||||
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({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.nameFa !== undefined ? { nameFa: dto.nameFa } : {}),
|
||||
...(dto.nameEn !== undefined ? { nameEn: dto.nameEn } : {}),
|
||||
...(slug !== undefined ? { slug } : {}),
|
||||
...(dto.parentId !== undefined ? { parentId: dto.parentId } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||
},
|
||||
@@ -134,4 +146,21 @@ export class CategoriesService {
|
||||
}
|
||||
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 {
|
||||
@IsString()
|
||||
@@ -9,6 +9,13 @@ export class CreateCategoryDto {
|
||||
@MinLength(1)
|
||||
nameEn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug باید فقط شامل حروف انگلیسی کوچک، عدد و خط تیره باشد',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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 {
|
||||
@IsOptional()
|
||||
@@ -11,6 +11,13 @@ export class UpdateCategoryDto {
|
||||
@MinLength(1)
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug باید فقط شامل حروف انگلیسی کوچک، عدد و خط تیره باشد',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
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,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
@@ -18,8 +18,9 @@ export class CreateOrderItemDto {
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 3 })
|
||||
@Min(0.1)
|
||||
quantity!: number;
|
||||
|
||||
@IsOptional()
|
||||
@@ -52,6 +53,10 @@ export class CreateOrderDto {
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
discountCode?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
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 { CreateMyOrderDto } from './dto/create-my-order.dto';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { ListOrdersQueryDto } from './dto/list-orders-query.dto';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
@@ -28,6 +31,38 @@ export class OrdersController {
|
||||
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')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.orders.findOne(id);
|
||||
|
||||
@@ -95,6 +95,24 @@ export class OrdersService {
|
||||
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) {
|
||||
const customer = await this.prisma.user.findUnique({
|
||||
where: { id: dto.customerId },
|
||||
@@ -110,10 +128,18 @@ export class OrdersService {
|
||||
const lineInputs = await this.resolveItems(dto.items);
|
||||
|
||||
const itemCount = lineInputs.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalPrice = lineInputs.reduce(
|
||||
(sum, item) => sum + item.quantity * item.unitTotal,
|
||||
0,
|
||||
const itemsSubtotal = Math.round(
|
||||
lineInputs.reduce(
|
||||
(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 created = await tx.order.create({
|
||||
@@ -130,6 +156,8 @@ export class OrdersService {
|
||||
shippingAddressLine: delivery.shippingAddressLine,
|
||||
shippingLandline: delivery.shippingLandline,
|
||||
note: dto.note?.trim() ?? '',
|
||||
discountCode: discount.discountCode,
|
||||
discountAmount: discount.discountAmount,
|
||||
itemCount,
|
||||
totalPrice,
|
||||
items: {
|
||||
@@ -252,6 +280,18 @@ export class OrdersService {
|
||||
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 optionsById = new Map(
|
||||
product.options.map((option) => [option.id, option]),
|
||||
@@ -314,6 +354,8 @@ export class OrdersService {
|
||||
totalPrice: order.totalPrice,
|
||||
status: order.status as OrderStatus,
|
||||
note: order.note || undefined,
|
||||
discountCode: order.discountCode || undefined,
|
||||
discountAmount: order.discountAmount || 0,
|
||||
delivery,
|
||||
items: order.items.map((item) => ({
|
||||
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()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categorySlug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
|
||||
@@ -19,32 +19,38 @@ import { UpdateProductDto } from './dto/update-product.dto';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Controller('products')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
export class ProductsController {
|
||||
constructor(private readonly products: ProductsService) {}
|
||||
|
||||
/** Public storefront + admin catalog list */
|
||||
@Get()
|
||||
list(@Query() query: ListProductsQueryDto) {
|
||||
return this.products.list(query);
|
||||
}
|
||||
|
||||
/** Public storefront + admin product detail */
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.products.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
create(@Body() dto: CreateProductDto) {
|
||||
return this.products.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||
return this.products.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.admin, UserRole.superAdmin)
|
||||
remove(@Param('id') id: string) {
|
||||
return this.products.remove(id);
|
||||
}
|
||||
|
||||
@@ -28,8 +28,13 @@ export class ProductsService {
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const categoryIds = await this.resolveCategoryFilter(
|
||||
query.categoryId,
|
||||
query.categorySlug,
|
||||
);
|
||||
|
||||
const where = {
|
||||
...(query.categoryId ? { categoryId: query.categoryId } : {}),
|
||||
...(categoryIds ? { categoryId: { in: categoryIds } } : {}),
|
||||
...(query.minPrice !== undefined || query.maxPrice !== undefined
|
||||
? {
|
||||
price: {
|
||||
@@ -54,7 +59,10 @@ export class ProductsService {
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { category: true },
|
||||
include: {
|
||||
category: true,
|
||||
options: { include: { flavor: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
@@ -242,6 +250,58 @@ export class ProductsService {
|
||||
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(
|
||||
categoryId: string,
|
||||
options: ProductOptionInput[] | undefined,
|
||||
|
||||
@@ -25,11 +25,13 @@ export class SettingsController {
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
|
||||
@Get('districts')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
getDistricts() {
|
||||
return this.settings.getDistricts();
|
||||
}
|
||||
|
||||
@Get('shipping')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
listShipping() {
|
||||
return this.settings.listShippingExceptions();
|
||||
}
|
||||
@@ -40,6 +42,7 @@ export class SettingsController {
|
||||
}
|
||||
|
||||
@Get('branches')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
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 {
|
||||
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()
|
||||
list(@Query() query: ListUsersQueryDto) {
|
||||
return this.users.list(query);
|
||||
@@ -88,11 +134,13 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Patch(':id/password')
|
||||
@Roles(UserRole.customer, UserRole.admin, UserRole.superAdmin)
|
||||
updatePassword(
|
||||
@CurrentUser() actor: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserPasswordDto,
|
||||
) {
|
||||
return this.users.updatePassword(id, dto);
|
||||
return this.users.updatePassword(actor, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -122,6 +122,17 @@ export class UsersService {
|
||||
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) {
|
||||
if (actor.role !== UserRole.superAdmin) {
|
||||
throw new ForbiddenException('فقط سوپرادمین میتواند نقش را تغییر دهد');
|
||||
@@ -137,7 +148,15 @@ export class UsersService {
|
||||
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);
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
|
||||
Reference in New Issue
Block a user