diff --git a/.env.example b/.env.example index f38e9cf..144cfca 100644 --- a/.env.example +++ b/.env.example @@ -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 + diff --git a/CONTEXT.md b/CONTEXT.md index ba98ef4..d14be90 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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` diff --git a/package.json b/package.json index 7c9aa75..89090d7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20260803120000_order_quantity_float/migration.sql b/prisma/migrations/20260803120000_order_quantity_float/migration.sql new file mode 100644 index 0000000..d754cbe --- /dev/null +++ b/prisma/migrations/20260803120000_order_quantity_float/migration.sql @@ -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; diff --git a/prisma/migrations/20260803150000_order_number_start_1000/migration.sql b/prisma/migrations/20260803150000_order_number_start_1000/migration.sql new file mode 100644 index 0000000..f0e76f3 --- /dev/null +++ b/prisma/migrations/20260803150000_order_number_start_1000/migration.sql @@ -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 $$; diff --git a/prisma/migrations/20260803154308_add_discounts/migration.sql b/prisma/migrations/20260803154308_add_discounts/migration.sql new file mode 100644 index 0000000..ea99a8c --- /dev/null +++ b/prisma/migrations/20260803154308_add_discounts/migration.sql @@ -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; diff --git a/prisma/migrations/20260803160830_discount_user_optional/migration.sql b/prisma/migrations/20260803160830_discount_user_optional/migration.sql new file mode 100644 index 0000000..7e95b25 --- /dev/null +++ b/prisma/migrations/20260803160830_discount_user_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Discount" ALTER COLUMN "userId" DROP NOT NULL; diff --git a/prisma/migrations/20260804100000_category_slug/migration.sql b/prisma/migrations/20260804100000_category_slug/migration.sql new file mode 100644 index 0000000..7109844 --- /dev/null +++ b/prisma/migrations/20260804100000_category_slug/migration.sql @@ -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"); diff --git a/prisma/migrations/20260804110000_order_discount/migration.sql b/prisma/migrations/20260804110000_order_discount/migration.sql new file mode 100644 index 0000000..9be9c21 --- /dev/null +++ b/prisma/migrations/20260804110000_order_discount/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Order" ADD COLUMN "discountCode" TEXT; +ALTER TABLE "Order" ADD COLUMN "discountAmount" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260804123000_sms_otp/migration.sql b/prisma/migrations/20260804123000_sms_otp/migration.sql new file mode 100644 index 0000000..a91084e --- /dev/null +++ b/prisma/migrations/20260804123000_sms_otp/migration.sql @@ -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"); diff --git a/prisma/migrations/20260804130000_sms_otp_login_reset/migration.sql b/prisma/migrations/20260804130000_sms_otp_login_reset/migration.sql new file mode 100644 index 0000000..fdbef53 --- /dev/null +++ b/prisma/migrations/20260804130000_sms_otp_login_reset/migration.sql @@ -0,0 +1,3 @@ +-- AlterEnum +ALTER TYPE "SmsOtpPurpose" ADD VALUE 'login'; +ALTER TYPE "SmsOtpPurpose" ADD VALUE 'resetPassword'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 2fe25d8..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee9243f..e8c7928 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/scripts/send-sms.ts b/scripts/send-sms.ts new file mode 100644 index 0000000..ec74c45 --- /dev/null +++ b/scripts/send-sms.ts @@ -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); +}); diff --git a/src/app.module.ts b/src/app.module.ts index 0936d2b..709a7cb 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, ], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 4f3c86a..11406dc 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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); } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 0912a39..c44f716 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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], @@ -28,4 +30,4 @@ import { JwtStrategy } from './strategies/jwt.strategy'; providers: [AuthService, JwtStrategy], exports: [AuthService, JwtModule], }) -export class AuthModule {} +export class AuthModule {} \ No newline at end of file diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c0f852d..216da38 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/auth/dto/cell-number.dto.ts b/src/auth/dto/cell-number.dto.ts new file mode 100644 index 0000000..4808a5d --- /dev/null +++ b/src/auth/dto/cell-number.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Matches } from 'class-validator'; + +export class CellNumberDto { + @IsString() + @Matches(/^09\d{9}$/, { message: 'شماره موبایل معتبر نیست' }) + cellNumber!: string; +} diff --git a/src/auth/dto/forgot-password-reset.dto.ts b/src/auth/dto/forgot-password-reset.dto.ts new file mode 100644 index 0000000..289bdf9 --- /dev/null +++ b/src/auth/dto/forgot-password-reset.dto.ts @@ -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; +} diff --git a/src/auth/dto/otp-verify.dto.ts b/src/auth/dto/otp-verify.dto.ts new file mode 100644 index 0000000..e1ec723 --- /dev/null +++ b/src/auth/dto/otp-verify.dto.ts @@ -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; +} diff --git a/src/auth/dto/register-send-code.dto.ts b/src/auth/dto/register-send-code.dto.ts new file mode 100644 index 0000000..06a23d2 --- /dev/null +++ b/src/auth/dto/register-send-code.dto.ts @@ -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; +} diff --git a/src/auth/dto/register-verify.dto.ts b/src/auth/dto/register-verify.dto.ts new file mode 100644 index 0000000..0dd20b0 --- /dev/null +++ b/src/auth/dto/register-verify.dto.ts @@ -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; +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index d0f37b1..aa37458 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -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('نشست نامعتبر است'); } diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts index 86c27f0..1636ebd 100644 --- a/src/categories/categories.controller.ts +++ b/src/categories/categories.controller.ts @@ -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, diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts index ef9f629..4baff7b 100644 --- a/src/categories/categories.service.ts +++ b/src/categories/categories.service.ts @@ -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; + } + } } diff --git a/src/categories/dto/create-category.dto.ts b/src/categories/dto/create-category.dto.ts index 5ad68ae..8372af6 100644 --- a/src/categories/dto/create-category.dto.ts +++ b/src/categories/dto/create-category.dto.ts @@ -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; diff --git a/src/categories/dto/update-category.dto.ts b/src/categories/dto/update-category.dto.ts index 25fd45f..2f8006c 100644 --- a/src/categories/dto/update-category.dto.ts +++ b/src/categories/dto/update-category.dto.ts @@ -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; diff --git a/src/common/slugify.ts b/src/common/slugify.ts new file mode 100644 index 0000000..7cce7e3 --- /dev/null +++ b/src/common/slugify.ts @@ -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' +} diff --git a/src/discounts/discounts.controller.ts b/src/discounts/discounts.controller.ts new file mode 100644 index 0000000..7befa8e --- /dev/null +++ b/src/discounts/discounts.controller.ts @@ -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); + } +} diff --git a/src/discounts/discounts.module.ts b/src/discounts/discounts.module.ts new file mode 100644 index 0000000..c1dce30 --- /dev/null +++ b/src/discounts/discounts.module.ts @@ -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 {} diff --git a/src/discounts/discounts.service.ts b/src/discounts/discounts.service.ts new file mode 100644 index 0000000..6e0a8e2 --- /dev/null +++ b/src/discounts/discounts.service.ts @@ -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; + } +} diff --git a/src/discounts/dto/create-discount.dto.ts b/src/discounts/dto/create-discount.dto.ts new file mode 100644 index 0000000..87c7602 --- /dev/null +++ b/src/discounts/dto/create-discount.dto.ts @@ -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; +} diff --git a/src/discounts/dto/list-discounts-query.dto.ts b/src/discounts/dto/list-discounts-query.dto.ts new file mode 100644 index 0000000..8123b74 --- /dev/null +++ b/src/discounts/dto/list-discounts-query.dto.ts @@ -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; +} diff --git a/src/discounts/dto/update-discount.dto.ts b/src/discounts/dto/update-discount.dto.ts new file mode 100644 index 0000000..ce4300c --- /dev/null +++ b/src/discounts/dto/update-discount.dto.ts @@ -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; +} diff --git a/src/orders/dto/create-my-order.dto.ts b/src/orders/dto/create-my-order.dto.ts new file mode 100644 index 0000000..7cfb950 --- /dev/null +++ b/src/orders/dto/create-my-order.dto.ts @@ -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[]; +} diff --git a/src/orders/dto/create-order.dto.ts b/src/orders/dto/create-order.dto.ts index cdb9583..514bdb4 100644 --- a/src/orders/dto/create-order.dto.ts +++ b/src/orders/dto/create-order.dto.ts @@ -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 }) diff --git a/src/orders/orders.controller.ts b/src/orders/orders.controller.ts index 8d4db0e..67a8a03 100644 --- a/src/orders/orders.controller.ts +++ b/src/orders/orders.controller.ts @@ -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); diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index 07186ad..3624530 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -95,6 +95,24 @@ export class OrdersService { return this.serializeOrder(order); } + async findOneForCustomer(customerId: string, id: string) { + const order = await this.prisma.order.findFirst({ + where: { id, customerId }, + include: { + items: { + orderBy: { sortOrder: 'asc' }, + include: { options: { orderBy: { sortOrder: 'asc' } } }, + }, + }, + }); + + if (!order) { + throw new NotFoundException('سفارش یافت نشد'); + } + + return this.serializeOrder(order); + } + async create(dto: CreateOrderDto) { const customer = await this.prisma.user.findUnique({ where: { id: dto.customerId }, @@ -110,10 +128,18 @@ export class OrdersService { const lineInputs = await this.resolveItems(dto.items); const itemCount = lineInputs.reduce((sum, item) => sum + item.quantity, 0); - const totalPrice = lineInputs.reduce( - (sum, item) => sum + item.quantity * item.unitTotal, - 0, + const itemsSubtotal = Math.round( + lineInputs.reduce( + (sum, item) => sum + item.quantity * item.unitTotal, + 0, + ), ); + const discount = await this.resolveDiscount( + dto.discountCode, + customer.id, + itemsSubtotal, + ); + const totalPrice = Math.max(0, itemsSubtotal - discount.discountAmount); const order = await this.prisma.$transaction(async (tx) => { const created = await tx.order.create({ @@ -130,6 +156,8 @@ export class OrdersService { shippingAddressLine: delivery.shippingAddressLine, shippingLandline: delivery.shippingLandline, note: dto.note?.trim() ?? '', + discountCode: discount.discountCode, + discountAmount: discount.discountAmount, itemCount, totalPrice, items: { @@ -252,6 +280,18 @@ export class OrdersService { throw new NotFoundException(`محصول یافت نشد: ${item.productId}`); } + if (product.sellUnit === 'unit') { + if (!Number.isInteger(item.quantity) || item.quantity < 1) { + throw new BadRequestException( + `تعداد «${product.nameFa}» باید عدد صحیح و حداقل ۱ باشد`, + ); + } + } else if (!(item.quantity >= 0.1)) { + throw new BadRequestException( + `وزن «${product.nameFa}» باید حداقل ۰٫۱ کیلو باشد`, + ); + } + const selectedIds = item.optionIds ?? []; const optionsById = new Map( product.options.map((option) => [option.id, option]), @@ -314,6 +354,8 @@ export class OrdersService { totalPrice: order.totalPrice, status: order.status as OrderStatus, note: order.note || undefined, + discountCode: order.discountCode || undefined, + discountAmount: order.discountAmount || 0, delivery, items: order.items.map((item) => ({ id: item.id, @@ -333,4 +375,37 @@ export class OrdersService { })), }; } + + private async resolveDiscount( + code: string | undefined, + customerId: string, + itemsSubtotal: number, + ) { + const trimmed = code?.trim(); + if (!trimmed) { + return { discountCode: null as string | null, discountAmount: 0 }; + } + + const discount = await this.prisma.discount.findFirst({ + where: { + code: { equals: trimmed, mode: 'insensitive' }, + active: true, + expiresAt: { gt: new Date() }, + OR: [{ userId: null }, { userId: customerId }], + }, + }); + + if (!discount) { + throw new BadRequestException('کد تخفیف معتبر نیست'); + } + if (itemsSubtotal < discount.minOrderAmount) { + throw new BadRequestException( + `حداقل مبلغ سفارش برای این کد ${discount.minOrderAmount.toLocaleString('fa-IR')} تومان است`, + ); + } + + const raw = Math.round((itemsSubtotal * discount.percent) / 100); + const discountAmount = Math.min(raw, discount.maxValue); + return { discountCode: discount.code, discountAmount }; + } } diff --git a/src/products/dto/list-products-query.dto.ts b/src/products/dto/list-products-query.dto.ts index 1ab9c97..173b031 100644 --- a/src/products/dto/list-products-query.dto.ts +++ b/src/products/dto/list-products-query.dto.ts @@ -10,6 +10,10 @@ export class ListProductsQueryDto { @IsString() categoryId?: string; + @IsOptional() + @IsString() + categorySlug?: string; + @IsOptional() @Type(() => Number) @IsInt() diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 15c5a20..71a9b6a 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -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); } diff --git a/src/products/products.service.ts b/src/products/products.service.ts index a1c79e1..931d87d 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -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(); + 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(); + 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, diff --git a/src/settings/settings.controller.ts b/src/settings/settings.controller.ts index c9c5c59..de08451 100644 --- a/src/settings/settings.controller.ts +++ b/src/settings/settings.controller.ts @@ -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(); } diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts new file mode 100644 index 0000000..cc282dd --- /dev/null +++ b/src/sms/sms.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from './sms.service'; + +@Module({ + providers: [SmsService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts new file mode 100644 index 0000000..7af2ffa --- /dev/null +++ b/src/sms/sms.service.ts @@ -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('MESHKEE_SMS_URL'); + this.apiKey = this.config.getOrThrow('MESHKEE_SMS_API_KEY'); + this.domain = this.config.getOrThrow('MESHKEE_SMS_DOMAIN'); + } + + async send(input: SendSmsInput): Promise { + 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 }; + } +} diff --git a/src/sms/sms.types.ts b/src/sms/sms.types.ts new file mode 100644 index 0000000..a3533ec --- /dev/null +++ b/src/sms/sms.types.ts @@ -0,0 +1,9 @@ +export type SendSmsInput = { + to: string; + message: string; +}; + +export type SendSmsResult = { + success: true; + serverId: string; +}; diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 5eb0b2f..7425040 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -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') diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 9d384ee..1d912ae 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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);