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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:15:10 +03:30
co-authored by Cursor
parent 58380ab81d
commit d09eca8702
48 changed files with 1719 additions and 38 deletions
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+2
View File
@@ -1 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql" provider = "postgresql"
+51 -2
View File
@@ -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])
}
+53
View File
@@ -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);
});
+4
View File
@@ -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,
], ],
+41 -5
View File
@@ -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
View File
@@ -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],
+291 -7
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import { IsString, Matches } from 'class-validator';
export class CellNumberDto {
@IsString()
@Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' })
cellNumber!: string;
}
+16
View File
@@ -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;
}
+12
View File
@@ -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;
}
+19
View File
@@ -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;
}
+12
View File
@@ -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;
}
+2 -2
View File
@@ -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('نشست نامعتبر است');
} }
+11 -2
View File
@@ -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,
+29
View File
@@ -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;
}
}
} }
+8 -1
View File
@@ -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;
+8 -1
View File
@@ -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;
+10
View File
@@ -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'
}
+57
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+326
View File
@@ -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;
}
}
+57
View File
@@ -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;
}
+61
View File
@@ -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;
}
+45
View File
@@ -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[];
}
+8 -3
View File
@@ -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 })
+35
View File
@@ -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);
+76 -1
View File
@@ -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(
lineInputs.reduce(
(sum, item) => sum + item.quantity * item.unitTotal, (sum, item) => sum + item.quantity * item.unitTotal,
0, 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()
+8 -2
View File
@@ -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);
} }
+62 -2
View File
@@ -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,
+3
View File
@@ -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();
} }
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}
+66
View File
@@ -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 };
}
}
+9
View File
@@ -0,0 +1,9 @@
export type SendSmsInput = {
to: string;
message: string;
};
export type SendSmsResult = {
success: true;
serverId: string;
};
+49 -1
View File
@@ -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')
+20 -1
View File
@@ -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);