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
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
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**
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
View File
@@ -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';
+2
View File
@@ -1 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+51 -2
View File
@@ -49,6 +49,8 @@ model User {
refreshTokens RefreshToken[]
addresses UserAddress[]
orders Order[]
discounts Discount[] @relation("DiscountAssignee")
discountsCreated Discount[] @relation("DiscountCreatedBy")
@@index([role])
@@index([disabled])
@@ -80,6 +82,27 @@ model RefreshToken {
@@index([tokenHash])
}
enum SmsOtpPurpose {
register
login
resetPassword
}
model SmsOtp {
id String @id @default(cuid())
cellNumber String
purpose SmsOtpPurpose
codeHash String
/// Pending registration fields (firstName, lastName, title, passwordHash)
payload Json?
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([cellNumber, purpose])
@@index([expiresAt])
}
model Flavor {
id String @id @default(cuid())
nameFa String
@@ -94,6 +117,7 @@ model Category {
id String @id @default(cuid())
nameFa String
nameEn String
slug String @unique
parentId String?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Category[] @relation("CategoryTree")
@@ -102,6 +126,7 @@ model Category {
updatedAt DateTime @updatedAt
optionBlocks CategoryOptionBlock[]
products Product[]
discounts Discount[]
@@index([parentId])
}
@@ -215,7 +240,9 @@ model Order {
shippingAddressLine String?
shippingLandline String?
note String @default("")
itemCount Int
discountCode String?
discountAmount Int @default(0)
itemCount Float
totalPrice Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -234,7 +261,7 @@ model OrderItem {
productId String?
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
nameFa String
quantity Int
quantity Float
unitPrice Int
sellUnit SellUnit
sortOrder Int @default(0)
@@ -254,3 +281,25 @@ model OrderItemOption {
@@index([orderItemId])
}
model Discount {
id String @id @default(cuid())
code String @unique
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
userId String?
user User? @relation("DiscountAssignee", fields: [userId], references: [id], onDelete: Cascade)
minOrderAmount Int @default(0)
expiresAt DateTime
percent Int
maxValue Int
active Boolean @default(true)
createdByAdminId String?
createdByAdmin User? @relation("DiscountCreatedBy", fields: [createdByAdminId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([categoryId])
@@index([active, expiresAt])
}
+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 { 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,
],
+41 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+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 { 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('نشست نامعتبر است');
}
+11 -2
View File
@@ -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,
+29
View File
@@ -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;
}
}
}
+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 {
@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;
+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 {
@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;
+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,
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 })
+35
View File
@@ -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);
+76 -1
View File
@@ -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(
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()
+8 -2
View File
@@ -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);
}
+62 -2
View File
@@ -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,
+3
View File
@@ -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();
}
+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 {
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')
+20 -1
View File
@@ -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);