init
This commit is contained in:
+58
@@ -0,0 +1,58 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
/react/*
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"insertPragma": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"printWidth": 120,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"requirePragma": false,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"eslint.format.enable": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "vscode.typescript-language-features"
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
# Stage 1: Base
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
# Install timezone support
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone
|
||||||
|
|
||||||
|
# Set workdir for clarity
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Stage 2: Dependencies
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /temp-deps
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# Stage 3: Build
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . ./
|
||||||
|
COPY --from=deps /temp-deps/node_modules ./node_modules
|
||||||
|
RUN if [ -f package.json ] && grep -q '"build":' package.json; then npm run build; fi
|
||||||
|
|
||||||
|
# Stage 4: Runner
|
||||||
|
FROM base AS runner
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
COPY --from=builder --chown=appuser:appgroup /build/ ./
|
||||||
|
RUN chown -R appuser:appgroup /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
||||||
|
# # Stage 1: Build Stage
|
||||||
|
# FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
# RUN npm install -g corepack@latest
|
||||||
|
# RUN corepack enable && corepack prepare pnpm@9 --activate
|
||||||
|
|
||||||
|
# # Install tzdata to support timezone settings
|
||||||
|
# RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
|
# # Set the timezone to Asia/Tehran
|
||||||
|
# RUN cp /usr/share/zoneinfo/Asia/Tehran /etc/localtime && echo "Asia/Tehran" > /etc/timezone
|
||||||
|
|
||||||
|
|
||||||
|
# FROM base AS deps
|
||||||
|
# WORKDIR /temp-deps
|
||||||
|
# COPY package*.json pnpm-lock.yaml ./
|
||||||
|
# RUN pnpm install --frozen-lockfile --loglevel info
|
||||||
|
|
||||||
|
|
||||||
|
# FROM base AS builder
|
||||||
|
# WORKDIR /build
|
||||||
|
# COPY . ./
|
||||||
|
# COPY --from=deps /temp-deps/node_modules ./node_modules
|
||||||
|
# RUN if [ -f package.json ] && grep -q '"build":' package.json; then pnpm run build; fi
|
||||||
|
# RUN pnpm install --prod --frozen-lockfile --loglevel info
|
||||||
|
|
||||||
|
# FROM base AS runner
|
||||||
|
# RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
|
# WORKDIR /app
|
||||||
|
|
||||||
|
# COPY . ./
|
||||||
|
# COPY --from=builder --chown=appuser:appgroup /build/ ./
|
||||||
|
|
||||||
|
# RUN chown -R appuser:appgroup /app
|
||||||
|
|
||||||
|
# USER appuser
|
||||||
|
|
||||||
|
# ENV NODE_ENV=production
|
||||||
|
# EXPOSE 3000
|
||||||
|
|
||||||
|
# CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ npm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260105085439 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "point_transactions" drop constraint if exists "point_transactions_type_check";`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "categories" add column "order" int null;`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "point_transactions" add constraint "point_transactions_type_check" check("type" in (''));`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`create table "user_wallets" ("id" char(26) not null, "created_at" timestamptz(6) not null default now(), "updated_at" timestamptz(6) not null default now(), "deleted_at" timestamptz(6) null, "restaurant_id" char(26) not null, "user_id" char(26) not null, "wallet" int4 not null default 0, "points" int4 not null default 0, constraint "user_wallets_pkey" primary key ("id"));`);
|
||||||
|
this.addSql(`alter table "user_wallets" add constraint "unique_user_restaurant" unique ("user_id", "restaurant_id");`);
|
||||||
|
this.addSql(`create index "user_wallets_created_at_index" on "user_wallets" ("created_at");`);
|
||||||
|
this.addSql(`create index "user_wallets_deleted_at_index" on "user_wallets" ("deleted_at");`);
|
||||||
|
this.addSql(`create index "user_wallets_restaurant_id_index" on "user_wallets" ("restaurant_id");`);
|
||||||
|
this.addSql(`create index "user_wallets_user_id_index" on "user_wallets" ("user_id");`);
|
||||||
|
this.addSql(`create index "user_wallets_user_id_restaurant_id_index" on "user_wallets" ("user_id", "restaurant_id");`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "point_transactions" drop constraint if exists "point_transactions_type_check";`);
|
||||||
|
|
||||||
|
this.addSql(`alter table "point_transactions" add constraint "point_transactions_type_check" check("type" in (''));`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260106054151 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_id" type varchar(255) using ("subscription_id"::varchar(255));`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_id" drop not null;`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_end_date" type timestamptz using ("subscription_end_date"::timestamptz);`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_end_date" drop not null;`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_start_date" type timestamptz using ("subscription_start_date"::timestamptz);`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_start_date" drop not null;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_id" type varchar(255) using ("subscription_id"::varchar(255));`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_id" set not null;`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_end_date" type timestamptz using ("subscription_end_date"::timestamptz);`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_end_date" set not null;`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_start_date" type timestamptz using ("subscription_start_date"::timestamptz);`);
|
||||||
|
this.addSql(`alter table "restaurants" alter column "subscription_start_date" set not null;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20260106192946 extends Migration {
|
||||||
|
|
||||||
|
override async up(): Promise<void> {
|
||||||
|
this.addSql(`alter table "contacts" add column "restaurant_id" char(26) null;`);
|
||||||
|
this.addSql(`alter table "contacts" add constraint "contacts_restaurant_id_foreign" foreign key ("restaurant_id") references "restaurants" ("id") on update cascade on delete set null;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async down(): Promise<void> {
|
||||||
|
this.addSql(`alter table "contacts" drop constraint "contacts_restaurant_id_foreign";`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
'@typescript-eslint/consistent-type-imports': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+17122
File diff suppressed because it is too large
Load Diff
+124
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"name": "base-api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"db:create": "npx mikro-orm schema:create --run --config ./src/config/mikro-orm.config.dev.ts",
|
||||||
|
"db:update": "npx mikro-orm schema:update --run --config ./src/config/mikro-orm.config.dev.ts ",
|
||||||
|
"db:drop": "npx mikro-orm schema:drop --run --config ./src/config/mikro-orm.config.dev.ts",
|
||||||
|
"db:seed": "npx mikro-orm seeder:run --config ./src/config/mikro-orm.config.dev.ts",
|
||||||
|
"db:reset": "npm run db:drop && npm run db:create && npm run db:seed",
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start dist/main",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node --max-old-space-size=2048 dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"migration:create": "npx mikro-orm migration:create --config ./src/config/mikro-orm.config.dev.ts",
|
||||||
|
"migration:up": "npx mikro-orm migration:up --config ./src/config/mikro-orm.config.dev.ts",
|
||||||
|
"migration:down": "npx mikro-orm migration:down --config ./src/config/mikro-orm.config.dev.ts",
|
||||||
|
"migration:list": "npx mikro-orm migration:list --config ./src/config/mikro-orm.config.dev.ts",
|
||||||
|
"migration:pending": "npx mikro-orm migration:pending --config ./src/config/mikro-orm.config.dev.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@apollo/server": "^5.1.0",
|
||||||
|
"@as-integrations/fastify": "^3.1.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.922.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.922.0",
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/multipart": "^9.3.0",
|
||||||
|
"@fastify/static": "^8.3.0",
|
||||||
|
"@keyv/redis": "^5.1.3",
|
||||||
|
"@mikro-orm/core": "^6.5.8",
|
||||||
|
"@mikro-orm/nestjs": "^6.1.1",
|
||||||
|
"@mikro-orm/postgresql": "^6.5.8",
|
||||||
|
"@nest-lab/fastify-multer": "^1.3.0",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
|
"@nestjs/cache-manager": "^3.0.1",
|
||||||
|
"@nestjs/common": "^11.0.1",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
|
"@nestjs/jwt": "^11.0.1",
|
||||||
|
"@nestjs/mapped-types": "*",
|
||||||
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/platform-fastify": "^11.1.8",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.9",
|
||||||
|
"@nestjs/schedule": "^6.0.1",
|
||||||
|
"@nestjs/swagger": "^11.2.1",
|
||||||
|
"@nestjs/throttler": "^6.4.0",
|
||||||
|
"@types/socket.io": "^3.0.1",
|
||||||
|
"axios": "^1.13.1",
|
||||||
|
"bullmq": "^5.65.1",
|
||||||
|
"cache-manager": "^7.2.4",
|
||||||
|
"cacheable": "^2.3.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"fastify": "^5.6.1",
|
||||||
|
"firebase-admin": "^13.6.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
|
"slugify": "^1.6.6",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"ulid": "^3.0.1",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@mikro-orm/cli": "^6.5.8",
|
||||||
|
"@mikro-orm/migrations": "^6.5.8",
|
||||||
|
"@mikro-orm/seeder": "^6.5.8",
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+14002
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
|||||||
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import dataBaseConfig from './config/mikro-orm.config';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { UserModule } from './modules/users/user.module';
|
||||||
|
import { UtilsModule } from './modules/utils/utils.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { UploaderModule } from './modules/uploader/uploader.module';
|
||||||
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { RestaurantsModule } from './modules/restaurants/restaurants.module';
|
||||||
|
import { FoodModule } from './modules/foods/food.module';
|
||||||
|
import { CartModule } from './modules/cart/cart.module';
|
||||||
|
import { RolesModule } from './modules/roles/roles.module';
|
||||||
|
import { PaymentsModule } from './modules/payments/payments.module';
|
||||||
|
import { DeliveryModule } from './modules/delivery/delivery.module';
|
||||||
|
import { OrdersModule } from './modules/orders/orders.module';
|
||||||
|
import { CouponModule } from './modules/coupons/coupon.module';
|
||||||
|
import { ReviewModule } from './modules/review/review.module';
|
||||||
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { PagerModule } from './modules/pager/pager.module';
|
||||||
|
import { ContactModule } from './modules/contact/contact.module';
|
||||||
|
import { InventoryModule } from './modules/inventory/inventory.module';
|
||||||
|
import { IconsModule } from './modules/icons/icons.module';
|
||||||
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { cacheConfig } from './config/cache.config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, cache: true }),
|
||||||
|
CacheModule.registerAsync(cacheConfig()),
|
||||||
|
MikroOrmModule.forRootAsync(dataBaseConfig),
|
||||||
|
UserModule,
|
||||||
|
UtilsModule,
|
||||||
|
AuthModule,
|
||||||
|
UploaderModule,
|
||||||
|
AdminModule,
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60, // time window in seconds
|
||||||
|
limit: 5, // max requests per window
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
RestaurantsModule,
|
||||||
|
FoodModule,
|
||||||
|
CartModule,
|
||||||
|
RolesModule,
|
||||||
|
PaymentsModule,
|
||||||
|
DeliveryModule,
|
||||||
|
OrdersModule,
|
||||||
|
CouponModule,
|
||||||
|
ReviewModule,
|
||||||
|
NotificationsModule,
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
PagerModule,
|
||||||
|
ContactModule,
|
||||||
|
InventoryModule,
|
||||||
|
IconsModule,
|
||||||
|
],
|
||||||
|
controllers: [],
|
||||||
|
providers: [],
|
||||||
|
// exports: [CacheService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
// export const AUTH_THROTTLE = "AUTH_THROTTLE";
|
||||||
|
export const AI_CONFIG = 'AI_CONFIG';
|
||||||
|
export const MIKRO_ORM_QUERY_LOGGER = 'MIKRO_ORM_QUERY_LOGGER';
|
||||||
|
export const NAJVA_CONFIG = 'NAJVA_CONFIG';
|
||||||
|
export const AUTH_THROTTLE_TTL = 1 * 60 * 1000;
|
||||||
|
export const AUTH_THROTTLE_LIMIT = 5;
|
||||||
|
export const AUTH__REFRESH_THROTTLE_TTL = 10 * 60 * 1000;
|
||||||
|
export const AUTH__REFRESH_THROTTLE_LIMIT = 10;
|
||||||
|
//
|
||||||
|
export const CONSOLE_JWT_STRATEGY_NAME = 'console_jwt_strategy';
|
||||||
|
export const LOCAL_JWT_STRATEGY_NAME = 'local_jwt_strategy';
|
||||||
|
|
||||||
|
export const API_HEADER_SLUG = {
|
||||||
|
name: 'X-Slug',
|
||||||
|
required: true,
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
default: 'zhivan',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to extract userId from the authenticated request.
|
||||||
|
* Must be used after AdminAuthGuard or AuthGuard that sets request.userId.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* @Get('/profile')
|
||||||
|
* @UseGuards(AdminAuthGuard)
|
||||||
|
* getProfile(@UserId() userId: string) {
|
||||||
|
* return this.userService.findById(userId);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const AdminId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<Request & { adminId?: string }>();
|
||||||
|
return request.adminId || '';
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { UserId } from './user-id.decorator';
|
||||||
|
export { RestId } from './rest-id.decorator';
|
||||||
|
export { RateLimit } from './rate-limit.decorator';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const PERMISSIONS_KEY = 'permissions';
|
||||||
|
|
||||||
|
export const Permissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { UseGuards, applyDecorators } from '@nestjs/common';
|
||||||
|
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AUTH_THROTTLE_LIMIT,
|
||||||
|
AUTH_THROTTLE_TTL,
|
||||||
|
AUTH__REFRESH_THROTTLE_LIMIT,
|
||||||
|
AUTH__REFRESH_THROTTLE_TTL,
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
|
export const RateLimit = (limit: number, ttl: number) =>
|
||||||
|
applyDecorators(Throttle({ default: { limit, ttl } }), UseGuards(ThrottlerGuard));
|
||||||
|
|
||||||
|
// Predefined rate limits for common scenarios
|
||||||
|
export const StrictRateLimit = () => RateLimit(AUTH_THROTTLE_LIMIT, AUTH_THROTTLE_TTL); // 5 requests per minute
|
||||||
|
export const StandardRateLimit = () => RateLimit(30, 60000); // 30 requests per minute
|
||||||
|
export const MailSendRateLimit = () => RateLimit(10, 60000); // 10 emails per minute
|
||||||
|
export const RefreshTokenRateLimit = () => RateLimit(AUTH__REFRESH_THROTTLE_LIMIT, AUTH__REFRESH_THROTTLE_TTL); // 10 emails per minute
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to extract restId from the authenticated request.
|
||||||
|
* Must be used after AdminAuthGuard or AuthGuard that sets request.restId.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* @Get('/restaurants')
|
||||||
|
* @UseGuards(AdminAuthGuard)
|
||||||
|
* getRestaurants(@RestId() restId: string) {
|
||||||
|
* return this.restaurantService.findById(restId);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const RestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<Request & { restId?: string }>();
|
||||||
|
return request.restId || '';
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
export const RestSlug = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<Request & { slug?: string }>();
|
||||||
|
return request.slug || '';
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to extract userId from the authenticated request.
|
||||||
|
* Must be used after AdminAuthGuard or AuthGuard that sets request.userId.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* @Get('/profile')
|
||||||
|
* @UseGuards(AdminAuthGuard)
|
||||||
|
* getProfile(@UserId() userId: string) {
|
||||||
|
* return this.userService.findById(userId);
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export const UserId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
||||||
|
const request = ctx.switchToHttp().getRequest<Request & { userId?: string }>();
|
||||||
|
return request.userId || '';
|
||||||
|
});
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import { Index, PrimaryKey, Property, Filter, OptionalProps } from '@mikro-orm/core';
|
||||||
|
import { ulid } from 'ulid';
|
||||||
|
|
||||||
|
@Filter({ name: 'notDeleted', cond: { deletedAt: null }, default: true })
|
||||||
|
@Index({ properties: ['deletedAt'] })
|
||||||
|
@Index({ properties: ['createdAt'] })
|
||||||
|
export abstract class BaseEntity {
|
||||||
|
[OptionalProps]?: 'id' | 'createdAt' | 'updatedAt' | 'deletedAt';
|
||||||
|
|
||||||
|
@PrimaryKey({ type: 'string', columnType: 'char(26)' })
|
||||||
|
id: string = ulid();
|
||||||
|
|
||||||
|
@Property({ defaultRaw: 'now()', columnType: 'timestamptz' })
|
||||||
|
createdAt: Date = new Date();
|
||||||
|
|
||||||
|
@Property({
|
||||||
|
onUpdate: () => new Date(),
|
||||||
|
defaultRaw: 'now()',
|
||||||
|
columnType: 'timestamptz',
|
||||||
|
})
|
||||||
|
updatedAt: Date = new Date();
|
||||||
|
|
||||||
|
@Property({ nullable: true })
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
Executable
+780
@@ -0,0 +1,780 @@
|
|||||||
|
export const enum AuthMessage {
|
||||||
|
UNAUTHORIZED_ACCESS = 'شما دسترسی لازم برای این عملیات را ندارید.',
|
||||||
|
PHONE_REGISTERED = 'شماره ثبت شد',
|
||||||
|
INVALID_PHONE_FORMAT = 'فرمت شماره تلفن صحیح نیست',
|
||||||
|
OTP_SENT = 'کد یکبار مصرف به شماره شما ارسال شد.',
|
||||||
|
INVALID_PHONE = 'شماره موبایل اشتباه می باشد',
|
||||||
|
INVALID_CREDENTIAL = 'شماره موبایل یا رمز عبور اشتباه می باشد',
|
||||||
|
OTP_FAILED = 'کد یا شماره موبایل اشتباه است',
|
||||||
|
LOGIN_SUCCESS = 'ورود با موفقیت انجام شد',
|
||||||
|
PASSWORD_LOGIN_SUCCESS = 'با موفقیت وارد شدید',
|
||||||
|
FORGOT_PASSWORD_SUCCESS = 'درخواست فراموشی رمز عبور با موفقیت ثبت شد',
|
||||||
|
PASSWORD_SET_SUCCESS = 'پسورد با موفقیت تنظیم شد',
|
||||||
|
PASSWORD_UPDATE_SUCCESS = 'رمز عبور شما تغییر کرد',
|
||||||
|
INVALID_OTP = 'کد صحیح نمیباشد یا منقضی شده است',
|
||||||
|
PASSWORD_MISMATCH = 'رمز عبور یکسان نیست',
|
||||||
|
INVALID_PASSWORD = 'ایمیل یا رمز عبور اشتباه است',
|
||||||
|
INVALID_REPEAT_PASSWORD = 'تکرار رمز عبور اشتباه است',
|
||||||
|
EMAIL_NOT_EMPTY = 'ایمیل نمیتواند خالی باشد.',
|
||||||
|
INVALID_EMAIL_FORMAT = 'فرمت ایمیل صحیح نیست',
|
||||||
|
PasswordNotEmpty = 'پسورد نمیتواند خالی باشد.',
|
||||||
|
USER_NOT_FOUND = 'کاربری با این شماره وجود ندارد',
|
||||||
|
ADMIN_NOT_FOUND = 'ادمینی با این شماره وجود ندارد',
|
||||||
|
USER_EXISTS = 'با این شماره قبلا ثبت نام شده است',
|
||||||
|
USER_REGISTER_SUCCESS = 'ثبت نام موفقیت آمیز بود',
|
||||||
|
INVALID_USER = 'کاربری با این مشخصات یافت نشد',
|
||||||
|
PHONE_NOT_FOUND = 'کاربری با این شماره یافت نشد',
|
||||||
|
TOKEN_EXPIRED = 'توکن منقضی شده است',
|
||||||
|
TOKEN_INVALID = 'توکن نامعتبر است',
|
||||||
|
BANNED = 'در حال حاضر شما امکان دسترسی به این سرویس را ندارید',
|
||||||
|
PHONE_EXISTS = 'شماره تلفن قبلا ثبت شده است',
|
||||||
|
ADMIN_CREATED = 'ادمین با موفقیت ایجاد شد',
|
||||||
|
PERM_NOT_FOUND = 'دسترسی یافت نشد',
|
||||||
|
INVALID_PASS_FORMAT = 'فرمت رمز عبور صحیح نیست',
|
||||||
|
PHONE_NOT_EMPTY = 'شماره تلفن نمیتواند خالی باشد',
|
||||||
|
PASSWORD_FORMAT_INVALID = 'رمز عبور باید به صورت رشته باشد',
|
||||||
|
OTP_FORMAT_INVALID = 'کد یکبار مصرف باید عددی و ۵ رقمی باشد',
|
||||||
|
PHONE_FORMAT_INVALID = 'شماره تلفن باید معتبر و به فرمت ایران باشد',
|
||||||
|
TOKEN_NOT_EMPTY = 'توکن نمیتواند خالی باشد',
|
||||||
|
PASSWORD_NOT_EMPTY = 'رمز عبور نمیتواند خالی باشد',
|
||||||
|
PASSWORD_CHANGED_SUCCESSFULLY = 'رمز عبور با موفقیت تغییر کرد',
|
||||||
|
PASSWORD_CONFIRMATION_NOT_EMPTY = 'تایید رمز عبور نمیتواند خالی باشد',
|
||||||
|
PASSWORD_LENGTH = 'رمز عبور باید حداقل ۸ کاراکتر باشد',
|
||||||
|
OTP_NOT_EMPTY = 'کد یکبار مصرف نمیتواند خالی باشد',
|
||||||
|
LAST_PASSWORD_NOT_EMPTY = 'رمز عبور قبلی نمیتواند خالی باشد',
|
||||||
|
FIRST_NAME_NOT_EMPTY = 'نام نمیتواند خالی باشد',
|
||||||
|
LAST_NAME_NOT_EMPTY = 'نام خانوادگی نمیتواند خالی باشد',
|
||||||
|
FIRST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = 'نام باید بین ۲ تا ۵۰ کاراکتر باشد',
|
||||||
|
LAST_NAME_SHOULD_BE_BETWEEN_2_AND_50 = 'نام خانوادگی باید بین ۲ تا ۵۰ کاراکتر باشد',
|
||||||
|
BIRTH_DATE_NOT_EMPTY = 'تاریخ تولد نمیتواند خالی باشد',
|
||||||
|
NATIONAL_CODE_INCORRECT = 'کد ملی باید ۱۰ رقمی باشد',
|
||||||
|
NATIONAL_NOT_EMPTY = 'کد ملی نمیتواند خالی باشد',
|
||||||
|
OTP_ALREADY_SENT = 'کد یکبار مصرف قبلا ارسال شده است',
|
||||||
|
TOO_MANY_REQUESTS = 'تعداد درخواست های شما بیش از حد مجاز است',
|
||||||
|
NOT_ADMIN = 'شما دسترسی به بخش ادمین ندارید',
|
||||||
|
PHONE_SHOULD_BE_11_DIGIT = 'شماره تلفن باید ۱۱ رقم باشد',
|
||||||
|
TIMESTAMP_NOT_EMPTY = 'زمان نمیتواند خالی باشد',
|
||||||
|
ADMIN_CAN_NOT_LOGIN = 'ادمین نمیتواند وارد شود',
|
||||||
|
NATIONAL_CODE_INVALID = 'کد ملی نامعتبر است',
|
||||||
|
INVALID_REFRESH_TOKEN = 'توکن رفرش نامعتبر است',
|
||||||
|
REFRESH_TOKEN_EXPIRED = 'توکن رفرش منقضی شده است',
|
||||||
|
LOGOUT_SUCCESS = 'خروج با موفقیت انجام شد',
|
||||||
|
CURRENT_PASSWORD_INVALID = 'رمز عبور فعلی نامعتبر است',
|
||||||
|
TOKEN_EXPIRED_OR_INVALID = 'توکن منقضی شده یا نامعتبر است',
|
||||||
|
ACCESS_DENIED = 'دسترسی شما محدود شده است',
|
||||||
|
USER_ACCOUNT_INACTIVE = 'حساب کاربری شما غیر فعال است',
|
||||||
|
TOKEN_MISSED_OR_EXPIRED = 'توکن مفقود یا منقضی شده است',
|
||||||
|
OLD_PASSWORD_REQUIRED = 'رمز عبور قبلی الزامی است',
|
||||||
|
PASSWORD_CHANGE_FAILED = 'تغییر رمز عبور با مشکل روبرد شد',
|
||||||
|
SLUG_REQUIRED = 'اسلاگ الزامی است',
|
||||||
|
INVALID_TOKEN_PAYLOAD = 'محتویات توکن نامعتبر است',
|
||||||
|
INVALID_SLUG = 'اسلاگ نامعتبر است',
|
||||||
|
INVALID_OR_EXPIRED_TOKEN = 'توکن نامعتبر یا منقضی شده است',
|
||||||
|
}
|
||||||
|
export const enum FoodMessage {
|
||||||
|
NOT_FOUND = 'غذایی با این مشخصات یافت نشد',
|
||||||
|
}
|
||||||
|
export const enum CategoryMessage {
|
||||||
|
NOT_FOUND = 'دستهبندی مورد نظر یافت نشد',
|
||||||
|
NOT_CREATED = 'ایجاد دستهبندی با خطا مواجه شد',
|
||||||
|
NOT_UPDATED = 'بهروزرسانی دستهبندی با خطا مواجه شد',
|
||||||
|
NOT_DELETED = 'حذف دستهبندی با خطا مواجه شد',
|
||||||
|
}
|
||||||
|
export const enum UserMessage {
|
||||||
|
USER_NOT_FOUND = 'کاربری با این مشخصات یافت نشد',
|
||||||
|
Rest_NOT_FOUND = 'رستورانی با این مشخصات یافت نشد',
|
||||||
|
USER_EXISTS = 'با این شماره قبلا ثبت نام شده است',
|
||||||
|
USER_REGISTER_SUCCESS = 'ثبت نام موفقیت آمیز بود',
|
||||||
|
EMAIL_ADDRESS_ALREADY_EXISTS = 'ایمیل قبلا ثبت شده است',
|
||||||
|
EMAIL_USER_NOT_FOUND = 'ایمیل یافت نشد',
|
||||||
|
EMAIL_USER_DELETED_SUCCESSFULLY = 'ایمیل با موفقیت حذف شد',
|
||||||
|
FAILED_TO_DELETE_EMAIL_USER = 'خطا در حذف ایمیل',
|
||||||
|
EMAIL_USER_QUOTA_UPDATED_SUCCESSFULLY = 'ایمیل با موفقیت به روز رسانی شد',
|
||||||
|
FAILED_TO_UPDATE_EMAIL_USER_QUOTA = 'خطا در به روز رسانی حجم ایمیل',
|
||||||
|
USERNAME_REQUIRED = 'نام کاربری الزامی است',
|
||||||
|
USERNAME_STRING = 'نام کاربری باید یک رشته باشد',
|
||||||
|
USERNAME_MIN_LENGTH = 'نام کاربری باید حداقل ۲ کاراکتر باشد',
|
||||||
|
USERNAME_MATCHES = 'نام کاربری فقط میتواند شامل حروف، اعداد، نقطه، خط زیر و خط فاصله باشد',
|
||||||
|
PASSWORD_MATCHES = 'رمز عبور باید حداقل ۸ کاراکتر باشد و شامل حروف بزرگ، حروف کوچک، اعداد و علائم خاص باشد',
|
||||||
|
PASSWORD_STRING = 'رمز عبور باید یک رشته باشد',
|
||||||
|
PASSWORD_REQUIRED = 'رمز عبور الزامی است',
|
||||||
|
PASSWORD_MIN_LENGTH = 'رمز عبور باید حداقل ۸ کاراکتر باشد',
|
||||||
|
DOMAIN_ID_STRING = 'شناسه دامنه باید یک رشته باشد',
|
||||||
|
DOMAIN_ID_REQUIRED = 'شناسه دامنه الزامی است',
|
||||||
|
DOMAIN_ID_UUID = 'شناسه دامنه باید یک یو یو آی دی باشد',
|
||||||
|
DISPLAY_NAME_STRING = 'نام نمایشی باید یک رشته باشد',
|
||||||
|
DISPLAY_NAME_OPTIONAL = 'نام نمایشی اختیاری است',
|
||||||
|
QUOTA_NUMBER = 'حجم ایمیل باید یک عدد باشد',
|
||||||
|
QUOTA_OPTIONAL = 'حجم ایمیل اختیاری است',
|
||||||
|
QUOTA_NOT_EMPTY = 'حجم ایمیل نمیتواند خالی باشد',
|
||||||
|
ALIASES_ARRAY = 'الیاس ها باید یک آرایه باشد',
|
||||||
|
ALIASES_STRING = 'الیاس ها باید یک رشته باشد',
|
||||||
|
ALIASES_OPTIONAL = 'الیاس ها اختیاری است',
|
||||||
|
TITLE_STRING = 'عنوان باید یک رشته باشد',
|
||||||
|
TITLE_OPTIONAL = 'عنوان اختیاری است',
|
||||||
|
TITLE_NOT_EMPTY = 'عنوان نمیتواند خالی باشد',
|
||||||
|
EMAIL_USER_CREATED_SUCCESSFULLY = 'ایمیل با موفقیت ایجاد شد',
|
||||||
|
EMAIL_USER_UPDATED_SUCCESSFULLY = 'ایمیل کاربر با موفقیت بهروزرسانی شد',
|
||||||
|
STATUS_UPDATED = 'وضعیت کاربر با موفقیت تغییر کرد',
|
||||||
|
TEMPLATE_UUID = 'شناسه قالب باید یک یو یو آی دی باشد',
|
||||||
|
TEMPLATE_NOT_EMPTY = 'شناسه قالب نمیتواند خالی باشد',
|
||||||
|
ALIASES_EMAIL = 'آدرس ایمیل الیاس باید معتبر باشد',
|
||||||
|
FORWARDERS_EMAIL_VALID = 'آدرس ایمیل فوروارد باید معتبر باشد',
|
||||||
|
FORWARDERS_ARRAY = 'آدرس های فوروارد باید یک آرایه باشد',
|
||||||
|
FORWARDERS_STRING = 'آدرس های فوروارد باید یک رشته باشد',
|
||||||
|
PROFILE_PICTURE_URL = 'آدرس تصویر پروفایل باید یک آدرس اینترنتی معتبر باشد',
|
||||||
|
USER_UPDATED_SUCCESSFULLY = 'کاربر با موفقیت به روز رسانی شد',
|
||||||
|
PROFILE_PICTURE_NOT_EMPTY = 'تصویر پروفایل نمیتواند خالی باشد',
|
||||||
|
PUSH_TOKEN_NOT_FOUND = 'توکن پوش نامعتبر است',
|
||||||
|
PUSH_TOKEN_ADDED_SUCCESSFULLY = 'توکن پوش با موفقیت اضافه شد',
|
||||||
|
NO_PUSH_TOKENS_FOUND = 'توکن پوشی برای این کاربر یافت نشد',
|
||||||
|
PUSH_TOKEN_REMOVED_SUCCESSFULLY = 'توکن پوش با موفقیت حذف شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum CommonMessage {
|
||||||
|
VALIDITY_TYPE_REQUIRED = 'نوع اعتبار سنجی مورد نیاز است',
|
||||||
|
THIS_FILED_IS_REQUIRED = 'این فیلد الزامی است',
|
||||||
|
VALID_FOR_CHOOSE = 'معتبر برای انتخاب',
|
||||||
|
UPDATE_SUCCESS = 'با موفقیت به روز رسانی شد',
|
||||||
|
CREATED = 'با موفقیت ایجاد شد',
|
||||||
|
DELETED = 'با موفقیت حذف شد',
|
||||||
|
ID_REQUIRED = 'شناسه مورد نیاز است',
|
||||||
|
ID_SHOULD_BE_UUID = 'شناسه باید یک یو یو آی دی باشد',
|
||||||
|
PaymentTypeQueryNotEmpty = 'نوع پرداخت نمیتواند خالی باشد',
|
||||||
|
SEARCH_QUERY_STRING = 'رشته جستجو باید یک رشته باشد',
|
||||||
|
UPDATED = 'با موفقیت آپدیت شد',
|
||||||
|
IS_ACTIVE_SHOULD_BE_1_0 = 'وضعیت باید یکی از مقادیر ۰ و ۱ باشد',
|
||||||
|
DATE_MUST_BE_DATE = 'تاریخ باید یک تاریخ معتبر به صورت رشته باشد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum UploaderMessage {
|
||||||
|
UPLOAD_FILE_INVALID = 'فایل معتبر نیست',
|
||||||
|
UPLOAD_FILE_TOO_LARGE = 'فایل بزرگتر از حد مجاز است . حداکثر حجم فایل [MAX_FILE_SIZE] مگابایت است',
|
||||||
|
UPLOAD_FILE_TYPE_NOT_SUPPORTED = 'نوع فایل معتبر نیست . مجاز است : [MIME_TYPES]',
|
||||||
|
UPLOAD_FILE_SUCCESS = 'فایل با موفقیت آپلود شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum EmailMessage {
|
||||||
|
EMAIL_VERIFICATION = 'تایید ایمیل',
|
||||||
|
EMAIL_SENDING_FAILED = 'ارسال ایمیل با خطا مواجه شد',
|
||||||
|
LOGIN = 'ورود به سیستم',
|
||||||
|
INVOICE = 'فاکتور',
|
||||||
|
WALLET = 'اعلان کیف پول',
|
||||||
|
TICKET = 'تیکت پشتیبانی',
|
||||||
|
ANNOUNCEMENT = 'اعلان',
|
||||||
|
PAYMENT_REMINDER = 'یادآوری پرداخت',
|
||||||
|
PAYMENT_CANCELLATION = 'لغو پرداخت',
|
||||||
|
EMAIL_SENDING_SUCCESS = 'ارسال ایمیل با موفقیت انجام شد',
|
||||||
|
EMAIL_FORWARD_SUCCESS = 'فوروارد ایمیل با موفقیت انجام شد',
|
||||||
|
EMAIL_STATUS_RETRIEVED_SUCCESSFULLY = 'وضعیت ایمیل با موفقیت دریافت شد',
|
||||||
|
MESSAGES_SEARCHED_SUCCESSFULLY = 'جستجوی ایمیل با موفقیت انجام شد',
|
||||||
|
MESSAGES_LISTED_SUCCESSFULLY = 'لیست ایمیل ها با موفقیت دریافت شد',
|
||||||
|
MESSAGE_RETRIEVED_SUCCESSFULLY = 'ایمیل با موفقیت دریافت شد',
|
||||||
|
MESSAGE_DELETED_SUCCESSFULLY = 'ایمیل با موفقیت حذف شد',
|
||||||
|
USER_ID_REQUIRED = 'شناسه کاربری مورد نیاز است',
|
||||||
|
USER_ID_MUST_BE_STRING = 'شناسه کاربری باید یک رشته باشد',
|
||||||
|
MAILBOX_ID_REQUIRED = 'شناسه میل باکس ایمیل مورد نیاز است',
|
||||||
|
MAILBOX_ID_MUST_BE_STRING = 'شناسه میل باکس ایمیل باید یک رشته باشد',
|
||||||
|
QUEUE_ID_REQUIRED = 'شناسه پیشنویس ایمیل مورد نیاز است',
|
||||||
|
QUEUE_ID_MUST_BE_STRING = 'شناسه پیشنویس ایمیل باید یک رشته باشد',
|
||||||
|
MESSAGE_ID_MUST_BE_NUMBER = 'شناسه ایمیل باید یک عدد باشد',
|
||||||
|
FROM_ADDRESS_REQUIRED = 'آدرس از مورد نیاز است',
|
||||||
|
|
||||||
|
// Validation Messages for Email DTOs
|
||||||
|
FROM_ADDRESS_MUST_BE_EMAIL = 'آدرس فرستنده باید یک ایمیل معتبر باشد',
|
||||||
|
|
||||||
|
// Query validation messages
|
||||||
|
SEARCH_QUERY_PARAMETER_MUST_BE_STRING = 'پارامتر جستجو باید یک رشته باشد',
|
||||||
|
MESSAGE_IDS_MUST_BE_STRING = 'شناسههای پیام باید یک رشته باشد',
|
||||||
|
THREAD_ID_MUST_BE_STRING = 'شناسه رشته گفتگو باید یک رشته باشد',
|
||||||
|
OR_QUERY_MUST_BE_OBJECT = 'پارامتر OR باید یک شیء باشد',
|
||||||
|
DATE_START_MUST_BE_DATE_STRING = 'تاریخ شروع باید یک رشته تاریخ معتبر باشد',
|
||||||
|
DATE_END_MUST_BE_DATE_STRING = 'تاریخ پایان باید یک رشته تاریخ معتبر باشد',
|
||||||
|
TO_ADDRESS_MUST_BE_STRING = 'آدرس گیرنده باید یک رشته باشد',
|
||||||
|
MIN_SIZE_MUST_BE_NUMBER = 'حداقل اندازه باید یک عدد باشد',
|
||||||
|
MIN_SIZE_MUST_BE_NON_NEGATIVE = 'حداقل اندازه نمیتواند منفی باشد',
|
||||||
|
MAX_SIZE_MUST_BE_NUMBER = 'حداکثر اندازه باید یک عدد باشد',
|
||||||
|
MAX_SIZE_MUST_BE_NON_NEGATIVE = 'حداکثر اندازه نمیتواند منفی باشد',
|
||||||
|
ATTACHMENTS_FILTER_MUST_BE_BOOLEAN = 'فیلتر پیوستها باید بولین باشد',
|
||||||
|
FLAGGED_FILTER_MUST_BE_BOOLEAN = 'فیلتر پیامهای علامتدار باید بولین باشد',
|
||||||
|
UNSEEN_FILTER_MUST_BE_BOOLEAN = 'فیلتر پیامهای خواندهنشده باید بولین باشد',
|
||||||
|
INCLUDE_HEADERS_MUST_BE_STRING = 'لیست هدرها باید یک رشته باشد',
|
||||||
|
SEARCHABLE_FILTER_MUST_BE_BOOLEAN = 'فیلتر قابل جستجو باید بولین باشد',
|
||||||
|
THREAD_COUNTERS_MUST_BE_BOOLEAN = 'شمارنده رشته گفتگو باید بولین باشد',
|
||||||
|
ORDER_MUST_BE_VALID = 'ترتیب باید یکی از مقادیر asc یا desc باشد',
|
||||||
|
NEXT_CURSOR_MUST_BE_STRING = 'نشانگر صفحه بعد باید یک رشته باشد',
|
||||||
|
PREVIOUS_CURSOR_MUST_BE_STRING = 'نشانگر صفحه قبل باید یک رشته باشد',
|
||||||
|
|
||||||
|
RECIPIENT_NAME_MUST_BE_STRING = 'نام گیرنده باید یک رشته باشد',
|
||||||
|
RECIPIENT_ADDRESS_REQUIRED = 'آدرس گیرنده مورد نیاز است',
|
||||||
|
RECIPIENT_ADDRESS_MUST_BE_EMAIL = 'آدرس گیرنده باید یک ایمیل معتبر باشد',
|
||||||
|
HEADER_KEY_REQUIRED = 'کلید هدر مورد نیاز است',
|
||||||
|
HEADER_KEY_MUST_BE_STRING = 'کلید هدر باید یک رشته باشد',
|
||||||
|
HEADER_VALUE_REQUIRED = 'مقدار هدر مورد نیاز است',
|
||||||
|
HEADER_VALUE_MUST_BE_STRING = 'مقدار هدر باید یک رشته باشد',
|
||||||
|
ATTACHMENT_FILENAME_MUST_BE_STRING = 'نام فایل پیوست باید یک رشته باشد',
|
||||||
|
ATTACHMENT_CONTENT_TYPE_MUST_BE_STRING = 'نوع محتوای پیوست باید یک رشته باشد',
|
||||||
|
ATTACHMENT_ENCODING_MUST_BE_STRING = 'کدگذاری پیوست باید یک رشته باشد',
|
||||||
|
ATTACHMENT_CONTENT_TRANSFER_ENCODING_MUST_BE_STRING = 'کدگذاری انتقال محتوای پیوست باید یک رشته باشد',
|
||||||
|
ATTACHMENT_CONTENT_DISPOSITION_INVALID = 'نحوه نمایش پیوست باید inline یا attachment باشد',
|
||||||
|
ATTACHMENT_CONTENT_REQUIRED = 'محتوای پیوست مورد نیاز است',
|
||||||
|
ATTACHMENT_CONTENT_MUST_BE_STRING = 'محتوای پیوست باید یک رشته باشد',
|
||||||
|
ATTACHMENT_CID_MUST_BE_STRING = 'شناسه محتوای پیوست باید یک رشته باشد',
|
||||||
|
DRAFT_MAILBOX_REQUIRED = 'شناسه صندوق پیشنویس مورد نیاز است',
|
||||||
|
DRAFT_MAILBOX_MUST_BE_STRING = 'شناسه صندوق پیشنویس باید یک رشته باشد',
|
||||||
|
DRAFT_ID_REQUIRED = 'شناسه پیشنویس مورد نیاز است',
|
||||||
|
DRAFT_ID_MUST_BE_NUMBER = 'شناسه پیشنویس باید یک عدد باشد',
|
||||||
|
ENVELOPE_FROM_REQUIRED = 'آدرس فرستنده در envelope مورد نیاز است',
|
||||||
|
ENVELOPE_TO_REQUIRED = 'آدرس گیرنده در envelope مورد نیاز است',
|
||||||
|
ENVELOPE_TO_MUST_BE_ARRAY = 'آدرس گیرنده در envelope باید یک آرایه باشد',
|
||||||
|
MAILBOX_MUST_BE_STRING = 'شناسه صندوق باید یک رشته باشد',
|
||||||
|
REPLY_TO_MUST_BE_VALID = 'آدرس پاسخ باید معتبر باشد',
|
||||||
|
TO_ADDRESSES_REQUIRED = 'آدرسهای گیرنده مورد نیاز است',
|
||||||
|
TO_ADDRESSES_MUST_BE_ARRAY = 'آدرسهای گیرنده باید یک آرایه باشد',
|
||||||
|
CC_ADDRESSES_MUST_BE_ARRAY = 'آدرسهای کپی کربن باید یک آرایه باشد',
|
||||||
|
BCC_ADDRESSES_MUST_BE_ARRAY = 'آدرسهای کپی مخفی باید یک آرایه باشد',
|
||||||
|
HEADERS_MUST_BE_ARRAY = 'هدرهای سفارشی باید یک آرایه باشد',
|
||||||
|
SUBJECT_MUST_BE_STRING = 'موضوع ایمیل باید یک رشته باشد',
|
||||||
|
TEXT_CONTENT_MUST_BE_STRING = 'محتوای متنی باید یک رشته باشد',
|
||||||
|
HTML_CONTENT_MUST_BE_STRING = 'محتوای HTML باید یک رشته باشد',
|
||||||
|
ATTACHMENTS_MUST_BE_ARRAY = 'پیوستها باید یک آرایه باشد',
|
||||||
|
SESSION_ID_MUST_BE_STRING = 'شناسه جلسه باید یک رشته باشد',
|
||||||
|
IP_ADDRESS_MUST_BE_STRING = 'آدرس IP باید یک رشته باشد',
|
||||||
|
IS_DRAFT_MUST_BE_BOOLEAN = 'وضعیت پیشنویس باید بولین باشد',
|
||||||
|
SEND_TIME_MUST_BE_DATE = 'زمان ارسال باید یک تاریخ معتبر باشد',
|
||||||
|
UPLOAD_ONLY_MUST_BE_BOOLEAN = 'حالت فقط آپلود باید بولین باشد',
|
||||||
|
ENVELOPE_MUST_BE_VALID = 'envelope باید معتبر باشد',
|
||||||
|
|
||||||
|
// Message operations
|
||||||
|
MESSAGE_MARKED_AS_SEEN_SUCCESSFULLY = 'پیام با موفقیت به عنوان خوانده شده علامت گذاری شد',
|
||||||
|
ALL_MESSAGES_MARKED_AS_READ_SUCCESSFULLY = 'تمام پیام ها با موفقیت به عنوان خوانده شده علامت گذاری شدند',
|
||||||
|
MESSAGE_MARK_AS_SEEN_FAILED = 'علامت گذاری پیام با خطا مواجه شد',
|
||||||
|
SENT_MESSAGES_RETRIEVED_SUCCESSFULLY = 'پیام های ارسالی با موفقیت دریافت شد',
|
||||||
|
SENT_MESSAGES_RETRIEVAL_FAILED = 'دریافت پیام های ارسالی با خطا مواجه شد',
|
||||||
|
TRASH_MESSAGES_RETRIEVED_SUCCESSFULLY = 'پیام های حذف شده با موفقیت دریافت شد',
|
||||||
|
TRASH_MESSAGES_RETRIEVAL_FAILED = 'دریافت پیام های حذف شده با خطا مواجه شد',
|
||||||
|
DRAFT_UPDATED_SUCCESSFULLY = 'پیش نویس با موفقیت به روز رسانی شد',
|
||||||
|
DRAFT_SENT_SUCCESSFULLY = 'پیش نویس با موفقیت ارسال شد',
|
||||||
|
DRAFT_DELETED_SUCCESSFULLY = 'پیش نویس با موفقیت حذف شد',
|
||||||
|
MESSAGE_MOVED_TO_ARCHIVE_SUCCESSFULLY = 'پیام با موفقیت به آرشیو منتقل شد',
|
||||||
|
MESSAGE_MOVED_TO_FAVORITE_SUCCESSFULLY = 'پیام با موفقیت به علاقه مندی ها منتقل شد',
|
||||||
|
MESSAGE_MOVED_TO_TRASH_SUCCESSFULLY = 'پیام با موفقیت به سطل زباله منتقل شد',
|
||||||
|
MESSAGE_MOVED_TO_JUNK_SUCCESSFULLY = 'پیام با موفقیت به جانک منتقل شد',
|
||||||
|
MESSAGE_MOVED_TO_INBOX_FROM_ARCHIVE_SUCCESSFULLY = 'پیام با موفقیت از آرشیو به صندوق ورودی منتقل شد',
|
||||||
|
MESSAGE_MOVED_TO_INBOX_FROM_FAVORITE_SUCCESSFULLY = 'پیام با موفقیت از علاقه مندی ها به صندوق ورودی منتقل شد',
|
||||||
|
MESSAGE_MOVED_TO_INBOX_FROM_TRASH_SUCCESSFULLY = 'پیام با موفقیت از سطل زباله به صندوق ورودی منتقل شد',
|
||||||
|
MESSAGE_MOVED_TO_INBOX_FROM_JUNK_SUCCESSFULLY = 'پیام با موفقیت از جانک به صندوق ورودی منتقل شد',
|
||||||
|
MESSAGE_NOT_FOUND = 'پیام یافت نشد',
|
||||||
|
|
||||||
|
// Bulk action messages
|
||||||
|
BULK_ACTION_COMPLETED_SUCCESSFULLY = '[count] پیام با موفقیت [action] شد',
|
||||||
|
BULK_ACTION_PARTIALLY_COMPLETED = '[successful] از [total] پیام [action] شد. [failed] پیام با خطا مواجه شد',
|
||||||
|
BULK_ACTION_FAILED = 'هیچ پیامی [action] نشد. تمام [total] تلاش ناموفق بود',
|
||||||
|
|
||||||
|
// Bulk action display names
|
||||||
|
BULK_ACTION_SEEN = 'خوانده شده علامتگذاری',
|
||||||
|
BULK_ACTION_DELETE = 'حذف',
|
||||||
|
BULK_ACTION_ARCHIVE = 'آرشیو',
|
||||||
|
BULK_ACTION_UNARCHIVE = 'از آرشیو خارج',
|
||||||
|
BULK_ACTION_FAVORITE = 'به علاقه مندی ها منتقل',
|
||||||
|
BULK_ACTION_UNFAVORITE = 'از علاقه مندی ها حذف',
|
||||||
|
BULK_ACTION_JUNK = 'به جانک منتقل',
|
||||||
|
BULK_ACTION_NOTJUNK = 'از جانک از حذف',
|
||||||
|
BULK_ACTION_TRASH = 'به سطل زباله منتقل',
|
||||||
|
BULK_ACTION_RESTORE = 'از سطل زباله بازیابی',
|
||||||
|
BULK_ACTION_PROCESSED = 'پردازش',
|
||||||
|
USER_NOT_FOUND = 'کاربر یافت نشد',
|
||||||
|
PRIORITY_MUST_BE_ONE_OF_HIGH_NORMAL_LOW = 'اولویت باید یکی از مقادیر high, normal, low باشد',
|
||||||
|
SUBJECT_REQUIRED = 'موضوع ایمیل مورد نیاز است',
|
||||||
|
ALL_MESSAGES_DELETED_SUCCESSFULLY = 'همه پیام ها با موفقیت حذف شدند',
|
||||||
|
MAILBOX_ID_MUST_BE_MONGO_ID = 'شناسه میل باکس ایمیل باید یک آی دی معتبر باشد',
|
||||||
|
MESSAGE_MARKED_AS_UNSEEN_SUCCESSFULLY = 'پیام با موفقیت به عنوان خوانده نشده علامت گذاری شد',
|
||||||
|
MESSAGE_MARK_AS_UNSEEN_FAILED = 'علامت گذاری پیام به عنوان خوانده نشده با خطا مواجه شد',
|
||||||
|
ATTACHMENT_ID_MUST_BE_STRING = 'شناسه پیوست باید یک رشته باشد',
|
||||||
|
IS_FORWARD_MUST_BE_BOOLEAN = 'IS_FORWARD_MUST_BE_BOOLEAN',
|
||||||
|
|
||||||
|
// Favorite/Star messages (Gmail-like behavior)
|
||||||
|
MESSAGE_MARKED_AS_FAVORITE_SUCCESSFULLY = 'پیام با موفقیت به عنوان مورد علاقه علامت گذاری شد',
|
||||||
|
MESSAGE_REMOVED_FROM_FAVORITE_SUCCESSFULLY = 'پیام با موفقیت از مورد علاقه حذف شد',
|
||||||
|
TRASH_EMPTYED_SUCCESSFULLY = 'سطل زباله با موفقیت خالی شد',
|
||||||
|
SPAM_EMPTYED_SUCCESSFULLY = 'همه پیام های اسپم با موفقیت حذف شدند',
|
||||||
|
SAVE_DRAFT_SUCCESS = 'پیش نویس با موفقیت ذخیره شد',
|
||||||
|
HTML_CONTENT_REQUIRED = 'محتوای HTML مورد نیاز است',
|
||||||
|
TEXT_CONTENT_REQUIRED = 'TEXT_CONTENT_REQUIRED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum WebSocketMessage {
|
||||||
|
// Connection Messages
|
||||||
|
WS_CONNECTION_ESTABLISHED = 'اتصال وب سوکت با موفقیت برقرار شد',
|
||||||
|
WS_CONNECTION_FAILED = 'اتصال وب سوکت ناموفق بود',
|
||||||
|
WS_AUTHENTICATION_REQUIRED = 'احراز هویت وب سوکت مورد نیاز است',
|
||||||
|
WS_AUTHENTICATION_FAILED = 'احراز هویت وب سوکت ناموفق بود',
|
||||||
|
WS_AUTHENTICATION_SUCCESS = 'احراز هویت وب سوکت با موفقیت انجام شد',
|
||||||
|
WS_INVALID_TOKEN = 'توکن وب سوکت نامعتبر است',
|
||||||
|
WS_TOKEN_EXPIRED = 'توکن وب سوکت منقضی شده است',
|
||||||
|
|
||||||
|
// Room Management Messages
|
||||||
|
JOINED_ROOM_SUCCESS = 'با موفقیت به اتاق متصل شدید',
|
||||||
|
LEFT_ROOM_SUCCESS = 'با موفقیت از اتاق خارج شدید',
|
||||||
|
ROOM_NOT_FOUND = 'اتاق یافت نشد',
|
||||||
|
UNAUTHORIZED_ROOM_ACCESS = 'دسترسی غیرمجاز به اتاق',
|
||||||
|
|
||||||
|
// Session Management Messages
|
||||||
|
WS_SESSION_CREATED = 'جلسه وب سوکت ایجاد شد',
|
||||||
|
WS_SESSION_JOINED = 'به جلسه وب سوکت پیوستید',
|
||||||
|
WS_SESSION_LEFT = 'از جلسه وب سوکت خارج شدید',
|
||||||
|
WS_SESSION_EXPIRED = 'جلسه وب سوکت منقضی شده است',
|
||||||
|
WS_SESSION_NOT_FOUND = 'جلسه وب سوکت یافت نشد',
|
||||||
|
|
||||||
|
// Error Messages
|
||||||
|
WS_CONNECTION_ERROR = 'خطا در اتصال وب سوکت',
|
||||||
|
INVALID_EVENT = 'رویداد نامعتبر',
|
||||||
|
INVALID_DATA = 'داده نامعتبر',
|
||||||
|
WS_RATE_LIMIT_EXCEEDED = 'تعداد درخواستهای وب سوکت از حد مجاز بیشتر است',
|
||||||
|
INTERNAL_ERROR = 'خطای داخلی سرور',
|
||||||
|
|
||||||
|
// Notification Messages
|
||||||
|
NEW_MESSAGE_NOTIFICATION = 'پیام جدید دریافت شد',
|
||||||
|
MESSAGE_STATUS_UPDATED = 'وضعیت پیام بهروزرسانی شد',
|
||||||
|
MESSAGE_MOVED_NOTIFICATION = 'پیام جابجا شد',
|
||||||
|
MESSAGE_DELETED_NOTIFICATION = 'پیام حذف شد',
|
||||||
|
|
||||||
|
// General Messages
|
||||||
|
OPERATION_SUCCESS = 'عملیات با موفقیت انجام شد',
|
||||||
|
OPERATION_FAILED = 'عملیات ناموفق بود',
|
||||||
|
PERMISSION_DENIED = 'مجوز دسترسی ندارید',
|
||||||
|
RESOURCE_NOT_FOUND = 'منبع یافت نشد',
|
||||||
|
|
||||||
|
// Authentication errors
|
||||||
|
AUTHENTICATION_REQUIRED = 'احراز هویت ضروری است',
|
||||||
|
INVALID_TOKEN = 'توکن احراز هویت نامعتبر است',
|
||||||
|
TOKEN_EXPIRED = 'توکن منقضی شده است',
|
||||||
|
UNAUTHORIZED_ACCESS = 'دسترسی غیرمجاز',
|
||||||
|
USER_NOT_AUTHENTICATED = 'کاربر احراز هویت نشده است',
|
||||||
|
|
||||||
|
// Connection errors
|
||||||
|
CONNECTION_FAILED = 'اتصال برقرار نشد',
|
||||||
|
WEBSOCKET_ERROR = 'خطا در ارتباط WebSocket',
|
||||||
|
CONNECTION_TIMEOUT = 'زمان اتصال به پایان رسید',
|
||||||
|
CONNECTION_REFUSED = 'اتصال رد شد',
|
||||||
|
|
||||||
|
// Session errors
|
||||||
|
SESSION_NOT_FOUND = 'جلسه چت یافت نشد',
|
||||||
|
SESSION_CREATION_FAILED = 'ایجاد جلسه چت با شکست مواجه شد',
|
||||||
|
SESSION_ACCESS_DENIED = 'دسترسی به جلسه چت مجاز نیست',
|
||||||
|
SESSION_EXPIRED = 'جلسه چت منقضی شده است',
|
||||||
|
SESSION_ALREADY_EXISTS = 'جلسه چت قبلاً وجود دارد',
|
||||||
|
|
||||||
|
// Message errors
|
||||||
|
MESSAGE_TOO_LONG = 'پیام از حداکثر طول مجاز تجاوز کرده است',
|
||||||
|
MESSAGE_EMPTY = 'پیام نمیتواند خالی باشد',
|
||||||
|
MESSAGE_SEND_FAILED = 'ارسال پیام با شکست مواجه شد',
|
||||||
|
INVALID_MESSAGE_FORMAT = 'فرمت پیام نامعتبر است',
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
RATE_LIMIT_EXCEEDED = 'تعداد پیامهای ارسالی بیش از حد مجاز است',
|
||||||
|
TOO_MANY_CONNECTIONS = 'تعداد اتصالات بیش از حد مجاز است',
|
||||||
|
|
||||||
|
// Service errors
|
||||||
|
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
|
||||||
|
SERVICE_UNAVAILABLE = 'سرویس موقتاً در دسترس نیست',
|
||||||
|
INTERNAL_SERVER_ERROR = '.خطای داخلی سرور',
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
INVALID_SESSION_ID = 'شناسه جلسه نامعتبر است',
|
||||||
|
INVALID_USER_ID = 'شناسه کاربر نامعتبر است',
|
||||||
|
INVALID_MESSAGE_ID = 'شناسه پیام نامعتبر است',
|
||||||
|
REQUIRED_FIELD_MISSING = 'فیلد ضروری موجود نیست',
|
||||||
|
|
||||||
|
// Success messages
|
||||||
|
AUTHENTICATED = 'احراز هویت موفقیتآمیز',
|
||||||
|
SESSION_CREATED = 'جلسه چت با موفقیت ایجاد شد',
|
||||||
|
CHAT_JOINED = 'با موفقیت به چت متصل شدید',
|
||||||
|
MESSAGE_SENT = 'پیام ارسال شد',
|
||||||
|
CONNECTION_ESTABLISHED = 'اتصال برقرار شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum SmsMessage {
|
||||||
|
SEND_SMS_ERROR = 'خطا در ارسال پیامک',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum RestMessage {
|
||||||
|
NOT_FOUND = 'کسب و کار یافت نشد',
|
||||||
|
BUSINESS_ID_REQUIRED = 'شناسه کسب و کار مورد نیاز است',
|
||||||
|
SLUG_REQUIRED = 'اسلاگ مورد نیاز است',
|
||||||
|
SLUG_MUST_BE_STRING = 'اسلاگ باید یک رشته باشد',
|
||||||
|
DOMAIN_INVALID = 'دامنه وارد شده معتبر نیست',
|
||||||
|
DOMAIN_UPDATED = 'دامنه با موفقیت بهروزرسانی شد',
|
||||||
|
DOMAIN_VERIFICATION_INITIATED = 'فرآیند تایید دامنه آغاز شد',
|
||||||
|
DOMAIN_VERIFICATION_FAILED = 'تایید دامنه با خطا مواجه شد',
|
||||||
|
DOMAIN_VERIFICATION_SUCCESS = 'دامنه با موفقیت تایید شد',
|
||||||
|
DOMAIN_VERIFICATION_METHOD_INVALID = 'روش تایید دامنه نامعتبر است',
|
||||||
|
DOMAIN_ALREADY_VERIFIED = 'این دامنه قبلا تایید شده است',
|
||||||
|
DOMAIN_REQUIRED = 'دامنه مورد نیاز است',
|
||||||
|
DOMAIN_MUST_BE_STRING = 'دامنه باید یک رشته باشد',
|
||||||
|
STAFF_NOT_FOUND = 'کاربر ادمین یافت نشد',
|
||||||
|
ZARINPAL_MERCHANT_ID_REQUIRED = 'شناسه مرچنت زرین پال مورد نیاز است',
|
||||||
|
ZARINPAL_MERCHANT_ID_STRING = 'شناسه مرچنت زرین پال باید یک رشته باشد',
|
||||||
|
LOGO_URL_REQUIRED = 'آدرس تصویر لوگو مورد نیاز است',
|
||||||
|
LOGO_URL_STRING = 'آدرس تصویر لوگو باید یک رشته باشد',
|
||||||
|
LOGO_URL_INVALID = 'آدرس تصویر لوگو معتبر نیست',
|
||||||
|
BUSINESS_SETTINGS_UPDATED = 'تنظیمات کسب و کار با موفقیت به روز رسانی شد',
|
||||||
|
NAME_REQUIRED = 'نام کسب و کار مورد نیاز است',
|
||||||
|
NAME_STRING = 'نام کسب و کار باید یک رشته باشد',
|
||||||
|
DOMAIN_CONNECTED = 'دامنه با موفقیت متصل شد',
|
||||||
|
DOMAIN_NOT_CONNECTED = 'دامنه به سرور ما متصل نشد',
|
||||||
|
DOMAIN_NOT_FOUND = 'دامنه یافت نشد',
|
||||||
|
DOMAIN_VERIFICATION_TOKEN_REQUIRED = 'توکن تایید دامنه مورد نیاز است',
|
||||||
|
INSUFFICIENT_QUOTA = 'حجم شما برای ساخت ایمیل کافی نمی باشد، لطفا حجم را افزایش دهید',
|
||||||
|
QUOTA_DOWNGRADE_NOT_ALLOWED = 'کاهش حجم ایمیل مجاز نیست، فقط میتوانید حجم را افزایش دهید',
|
||||||
|
QUOTA_AMOUNT_REQUIRED = 'مقدار حجم مورد نیاز است',
|
||||||
|
QUOTA_AMOUNT_MUST_BE_NUMBER = 'مقدار حجم باید یک عدد باشد',
|
||||||
|
QUOTA_AMOUNT_MUST_BE_POSITIVE = 'مقدار حجم باید مثبت باشد',
|
||||||
|
CALLBACK_URL_MUST_BE_STRING = 'آدرس بازگشت باید یک رشته باشد',
|
||||||
|
CALLBACK_URL_INVALID = 'آدرس بازگشت معتبر نیست',
|
||||||
|
PURCHASE_DESCRIPTION_MUST_BE_STRING = 'توضیحات خرید باید یک رشته باشد',
|
||||||
|
QUOTA_PURCHASE_SUCCESS = 'درخواست خرید حجم با موفقیت ثبت شد',
|
||||||
|
QUOTA_PURCHASE_FAILED = 'خرید حجم با خطا مواجه شد',
|
||||||
|
PAYMENT_GATEWAY_ERROR = 'خطا در اتصال به درگاه پرداخت',
|
||||||
|
QUOTA_UPDATED = 'حجم ایمیل با موفقیت به روز شد',
|
||||||
|
USER_DOES_NOT_HAVE_ACCESS_TO_SERVICE = 'کاربر دسترسی به این سرویس را ندارد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum DomainMessage {
|
||||||
|
// Domain Ownership & Verification
|
||||||
|
NOT_BELONG_TO_BUSINESS = 'دامنه به این کسب و کار تعلق ندارد',
|
||||||
|
NOT_VERIFIED = 'دامنه باید تایید شده باشد',
|
||||||
|
DOMAIN_NOT_FOUND = 'دامنه یافت نشد',
|
||||||
|
DOMAIN_ALREADY_EXISTS = 'دامنه [name] قبلا ثبت شده است',
|
||||||
|
DOMAIN_DELETED_SUCCESSFULLY = 'دامنه با موفقیت حذف شد',
|
||||||
|
VERIFICATION_NOT_FOUND = 'تایید دامنه یافت نشد',
|
||||||
|
NO_DOMAIN_FOUND = 'دامنه ای یافت نشد',
|
||||||
|
DOMAIN_CHECKED = 'دامنه با موفقیت بررسی شد',
|
||||||
|
|
||||||
|
// Domain Creation & Setup
|
||||||
|
DOMAIN_CREATED_WITH_RECORDS = 'دامنه [name] با موفقیت ایجاد شد و تمام رکوردهای مورد نیاز تولید گردید',
|
||||||
|
ALL_REQUIRED_RECORDS_GENERATED = 'تمام رکوردهای مورد نیاز (MX, SPF, DMARC, DKIM) به طور خودکار برای شما تولید شدهاند',
|
||||||
|
ESTIMATED_PROPAGATION_TIME = 'تغییرات DNS معمولاً ۱۵ دقیقه تا ۴۸ ساعت در سراسر جهان منتشر میشود',
|
||||||
|
|
||||||
|
// DNS Records Instructions
|
||||||
|
ADD_MX_RECORD = 'یک رکورد MX با نام [name] که به [value] با اولویت [priority] اشاره کند اضافه کنید',
|
||||||
|
ADD_TXT_RECORD = 'یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید',
|
||||||
|
ADD_DKIM_RECORD = 'یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (کلید عمومی DKIM)',
|
||||||
|
ADD_DMARC_RECORD = 'یک رکورد TXT با نام [name] و مقدار [value] اضافه کنید (سیاست DMARC)',
|
||||||
|
ADD_CNAME_RECORD = 'یک رکورد CNAME با نام [name] که به [value] اشاره کند اضافه کنید',
|
||||||
|
ADD_GENERIC_RECORD = 'یک رکورد [type] با نام [name] و مقدار [value] اضافه کنید',
|
||||||
|
|
||||||
|
// Domain Setup Steps
|
||||||
|
STEP_LOGIN_DNS_PANEL = 'وارد پنل مدیریت DNS ثبت کننده دامنه خود شوید',
|
||||||
|
STEP_ADD_ALL_RECORDS = 'تمام رکوردهای DNS مورد نیاز لیست شده در بالا را به زون DNS دامنه خود اضافه کنید',
|
||||||
|
STEP_INCLUDE_ALL_TYPES = 'مطمئن شوید که رکوردهای MX، SPF، DMARC و DKIM را برای عملکرد کامل ایمیل شامل کردهاید',
|
||||||
|
STEP_WAIT_PROPAGATION = 'منتظر انتشار DNS بمانید (معمولاً ۱۵ دقیقه تا ۴۸ ساعت)',
|
||||||
|
STEP_USE_CHECK_ENDPOINT = "از نقطه پایانی 'بررسی DNS' برای تایید تنظیم صحیح رکوردهای خود استفاده کنید",
|
||||||
|
STEP_DOMAIN_READY = 'پس از تایید، دامنه شما برای سرویسهای ایمیل آماده خواهد بود!',
|
||||||
|
|
||||||
|
// Status Messages
|
||||||
|
RECORDS_PENDING_VERIFICATION = '[count] رکورد DNS هنوز در انتظار تایید هستند',
|
||||||
|
ALL_REQUIRED_RECORDS_MUST_BE_CONFIGURED = 'تمام [count] رکورد مورد نیاز باید برای کار درست ایمیل تنظیم شوند',
|
||||||
|
DOMAIN_FULLY_VERIFIED = 'دامنه شما به طور کامل تایید شده و برای ایمیل آماده است!',
|
||||||
|
EMAIL_ACCOUNTS_READY = 'اکنون میتوانید حسابهای ایمیل ایجاد کرده و شروع به ارسال/دریافت ایمیل کنید',
|
||||||
|
|
||||||
|
// Verification Status
|
||||||
|
DOMAIN_VERIFICATION_INITIATED = 'فرآیند تایید دامنه آغاز شد',
|
||||||
|
DOMAIN_VERIFICATION_FAILED = 'تایید دامنه با خطا مواجه شد',
|
||||||
|
DOMAIN_VERIFICATION_SUCCESS = 'دامنه با موفقیت تایید شد',
|
||||||
|
DOMAIN_VERIFICATION_PENDING = 'تایید دامنه در انتظار است',
|
||||||
|
|
||||||
|
// DNS Record Status
|
||||||
|
DNS_RECORD_VERIFIED = 'رکورد DNS تایید شد',
|
||||||
|
DNS_RECORD_FAILED = 'رکورد DNS تایید نشد',
|
||||||
|
DNS_RECORD_NOT_FOUND_OR_MISMATCH = 'رکورد DNS یافت نشد یا مقدار مطابقت ندارد',
|
||||||
|
|
||||||
|
// DKIM Messages
|
||||||
|
DKIM_ENABLED_AUTOMATICALLY = 'DKIM به طور خودکار برای دامنه [name] فعال شد',
|
||||||
|
DKIM_FAILED_TO_CREATE = 'ایجاد DKIM برای دامنه [name] ناموفق بود',
|
||||||
|
DKIM_ENABLED_SUCCESSFULLY = 'DKIM برای دامنه [name] با موفقیت فعال شد',
|
||||||
|
|
||||||
|
// Error Messages
|
||||||
|
FAILED_TO_VERIFY_DOMAIN = 'تایید دامنه [name] ناموفق بود',
|
||||||
|
FAILED_TO_SETUP_MAIL_SERVER = 'راهاندازی دامنه [name] در سرور ایمیل ناموفق بود',
|
||||||
|
DOMAIN_CREATION_FAILED = 'ایجاد دامنه ناموفق بود',
|
||||||
|
DNS_VERIFICATION_ATTEMPTS_EXCEEDED = 'تعداد تلاشهای تایید DNS از حد مجاز بیشتر شد',
|
||||||
|
|
||||||
|
// Service Messages
|
||||||
|
DOMAIN_UPDATED_SUCCESSFULLY = 'دامنه بروزرسانی شد',
|
||||||
|
VERIFICATION_TOKEN_PREFIX = 'توکن تایید: [token]',
|
||||||
|
RECORD_NEEDS_FIXING = 'رکورد [type] برای [name] نیاز به اصلاح دارد: [error]',
|
||||||
|
RECORD_PENDING_VERIFICATION_DETAIL = 'رکورد [type] برای [name] هنوز در انتظار تایید است. لطفا مطمئن شوید که انتشار DNS کامل شده است.',
|
||||||
|
RECORD_DESCRIPTION_EMAIL_FUNCTIONALITY = 'رکورد [type] برای عملکرد ایمیل',
|
||||||
|
BUSINESS_ALREADY_HAS_DOMAIN = 'این کسب و کار قبلا دامنه ای دارد',
|
||||||
|
CAN_NOT_USE_THIS_DOMAIN = 'این دامنه نمیتواند ثبت شود',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum MailServerMessage {
|
||||||
|
FAILED_TO_CREATE_ACCOUNT = 'خطا در ایجاد ایمیل',
|
||||||
|
FAILED_TO_CREATE_DKIM_KEY = 'خطا در ایجاد کلید DKIM',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum DnsRecordMessage {
|
||||||
|
DNS_RECORD_NOT_FOUND = 'رکورد DNS یافت نشد',
|
||||||
|
DNS_RECORD_DELETED = 'رکورد DNS با موفقیت حذف شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum RoleMessage {
|
||||||
|
NOT_FOUND = 'نقش یافت نشد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum SignatureMessage {
|
||||||
|
NAME_NOT_EMPTY = 'نام امضاء نمیتواند خالی باشد',
|
||||||
|
NAME_MAX_LENGTH = 'نام امضاء نمیتواند بیشتر از حداکثر طول مجاز باشد',
|
||||||
|
NAME_STRING = 'نام امضاء باید یک رشته باشد',
|
||||||
|
TEXT_CONTENT_NOT_EMPTY = 'محتوای متنی امضاء نمیتواند خالی باشد',
|
||||||
|
TEXT_CONTENT_STRING = 'محتوای متنی امضاء باید یک رشته باشد',
|
||||||
|
TEXT_CONTENT_MAX_LENGTH = 'محتوای متنی امضاء نمیتواند بیشتر از حداکثر طول مجاز باشد',
|
||||||
|
IS_ACTIVE_NOT_EMPTY = 'وضعیت فعال بودن نمیتواند خالی باشد',
|
||||||
|
IS_ACTIVE_BOOLEAN = 'وضعیت فعال بودن باید بولین باشد',
|
||||||
|
IS_DEFAULT_NOT_EMPTY = 'وضعیت پیش فرض نمیتواند خالی باشد',
|
||||||
|
IS_DEFAULT_BOOLEAN = 'وضعیت پیش فرض باید بولین باشد',
|
||||||
|
CREATED = 'امضاء با موفقیت ایجاد شد',
|
||||||
|
NOT_FOUND = 'امضاء یافت نشد',
|
||||||
|
DELETED = 'امضاء با موفقیت حذف شد',
|
||||||
|
TOGGLE_ACTIVE = 'وضعیت امضاء با موفقیت تغییر کرد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum TemplateMessage {
|
||||||
|
TEMPLATE_NOT_FOUND = 'قالب یافت نشد',
|
||||||
|
TEMPLATE_CREATED_SUCCESSFULLY = 'قالب با موفقیت ایجاد شد',
|
||||||
|
TEMPLATE_UPDATED_SUCCESSFULLY = 'قالب با موفقیت به روزرسانی شد',
|
||||||
|
TEMPLATE_DELETED_SUCCESSFULLY = 'قالب با موفقیت حذف شد',
|
||||||
|
TEMPLATE_RETRIEVED_SUCCESSFULLY = 'قالب با موفقیت دریافت شد',
|
||||||
|
TEMPLATES_LISTED_SUCCESSFULLY = 'لیست قالب ها با موفقیت دریافت شد',
|
||||||
|
TEMPLATE_NAME_REQUIRED = 'نام قالب الزامی است',
|
||||||
|
TEMPLATE_NAME_STRING = 'نام قالب باید یک رشته باشد',
|
||||||
|
TEMPLATE_NAME_MAX_LENGTH = 'نام قالب نمیتواند بیشتر از ۲۵۵ کاراکتر باشد',
|
||||||
|
TEMPLATE_STRUCTURE_REQUIRED = 'ساختار قالب الزامی است',
|
||||||
|
TEMPLATE_STRUCTURE_VALID = 'ساختار قالب باید معتبر باشد',
|
||||||
|
TEMPLATE_RAW_HTML_STRING = 'HTML خام باید یک رشته باشد',
|
||||||
|
TEMPLATE_BUSINESS_ID_REQUIRED = 'شناسه کسب و کار الزامی است',
|
||||||
|
TEMPLATE_BUSINESS_ID_UUID = 'شناسه کسب و کار باید یک UUID معتبر باشد',
|
||||||
|
TEMPLATE_ID_REQUIRED = 'شناسه قالب الزامی است',
|
||||||
|
TEMPLATE_ID_UUID = 'شناسه قالب باید یک UUID معتبر باشد',
|
||||||
|
TEMPLATE_ACCESS_DENIED = 'شما دسترسی به این قالب را ندارید',
|
||||||
|
TEMPLATE_BUSINESS_NOT_FOUND = 'کسب و کار مالک قالب یافت نشد',
|
||||||
|
SEARCH_STRING = 'نام برای جستجو باید یک رشته باشد',
|
||||||
|
TEMPLATE_SELECTED_SUCCESSFULLY = 'وضعیت قالب با موفقیت تغییر کرد',
|
||||||
|
TEMPLATE_THUMBNAIL_URL_VALID = 'آدرس تصویر قالب باید یک آدرس معتبر باشد',
|
||||||
|
TEMPLATE_RAW_HTML_REQUIRED = 'HTML خام قالب الزامی است',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum NotificationMessage {
|
||||||
|
TITLE_IS_REQUIRED = 'عنوان اعلان الزامی است',
|
||||||
|
TITLE_STRING = 'عنوان اعلان باید یک رشته باشد',
|
||||||
|
MESSAGE_IS_REQUIRED = 'متن اعلان الزامی است',
|
||||||
|
MESSAGE_STRING = 'متن اعلان باید یک رشته باشد',
|
||||||
|
USERID_IS_REQUIRED = 'شناسه کاربر الزامی است',
|
||||||
|
USERID_IS_STRING = 'شناسه کاربر باید یک رشته باشد',
|
||||||
|
USERID_IS_UUID = 'شناسه کاربر باید یک UUID معتبر باشد',
|
||||||
|
TYPE_IS_REQUIRED = 'نوع اعلان الزامی است',
|
||||||
|
TYPE_ENUM = 'نوع اعلان باید یک enum معتبر باشد',
|
||||||
|
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = 'اعلان یافت نشد یا دسترسی آن را ندارید',
|
||||||
|
LOGIN_MESSAGE = 'ورود با موفقیت انجام شد در تاریخ [loginDate]',
|
||||||
|
LOGIN = 'ورود',
|
||||||
|
NEW_EMAIL = 'ایمیل جدید',
|
||||||
|
NEW_EMAIL_MESSAGE = 'یک ایمیل جدید با موضوع [subject] دریافت شد',
|
||||||
|
CHANGE_PASSWORD_MESSAGE = 'رمز عبور شما با موفقیت تغییر کرد',
|
||||||
|
CHANGE_PASSWORD = 'تغییر رمز عبور',
|
||||||
|
NOT_FOUND = 'اعلان یافت نشد',
|
||||||
|
READ_SUCCESS = 'اعلان با موفقیت خوانده شد',
|
||||||
|
ORDER_CREATED = 'سفارش جدید ایجاد شد',
|
||||||
|
ORDER_STATUS_CHANGED = 'وضعیت سفارش تغییر کرد',
|
||||||
|
PAYMENT_SUCCESS = 'پرداخت با موفقیت انجام شد',
|
||||||
|
PAGER_CREATED = 'پیج جدید ایجاد شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum SettingMessageEnum {
|
||||||
|
SMS_MUST_BE_BOOLEAN = 'تنظیمات ارسال پیامک باید یک boolean باشد',
|
||||||
|
EMAIL_MUST_BE_BOOLEAN = 'تنظیمات ارسال ایمیل باید یک boolean باشد',
|
||||||
|
NOTIFICATION_NOT_FOUND_OR_ACCESS_DENIED = 'اعلان یافت نشد یا دسترسی آن را ندارید',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum AiMessage {
|
||||||
|
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
|
||||||
|
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
|
||||||
|
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
|
||||||
|
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
|
||||||
|
DESCRIPTION_MUST_BE_A_STRING = 'شرح قالب باید یک رشته باشد',
|
||||||
|
DESCRIPTION_MUST_BE_AT_LEAST_10_CHARACTERS = 'شرح قالب باید حداقل ۱۰ کاراکتر باشد',
|
||||||
|
DESCRIPTION_MUST_BE_LESS_THAN_500_CHARACTERS = 'شرح قالب نمیتواند بیشتر از ۵۰۰ کاراکتر باشد',
|
||||||
|
THEME_MUST_BE_A_VALID_VALUE = 'تم قالب باید یکی از مقادیر مجاز باشد',
|
||||||
|
STYLE_MUST_BE_A_VALID_VALUE = 'سبک قالب باید یکی از مقادیر مجاز باشد',
|
||||||
|
PURPOSE_MUST_BE_A_VALID_VALUE = 'هدف قالب باید یکی از مقادیر مجاز باشد',
|
||||||
|
TEMPLATE_NAME_MUST_BE_A_STRING = 'نام قالب باید یک رشته باشد',
|
||||||
|
TEMPLATE_NAME_MUST_BE_LESS_THAN_100_CHARACTERS = 'نام قالب نمیتواند بیشتر از ۱۰۰ کاراکتر باشد',
|
||||||
|
PREFERRED_COLORS_MUST_BE_A_STRING = 'رنگهای ترجیحی باید یک رشته باشد',
|
||||||
|
PREFERRED_COLORS_MUST_BE_LESS_THAN_200_CHARACTERS = 'رنگهای ترجیحی نمیتواند بیشتر از ۲۰۰ کاراکتر باشد',
|
||||||
|
PREFERRED_COLORS_IS_REQUIRED = 'رنگهای ترجیحی الزامی است',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum TwoFactorMessage {
|
||||||
|
TWO_FACTOR_SETUP_INITIATED = 'راهاندازی احراز هویت دو مرحلهای با موفقیت آغاز شد',
|
||||||
|
TWO_FACTOR_ENABLED_SUCCESSFULLY = 'احراز هویت دو مرحلهای با موفقیت فعال شد',
|
||||||
|
TWO_FACTOR_DISABLED_SUCCESSFULLY = 'احراز هویت دو مرحلهای با موفقیت غیرفعال شد',
|
||||||
|
TWO_FACTOR_SETUP_FAILED = 'راهاندازی احراز هویت دو مرحلهای ناموفق بود',
|
||||||
|
TWO_FACTOR_ENABLE_FAILED = 'فعالسازی احراز هویت دو مرحلهای ناموفق بود',
|
||||||
|
TWO_FACTOR_TOKEN_VERIFIED_SUCCESSFULLY = 'کد احراز هویت دو مرحلهای با موفقیت تأیید شد',
|
||||||
|
TWO_FACTOR_TOKEN_VERIFIED_FAILED = 'کد احراز هویت دو مرحلهای نامعتبر میباشد',
|
||||||
|
TWO_FACTOR_NOT_ENABLED = 'احراز هویت دو مرحلهای فعال نیست',
|
||||||
|
TWO_FACTOR_BACKUP_CODES_GENERATED_SUCCESSFULLY = 'کدهای پشتیبان با موفقیت تولید شدند',
|
||||||
|
TWO_FACTOR_BACKUP_CODE_VERIFIED_SUCCESSFULLY = 'کد پشتیبان با موفقیت تأیید شد',
|
||||||
|
TWO_FACTOR_BACKUP_CODE_VERIFIED_FAILED = 'کد پشتیبان نامعتبر است',
|
||||||
|
TWO_FACTOR_TOKEN_REQUIRED = 'کد احراز هویت دو مرحلهای الزامی است',
|
||||||
|
TWO_FACTOR_TOKEN_INVALID = 'کد احراز هویت دو مرحلهای نامعتبر است',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum CouponMessage {
|
||||||
|
NOT_FOUND = 'کوپن یافت نشد',
|
||||||
|
CODE_ALREADY_EXISTS = 'کد کوپن قبلاً ثبت شده است',
|
||||||
|
END_DATE_MUST_BE_AFTER_START_DATE = 'تاریخ پایان باید بعد از تاریخ شروع باشد',
|
||||||
|
PERCENTAGE_MUST_BE_BETWEEN_0_AND_100 = 'درصد تخفیف باید بین ۰ تا ۱۰۰ باشد',
|
||||||
|
COUPON_INACTIVE = 'کوپن غیرفعال است',
|
||||||
|
COUPON_NOT_STARTED = 'کوپن هنوز شروع نشده است',
|
||||||
|
COUPON_EXPIRED = 'کوپن منقضی شده است',
|
||||||
|
COUPON_MAX_USES_REACHED = 'حداکثر استفاده از کوپن به پایان رسیده است',
|
||||||
|
MIN_ORDER_AMOUNT_NOT_MET = 'مبلغ سفارش کمتر از حداقل مجاز است',
|
||||||
|
COUPON_CREATED = 'کوپن با موفقیت ایجاد شد',
|
||||||
|
COUPON_UPDATED = 'کوپن با موفقیت بهروزرسانی شد',
|
||||||
|
COUPON_DELETED = 'کوپن با موفقیت حذف شد',
|
||||||
|
COUPON_VALID = 'کوپن معتبر است',
|
||||||
|
COUPON_INVALID = 'کوپن نامعتبر است',
|
||||||
|
COUPON_RESTRICTED_TO_USER = 'کوپن فقط برای کاربر مشخصی معتبر است',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum ReviewMessage {
|
||||||
|
NOT_FOUND = 'نظر یافت نشد',
|
||||||
|
ALREADY_COMMENTED = 'شما قبلاً برای این غذا نظر دادهاید',
|
||||||
|
CAN_ONLY_UPDATE_OWN = 'شما فقط میتوانید نظرات خود را ویرایش کنید',
|
||||||
|
CAN_ONLY_DELETE_OWN = 'شما فقط میتوانید نظرات خود را حذف کنید',
|
||||||
|
RATING_REQUIRED = 'امتیاز الزامی است',
|
||||||
|
RATING_INVALID = 'امتیاز باید بین ۱ تا ۵ باشد',
|
||||||
|
COMMENT_CREATED = 'نظر با موفقیت ثبت شد',
|
||||||
|
COMMENT_UPDATED = 'نظر با موفقیت بهروزرسانی شد',
|
||||||
|
COMMENT_DELETED = 'نظر با موفقیت حذف شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum IconMessage {
|
||||||
|
NOT_FOUND = 'آیکون یافت نشد',
|
||||||
|
NOT_CREATED = 'ایجاد آیکون با خطا مواجه شد',
|
||||||
|
NOT_UPDATED = 'بهروزرسانی آیکون با خطا مواجه شد',
|
||||||
|
NOT_DELETED = 'حذف آیکون با خطا مواجه شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum GroupMessage {
|
||||||
|
NOT_FOUND = 'گروه آیکون یافت نشد',
|
||||||
|
NOT_CREATED = 'ایجاد گروه آیکون با خطا مواجه شد',
|
||||||
|
NOT_UPDATED = 'بهروزرسانی گروه آیکون با خطا مواجه شد',
|
||||||
|
NOT_DELETED = 'حذف گروه آیکون با خطا مواجه شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum OrderMessage {
|
||||||
|
NOT_FOUND = 'سفارش یافت نشد',
|
||||||
|
PAYMENT_METHOD_MISSING = 'روش پرداخت سفارش مشخص نشده است',
|
||||||
|
INVALID_STATUS_TRANSITION = 'انتقال وضعیت نامعتبر است',
|
||||||
|
CART_EMPTY = 'سبد خرید خالی است. لطفا قبل از ایجاد سفارش، آیتمهایی به سبد اضافه کنید',
|
||||||
|
DELIVERY_METHOD_NOT_FOUND = 'روش ارسال یافت نشد',
|
||||||
|
PAYMENT_METHOD_REQUIRED = 'روش پرداخت الزامی است. لطفا قبل از ایجاد سفارش، روش پرداخت را تنظیم کنید',
|
||||||
|
USER_NOT_FOUND = 'کاربر یافت نشد',
|
||||||
|
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
|
||||||
|
DELIVERY_NOT_FOUND = 'روش ارسال یافت نشد',
|
||||||
|
MIN_ORDER_AMOUNT_NOT_MET = 'مبلغ سفارش کمتر از حداقل مجاز برای این روش ارسال است',
|
||||||
|
TABLE_NUMBER_REQUIRED = 'شماره میز برای سفارش در محل الزامی است',
|
||||||
|
ADDRESS_REQUIRED = 'آدرس الزامی است. لطفا قبل از ایجاد سفارش، آدرس تحویل را تنظیم کنید',
|
||||||
|
CAR_ADDRESS_REQUIRED = 'آدرس خودرو الزامی است. لطفا قبل از ایجاد سفارش، آدرس خودرو را تنظیم کنید',
|
||||||
|
PAYMENT_METHOD_NOT_FOUND = 'روش پرداخت یافت نشد',
|
||||||
|
PAYMENT_METHOD_NOT_ENABLED = 'روش پرداخت برای این رستوران فعال نیست',
|
||||||
|
FOOD_NOT_FOUND = 'غذا یافت نشد',
|
||||||
|
FOOD_NOT_BELONGS_TO_RESTAURANT = 'غذا به این رستوران تعلق ندارد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum CartMessage {
|
||||||
|
TABLE_NUMBER_REQUIRED = 'شماره میز برای سفارش در محل الزامی است',
|
||||||
|
CAR_ADDRESS_REQUIRED = 'آدرس خودرو برای تحویل به خودرو الزامی است',
|
||||||
|
ADDRESS_REQUIRED = 'آدرس برای تحویل پیک الزامی است',
|
||||||
|
NOT_FOUND = 'سبد خرید یافت نشد',
|
||||||
|
COUPON_NOT_FOUND = 'کوپن یافت نشد',
|
||||||
|
FOOD_NOT_FOUND = 'غذا یافت نشد',
|
||||||
|
FOOD_NOT_BELONGS_TO_RESTAURANT = 'غذا به این رستوران تعلق ندارد',
|
||||||
|
FOOD_NO_INVENTORY = 'غذا موجودی ندارد',
|
||||||
|
INSUFFICIENT_STOCK = 'موجودی کافی نیست',
|
||||||
|
USER_NOT_FOUND = 'کاربر یافت نشد',
|
||||||
|
ADDRESS_NOT_FOUND = 'آدرس یافت نشد',
|
||||||
|
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
|
||||||
|
ADDRESS_NOT_BELONGS_TO_USER = 'آدرس به کاربر فعلی تعلق ندارد',
|
||||||
|
ADDRESS_NO_COORDINATES = 'آدرس مختصات جغرافیایی ندارد',
|
||||||
|
ADDRESS_OUTSIDE_SERVICE_AREA = 'آدرس خارج از محدوده سرویس رستوران است',
|
||||||
|
DELIVERY_METHOD_NOT_FOUND = 'روش ارسال یافت نشد',
|
||||||
|
DELIVERY_METHOD_NOT_ENABLED = 'روش ارسال برای این رستوران فعال نیست',
|
||||||
|
PAYMENT_METHOD_NOT_FOUND = 'روش پرداخت یافت نشد',
|
||||||
|
PAYMENT_METHOD_NOT_ENABLED = 'روش پرداخت برای این رستوران فعال نیست',
|
||||||
|
WALLET_NOT_FOUND = 'کیف پول کاربر یافت نشد',
|
||||||
|
WALLET_INSUFFICIENT = 'موجودی کیف پول کافی نیست',
|
||||||
|
COUPON_MAX_USES_REACHED = 'حداکثر استفاده از کوپن به پایان رسیده است',
|
||||||
|
ITEM_NOT_FOUND = 'آیتم در سبد خرید یافت نشد',
|
||||||
|
DELIVERY_METHOD_NOT_FOUND_FOR_RESTAURANT = 'روش ارسال برای این رستوران یافت نشد',
|
||||||
|
COUPON_CANNOT_BE_APPLIED = 'این کوپن قابل اعمال بر روی آیتمهای سبد خرید شما نیست. لطفا آیتمهایی از دستهبندیها یا غذاهای مشخص شده اضافه کنید',
|
||||||
|
FOODS_MUST_HAVE_PICKUP_SERVE_FOR_COURIER = 'تمام غذاها باید قابلیت تحویل پیک داشته باشند. غذاهای زیر این قابلیت را ندارند: [foodTitles]',
|
||||||
|
FOOD_ONLY_AVAILABLE_DURING_MEAL_TIMES = 'غذا [foodTitle] فقط در ساعات وعدههای غذایی (صبحانه، ناهار، عصرانه یا شام) در دسترس است. زمان فعلی خارج از ساعات وعدههای غذایی است',
|
||||||
|
FOOD_ONLY_AVAILABLE_FOR_MEAL_TYPES = 'غذا [foodTitle] فقط برای [allowedMealTypes] در دسترس است. زمان فعلی [mealType] است که با این غذا سازگار نیست',
|
||||||
|
FOOD_ONLY_AVAILABLE_ON_DAYS = 'غذا [foodTitle] فقط در روزهای [allowedDays] در دسترس است. امروز [currentDay] است که این غذا در دسترس نیست',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum PaymentMessage {
|
||||||
|
UNSUPPORTED_PAYMENT_METHOD = 'روش پرداخت پشتیبانی نمیشود',
|
||||||
|
ORDER_NOT_FOUND = 'سفارش یافت نشد',
|
||||||
|
AMOUNT_MUST_BE_GREATER_THAN_ZERO = 'مبلغ باید بیشتر از صفر باشد',
|
||||||
|
PAYMENT_METHOD_NOT_FOUND = 'روش پرداخت یافت نشد',
|
||||||
|
GATEWAY_REQUIRED = 'درگاه پرداخت برای پرداخت آنلاین الزامی است',
|
||||||
|
MERCHANT_ID_REQUIRED = 'شناسه مرچنت برای پرداخت آنلاین الزامی است',
|
||||||
|
RESTAURANT_DOMAIN_REQUIRED = 'دامنه رستوران برای پرداخت آنلاین الزامی است',
|
||||||
|
WALLET_NOT_FOUND = 'کیف پول کاربر یافت نشد',
|
||||||
|
INSUFFICIENT_WALLET_BALANCE = 'موجودی کیف پول کافی نیست',
|
||||||
|
PAYMENT_NOT_FOUND = 'پرداخت یافت نشد',
|
||||||
|
INVALID_PAYMENT_CONFIGURATION = 'پیکربندی پرداخت نامعتبر است',
|
||||||
|
PAYMENT_METHOD_NOT_CASH = 'روش پرداخت نقدی نیست',
|
||||||
|
PAYMENT_ALREADY_PAID = 'پرداخت قبلا انجام شده است',
|
||||||
|
EXISTING_PENDING_PAYMENT_MISMATCH = 'پرداخت در انتظار موجود با روش پرداخت سفارش مطابقت ندارد',
|
||||||
|
ZARINPAL_INVALID_RESPONSE = 'پاسخ نامعتبر از API زرینپال',
|
||||||
|
ZARINPAL_PAYMENT_REQUEST_ERROR = 'خطا در درخواست پرداخت زرینپال',
|
||||||
|
ZARINPAL_VERIFY_ERROR = 'خطا در تایید پرداخت زرینپال',
|
||||||
|
GATEWAY_ERROR = 'خطا در اتصال به درگاه پرداخت',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum DeliveryMessage {
|
||||||
|
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
|
||||||
|
DELIVERY_METHOD_NOT_FOUND = 'روش ارسال یافت نشد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum InventoryMessage {
|
||||||
|
AVAILABLE_STOCK_EXCEEDS_TOTAL = 'موجودی موجود نمیتواند از موجودی کل بیشتر باشد',
|
||||||
|
FOOD_NOT_FOUND = 'غذا یافت نشد',
|
||||||
|
FOOD_NOT_BELONGS_TO_RESTAURANT = 'غذا به این رستوران تعلق ندارد',
|
||||||
|
RESTAURANT_NOT_FOUND = 'رستوران یافت نشد',
|
||||||
|
FOODS_NOT_FOUND = 'غذاها یافت نشدند',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const enum PagerMessage {
|
||||||
|
CREATED_SUCCESS = 'درخواست پیج با موفقیت ایجاد شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum UserSuccessMessage {
|
||||||
|
ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد',
|
||||||
|
SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum ContactMessage {
|
||||||
|
NOT_FOUND = 'تماس یافت نشد',
|
||||||
|
CREATED = 'تماس با موفقیت ثبت شد',
|
||||||
|
UPDATED = 'تماس با موفقیت بهروزرسانی شد',
|
||||||
|
DELETED = 'تماس با موفقیت حذف شد',
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Permission names enum
|
||||||
|
* All permission names used in the system should be defined here
|
||||||
|
*/
|
||||||
|
export enum Permission {
|
||||||
|
MANAGE_PAGER = 'manage_pager',
|
||||||
|
// Food Management
|
||||||
|
MANAGE_FOODS = 'manage_foods',
|
||||||
|
MANAGE_CATEGORIES = 'manage_categories',
|
||||||
|
|
||||||
|
// Order Management
|
||||||
|
MANAGE_ORDERS = 'manage_orders',
|
||||||
|
|
||||||
|
// User & Admin Management
|
||||||
|
MANAGE_ADMINS = 'manage_admins',
|
||||||
|
MANAGE_USERS = 'manage_users',
|
||||||
|
|
||||||
|
// Reports & Finances
|
||||||
|
VIEW_REPORTS = 'view_reports',
|
||||||
|
|
||||||
|
|
||||||
|
UPDATE_RESTAURANT = 'update_restaurant',
|
||||||
|
|
||||||
|
// Role Management
|
||||||
|
MANAGE_ROLES = 'manage_roles',
|
||||||
|
MANAGE_REVIEWS = 'manage_reviews',
|
||||||
|
MANAGE_COUPONS = 'manage_coupons',
|
||||||
|
MANAGE_PAYMENTS = 'manage_payments',
|
||||||
|
MANAGE_DELIVERY = 'manage_delivery',
|
||||||
|
MANAGE_SETTINGS = 'manage_settings',
|
||||||
|
MANAGE_SCHEDULES = 'manage_schedules',
|
||||||
|
MANAGE_REPORTS = 'manage_reports',
|
||||||
|
MANAGE_CONTACTS = 'manage_contacts',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission titles mapping (Farsi)
|
||||||
|
* Maps permission names to their Farsi titles
|
||||||
|
*/
|
||||||
|
export const PermissionTitles: Record<Permission, string> = {
|
||||||
|
[Permission.MANAGE_PAGER]: 'مدیریت پیجر',
|
||||||
|
[Permission.MANAGE_FOODS]: 'مدیریت غذاها',
|
||||||
|
[Permission.MANAGE_CATEGORIES]: 'مدیریت دستهبندیها',
|
||||||
|
[Permission.MANAGE_ORDERS]: 'مدیریت سفارشات',
|
||||||
|
[Permission.MANAGE_ADMINS]: 'مدیریت مدیران',
|
||||||
|
[Permission.MANAGE_USERS]: 'مدیریت کاربران',
|
||||||
|
[Permission.VIEW_REPORTS]: 'مشاهده گزارشات',
|
||||||
|
[Permission.UPDATE_RESTAURANT]: 'ویرایش رستوران',
|
||||||
|
[Permission.MANAGE_ROLES]: 'مدیریت نقشها',
|
||||||
|
[Permission.MANAGE_REVIEWS]: 'مدیریت نظرات',
|
||||||
|
[Permission.MANAGE_COUPONS]: 'مدیریت کوپنها',
|
||||||
|
[Permission.MANAGE_PAYMENTS]: 'مدیریت پرداختها',
|
||||||
|
[Permission.MANAGE_DELIVERY]: 'مدیریت تحویل',
|
||||||
|
[Permission.MANAGE_SETTINGS]: 'مدیریت تنظیمات',
|
||||||
|
[Permission.MANAGE_SCHEDULES]: 'مدیریت برنامهها',
|
||||||
|
[Permission.MANAGE_REPORTS]: 'مدیریت گزارشات',
|
||||||
|
[Permission.MANAGE_CONTACTS]: 'مدیریت تماسهای کاربران',
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { ValueProvider } from '@nestjs/common';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MIKRO_ORM_QUERY_LOGGER } from '../constants';
|
||||||
|
|
||||||
|
export const mikroOrmQueryLoggerProvider: ValueProvider = {
|
||||||
|
provide: MIKRO_ORM_QUERY_LOGGER, //
|
||||||
|
useValue: new Logger('mikro-orm'),
|
||||||
|
};
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import { Keyv } from 'keyv';
|
||||||
|
import KeyvRedis from '@keyv/redis';
|
||||||
|
import type { CacheModuleAsyncOptions } from '@nestjs/cache-manager';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CacheableMemory } from 'cacheable';
|
||||||
|
|
||||||
|
export function cacheConfig(): CacheModuleAsyncOptions {
|
||||||
|
return {
|
||||||
|
isGlobal: true,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: async (configService: ConfigService) => {
|
||||||
|
const redisUri = configService.get('REDIS_URI');
|
||||||
|
const namespace = configService.get('CACHE_NAMESPACE', 'app-cache');
|
||||||
|
const ttl = +configService.get<number>('CACHE_TTL', 3600); //1 hour
|
||||||
|
|
||||||
|
return {
|
||||||
|
stores: [
|
||||||
|
new Keyv({
|
||||||
|
store: new CacheableMemory({ ttl: ttl * 1000, lruSize: 5000 }),
|
||||||
|
namespace: namespace
|
||||||
|
}),
|
||||||
|
new KeyvRedis(redisUri, { namespace: namespace }),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import type { HttpModuleAsyncOptions } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
export function httpConfig(): HttpModuleAsyncOptions {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
global: true,
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
return {
|
||||||
|
timeout: configService.getOrThrow<number>('AXIOS_TIMEOUT'),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
global: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { defineConfig, PostgreSqlDriver } from '@mikro-orm/postgresql';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// 1. Load environment variables from your .env file
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// 2. Read variables directly from process.env
|
||||||
|
const DB_PASS = process.env.DB_PASS;
|
||||||
|
const DB_USER = process.env.DB_USER;
|
||||||
|
const DB_HOST = process.env.DB_HOST;
|
||||||
|
const DB_PORT = process.env.DB_PORT ? +process.env.DB_PORT : 5432;
|
||||||
|
const DB_NAME = process.env.DB_NAME;
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
// 3. Add checks to ensure variables are loaded
|
||||||
|
if (!DB_PASS || !DB_USER || !DB_HOST || !DB_PORT || !DB_NAME) {
|
||||||
|
throw new Error('One or more database environment variables are not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedPassword = encodeURIComponent(DB_PASS);
|
||||||
|
|
||||||
|
// 4. Export the result of defineConfig as the default
|
||||||
|
export default defineConfig({
|
||||||
|
entities: ['dist/**/*.entity.js'],
|
||||||
|
entitiesTs: ['src/**/*.entity.ts'],
|
||||||
|
driver: PostgreSqlDriver,
|
||||||
|
dbName: DB_NAME,
|
||||||
|
debug: false,
|
||||||
|
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
|
||||||
|
ensureDatabase: isProduction
|
||||||
|
? false
|
||||||
|
: {
|
||||||
|
forceCheck: true,
|
||||||
|
create: true,
|
||||||
|
schema: 'update',
|
||||||
|
},
|
||||||
|
forceUtcTimezone: true,
|
||||||
|
pool: {
|
||||||
|
min: isProduction ? 5 : 2,
|
||||||
|
max: isProduction ? 20 : 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
acquireTimeoutMillis: 30000,
|
||||||
|
reapIntervalMillis: 1000,
|
||||||
|
createTimeoutMillis: 15000,
|
||||||
|
destroyTimeoutMillis: 5000,
|
||||||
|
},
|
||||||
|
// 5. Use console for CLI logging, not the injected Nest logger
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
logger: isProduction ? console.log.bind(console) : console.debug.bind(console),
|
||||||
|
schemaGenerator: {
|
||||||
|
disableForeignKeys: false,
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
path: './database/migrations',
|
||||||
|
pathTs: './database/migrations',
|
||||||
|
tableName: 'migrations',
|
||||||
|
transactional: true,
|
||||||
|
allOrNothing: true,
|
||||||
|
dropTables: false,
|
||||||
|
safe: true,
|
||||||
|
emit: 'ts',
|
||||||
|
},
|
||||||
|
connect: true,
|
||||||
|
allowGlobalContext: true,
|
||||||
|
seeder: {
|
||||||
|
path: './dist/seeders',
|
||||||
|
pathTs: './src/seeders',
|
||||||
|
defaultSeeder: 'DatabaseSeeder',
|
||||||
|
},
|
||||||
|
driverOptions: {
|
||||||
|
connection: {
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveInitialDelayMillis: 10000,
|
||||||
|
statement_timeout: 60000,
|
||||||
|
query_timeout: 60000,
|
||||||
|
idle_in_transaction_session_timeout: 60000,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
application_name: DB_NAME,
|
||||||
|
},
|
||||||
|
connectionRetries: 5,
|
||||||
|
connectionRetryDelay: 3000,
|
||||||
|
validateConnection: (connection: any) => {
|
||||||
|
try {
|
||||||
|
return !!(connection && !connection.closed);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Use console.error directly
|
||||||
|
console.error(`Connection validation failed: ${(e as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
poolErrorHandler: (err: Error) => {
|
||||||
|
// Use console.error directly
|
||||||
|
console.error(`PostgreSQL pool error: ${err.message}`);
|
||||||
|
if (err.message.includes('ECONNRESET') || err.message.includes('Connection terminated unexpectedly')) {
|
||||||
|
console.warn('Connection reset detected, will attempt to reconnect');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { MikroOrmModuleAsyncOptions } from '@mikro-orm/nestjs';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PostgreSqlDriver } from '@mikro-orm/postgresql';
|
||||||
|
// import { defineConfig } from '@mikro-orm/postgresql';
|
||||||
|
|
||||||
|
import type { Logger } from '@nestjs/common';
|
||||||
|
import { MIKRO_ORM_QUERY_LOGGER } from '../common/constants';
|
||||||
|
import { mikroOrmQueryLoggerProvider } from '../common/providers/mikro-orm-logger.provider';
|
||||||
|
|
||||||
|
const dataBaseConfig: MikroOrmModuleAsyncOptions = {
|
||||||
|
// entities: ['dist/**/*.entity.js'],
|
||||||
|
// entitiesTs: ['src/**/*.entity.ts'],
|
||||||
|
// dbName: 'your_db_name',
|
||||||
|
// type: 'postgresql',
|
||||||
|
// user: 'your_user',
|
||||||
|
// password: 'your_password',
|
||||||
|
// host: 'localhost',
|
||||||
|
// port: 5432,
|
||||||
|
// debug: true,
|
||||||
|
|
||||||
|
inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER],
|
||||||
|
providers: [mikroOrmQueryLoggerProvider],
|
||||||
|
useFactory: (configService: ConfigService, logger: Logger) => {
|
||||||
|
const DB_PASS = configService.getOrThrow<string>('DB_PASS');
|
||||||
|
const DB_USER = configService.getOrThrow<string>('DB_USER');
|
||||||
|
const DB_HOST = configService.getOrThrow<string>('DB_HOST');
|
||||||
|
const DB_PORT = configService.getOrThrow<number>('DB_PORT');
|
||||||
|
const DB_NAME = configService.getOrThrow<string>('DB_NAME');
|
||||||
|
const encodedPassword = encodeURIComponent(DB_PASS);
|
||||||
|
const isProduction = configService.getOrThrow<string>('NODE_ENV') === 'production';
|
||||||
|
|
||||||
|
return {
|
||||||
|
// entities: ['dist/**/*.entity.js'],
|
||||||
|
entitiesTs: ['src/**/*.entity.ts'],
|
||||||
|
driver: PostgreSqlDriver,
|
||||||
|
autoLoadEntities: true,
|
||||||
|
dbName: DB_NAME,
|
||||||
|
debug: false,
|
||||||
|
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
|
||||||
|
ensureDatabase: isProduction
|
||||||
|
? false
|
||||||
|
: {
|
||||||
|
forceCheck: true,
|
||||||
|
create: true,
|
||||||
|
schema: 'update',
|
||||||
|
},
|
||||||
|
forceUtcTimezone: true,
|
||||||
|
pool: {
|
||||||
|
min: isProduction ? 5 : 2,
|
||||||
|
max: isProduction ? 20 : 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
acquireTimeoutMillis: 30000,
|
||||||
|
reapIntervalMillis: 1000,
|
||||||
|
createTimeoutMillis: 15000,
|
||||||
|
destroyTimeoutMillis: 5000,
|
||||||
|
},
|
||||||
|
logger: isProduction ? message => logger.log(message) : message => logger.debug(message),
|
||||||
|
schemaGenerator: {
|
||||||
|
// createForeignKey: !isProduction,
|
||||||
|
disableForeignKeys: false,
|
||||||
|
// createIndex: true,
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
path: './database/migrations',
|
||||||
|
pathTs: './database/migrations',
|
||||||
|
tableName: 'migrations',
|
||||||
|
transactional: true,
|
||||||
|
allOrNothing: true,
|
||||||
|
dropTables: false,
|
||||||
|
safe: true,
|
||||||
|
emit: 'ts',
|
||||||
|
},
|
||||||
|
connect: true,
|
||||||
|
allowGlobalContext: true,
|
||||||
|
driverOptions: {
|
||||||
|
connection: {
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveInitialDelayMillis: 10000,
|
||||||
|
statement_timeout: 60000,
|
||||||
|
query_timeout: 60000,
|
||||||
|
idle_in_transaction_session_timeout: 60000,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
application_name: configService.getOrThrow<string>('DB_NAME'),
|
||||||
|
},
|
||||||
|
connectionRetries: 5,
|
||||||
|
connectionRetryDelay: 3000,
|
||||||
|
validateConnection: (connection: any) => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
return !!(connection && !connection.closed);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
logger.error(`Connection validation failed: ${(e as Error).message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
poolErrorHandler: (err: Error) => {
|
||||||
|
logger.error(`PostgreSQL pool error: ${err.message}`);
|
||||||
|
if (err.message.includes('ECONNRESET') || err.message.includes('Connection terminated unexpectedly')) {
|
||||||
|
logger.warn('Connection reset detected, will attempt to reconnect');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dataBaseConfig;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { INestApplication } from '@nestjs/common';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export function getSwaggerConfig(app: INestApplication) {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('D-menu API')
|
||||||
|
.setDescription('API documentation for D-menu backend')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth() // optional: for JWT endpoints
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
|
||||||
|
SwaggerModule.setup('docs', app, document, {
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
filter: true,
|
||||||
|
showExtensions: true,
|
||||||
|
docExpansion: 'none',
|
||||||
|
|
||||||
|
// HIDE MODELS / SCHEMAS
|
||||||
|
defaultModelsExpandDepth: -1,
|
||||||
|
defaultModelExpandDepth: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
|
||||||
|
@Catch(HttpException)
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost) {
|
||||||
|
// Handle HTTP/REST context
|
||||||
|
try {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const reply = ctx.getResponse<FastifyReply>();
|
||||||
|
|
||||||
|
// Safety check: ensure reply has the status method
|
||||||
|
if (!reply || typeof reply.status !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// const request = ctx.getRequest<FastifyRequest>();
|
||||||
|
const status = exception.getStatus() || HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
const message = this.extractMessage(exceptionResponse);
|
||||||
|
|
||||||
|
reply.status(status).send({
|
||||||
|
statusCode: status,
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
// timestamp: new Date().toISOString(),
|
||||||
|
// path: request.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If we can't handle it, let it propagate
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMessage(response: string | Record<string, any>): string | string[] {
|
||||||
|
if (typeof response === 'string') {
|
||||||
|
return [response];
|
||||||
|
}
|
||||||
|
if (typeof response === 'object' && 'message' in response) {
|
||||||
|
return Array.isArray(response.message) ? response.message : [response.message];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'something wrong';
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
// import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||||
|
// import { FastifyRequest } from 'fastify';
|
||||||
|
// import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
|
// import { IPageFormat } from '../../../../danak_dsc_api/src/common/interfaces/IPagination';
|
||||||
|
|
||||||
|
// @Injectable()
|
||||||
|
// export class PaginationInterceptor implements NestInterceptor {
|
||||||
|
// private readonly logger = new Logger(PaginationInterceptor.name);
|
||||||
|
|
||||||
|
// intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
// const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
// const query = request.query as { page: string; limit: string };
|
||||||
|
|
||||||
|
// const page = parseInt(query.page, 10) || 1;
|
||||||
|
// const limit = parseInt(query.limit, 10) || 10;
|
||||||
|
|
||||||
|
// const className = context.getClass().name;
|
||||||
|
// const handlerName = context.getHandler().name;
|
||||||
|
|
||||||
|
// return next.handle().pipe(
|
||||||
|
// map(data => {
|
||||||
|
// if (data && (data.paginate || data.count)) {
|
||||||
|
// const { count, paginate, ...response } = data;
|
||||||
|
// this.logger.log(`paginate response from ${className}.${handlerName}`);
|
||||||
|
// const pager = this.formatPage(page, limit, count, request);
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// pager,
|
||||||
|
// ...response,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return data;
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// private formatPage(page: number, limit: number, totalItems: number, request: FastifyRequest): IPageFormat {
|
||||||
|
// const totalPages = Math.ceil(totalItems / limit);
|
||||||
|
// const prevPage = page === 1 ? false : page - 1;
|
||||||
|
// const nextPage = page >= totalPages ? false : page + 1;
|
||||||
|
|
||||||
|
// const formatLink = (pageNum: number | boolean): string | boolean => {
|
||||||
|
// if (!pageNum) return false;
|
||||||
|
// const protocol = request.protocol;
|
||||||
|
// const hostname = request.hostname;
|
||||||
|
// const originalUrl = request.url;
|
||||||
|
// return `${protocol}://${hostname}${originalUrl.split('?')[0]}?page=${pageNum}&limit=${limit}`;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// page,
|
||||||
|
// limit,
|
||||||
|
// totalItems,
|
||||||
|
// totalPages,
|
||||||
|
// prevPage: formatLink(prevPage),
|
||||||
|
// nextPage: formatLink(nextPage),
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// }
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { FastifyReply } from 'fastify';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseInterceptor implements NestInterceptor {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler<Record<string, unknown>>): Observable<unknown> {
|
||||||
|
// For REST endpoints, wrap the response
|
||||||
|
try {
|
||||||
|
const ctx = context.switchToHttp().getResponse<FastifyReply>();
|
||||||
|
const statusCode = ctx.statusCode;
|
||||||
|
|
||||||
|
// If statusCode is undefined, skip wrapping
|
||||||
|
if (statusCode === undefined) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
map(data => {
|
||||||
|
// If data is already wrapped or doesn't need wrapping, return as-is
|
||||||
|
if (data && typeof data === 'object' && 'data' in data && 'success' in data) {
|
||||||
|
console.log('data', data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data,
|
||||||
|
...('nextCursor' in (data as any) && (data as any).nextCursor != null
|
||||||
|
? { nextCursor: (data as any).nextCursor }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a paginated result (has both data and meta properties)
|
||||||
|
if (data && typeof data === 'object' && 'data' in data && 'meta' in data) {
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
success: true,
|
||||||
|
data: data.data,
|
||||||
|
meta: data.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && 'data' in data) {
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
success: true,
|
||||||
|
data: data.data,
|
||||||
|
...('nextCursor' in data
|
||||||
|
? { nextCursor: (data as any).nextCursor }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// If we can't get HTTP context, return data as-is
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
|
||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HTTPLogger implements NestMiddleware {
|
||||||
|
private readonly logger = new Logger(HTTPLogger.name);
|
||||||
|
// use(req: any, res: any, next: (error?: Error | any) => void) {}
|
||||||
|
|
||||||
|
use(req: FastifyRequest, rep: FastifyReply['raw'], next: () => void) {
|
||||||
|
const { ip, method, originalUrl } = req;
|
||||||
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
const startAt = process.hrtime();
|
||||||
|
|
||||||
|
rep.on('finish', () => {
|
||||||
|
const { statusCode } = rep;
|
||||||
|
const contentLength = rep.getHeader('content-length') || 0;
|
||||||
|
const dif = process.hrtime(startAt);
|
||||||
|
const responseTime = dif[0] * 1e3 + dif[1] * 1e-6;
|
||||||
|
this.logger.log(
|
||||||
|
`${method} - ${originalUrl} - ${statusCode} - ${contentLength} - ${userAgent} - ${ip} - ${responseTime.toFixed(2)}ms`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { getSwaggerConfig } from './config/swagger.config';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
|
||||||
|
// 👈 Import the Fastify application type
|
||||||
|
import { type NestFastifyApplication, FastifyAdapter } from '@nestjs/platform-fastify';
|
||||||
|
import { HttpExceptionFilter } from './core/exceptions/http.exceptions';
|
||||||
|
import { ResponseInterceptor } from './core/interceptors/response.interceptor';
|
||||||
|
// import { PaginationInterceptor } from './core/interceptors/pagination.interceptor';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger('APP');
|
||||||
|
|
||||||
|
// 1. Create the application using the Fastify adapter
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter(), // Instantiate the FastifyAdapter
|
||||||
|
// Optional: Pass the custom logger (like your 'APP' logger) to NestFactory
|
||||||
|
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.register(fastifyCookie);
|
||||||
|
|
||||||
|
await app.register(multipart);
|
||||||
|
app.useGlobalPipes(new ValidationPipe());
|
||||||
|
|
||||||
|
app.useGlobalInterceptors(
|
||||||
|
new ResponseInterceptor(),
|
||||||
|
// , new PaginationInterceptor()
|
||||||
|
);
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
|
const configService = app.get<ConfigService>(ConfigService);
|
||||||
|
// Ensure the port is handled correctly, use 4000 as fallback if needed
|
||||||
|
const APP_PORT = configService.getOrThrow<number>('APP_PORT') ?? 4000;
|
||||||
|
|
||||||
|
// 2. Swagger Integration Note
|
||||||
|
// Since you are using platform-fastify, you must ensure your getSwaggerConfig
|
||||||
|
// function is configured to use the Fastify platform option if necessary.
|
||||||
|
getSwaggerConfig(app);
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
credentials: true,
|
||||||
|
optionsSuccessStatus: 204,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Listen on port using Fastify's listen method
|
||||||
|
// '0.0.0.0' is recommended for Docker/containerized environments
|
||||||
|
await app.listen(APP_PORT, '0.0.0.0', () => {
|
||||||
|
logger.log(`Application is running on: http://localhost:${APP_PORT}`);
|
||||||
|
logger.log(`Swagger documentation: http://localhost:${APP_PORT}/docs`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { AdminService } from './providers/admin.service';
|
||||||
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
|
import { Admin } from './entities/admin.entity';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { AdminRepository } from './repositories/admin.repository';
|
||||||
|
import { Role } from '../roles/entities/role.entity';
|
||||||
|
import { Permission } from '../roles/entities/permission.entity';
|
||||||
|
import { RolePermission } from '../roles/entities/rolePermission.entity';
|
||||||
|
import { AdminController } from './controllers/admin.controller';
|
||||||
|
import { UtilsModule } from '../utils/utils.module';
|
||||||
|
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||||
|
import { AdminRole } from './entities/adminRole.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AdminService, AdminRepository],
|
||||||
|
controllers: [AdminController],
|
||||||
|
imports: [
|
||||||
|
MikroOrmModule.forFeature([Admin, Role, Permission, RolePermission, AdminRole]),
|
||||||
|
JwtModule,
|
||||||
|
UtilsModule,
|
||||||
|
forwardRef(() => RestaurantsModule),
|
||||||
|
],
|
||||||
|
exports: [AdminService, AdminRepository],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { Controller, Post, Body, HttpCode, HttpStatus, Get, UseGuards, Patch, Param, Delete } from '@nestjs/common';
|
||||||
|
import { AdminService } from '../providers/admin.service';
|
||||||
|
import { CreateAdminDto } from '../dto/create-admin.dto';
|
||||||
|
import { UpdateAdminDto } from '../dto/update-admin.dto';
|
||||||
|
import { ApiTags, ApiOperation, ApiBody, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||||
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
|
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
||||||
|
import { RestId } from 'src/common/decorators';
|
||||||
|
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||||
|
import { Admin } from '../entities/admin.entity';
|
||||||
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
|
import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto';
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiTags('admin')
|
||||||
|
@Controller()
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
@Get('admin/admins')
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
|
@ApiOperation({ summary: 'admin' })
|
||||||
|
async getAll(@RestId() restId: string) {
|
||||||
|
const admin = await this.adminService.findAllByRestaurantId(restId);
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/admins/profile')
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
|
@ApiOperation({ summary: 'admin' })
|
||||||
|
async getMe(@AdminId() adminId: string, @RestId() restId: string) {
|
||||||
|
const admin = await this.adminService.findById(adminId, restId);
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/admins')
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@ApiOperation({ summary: 'Create a new admin' })
|
||||||
|
@ApiBody({ type: CreateAdminDto })
|
||||||
|
async create(@Body() dto: CreateAdminDto, @RestId() restId: string) {
|
||||||
|
const admin = await this.adminService.createAdminForMyRestaurant(restId, dto);
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('admin/admins/:adminId')
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
|
@ApiOperation({ summary: 'Update an admin' })
|
||||||
|
@ApiParam({ name: 'adminId', description: 'Admin ID' })
|
||||||
|
@ApiBody({ type: UpdateAdminDto })
|
||||||
|
update(@Param('adminId') adminId: string, @Body() dto: UpdateAdminDto, @RestId() restId: string): Promise<Admin> {
|
||||||
|
return this.adminService.update(adminId, restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/admins/:adminId')
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
|
@ApiOperation({ summary: 'Get an admin by ID' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Admin ID' })
|
||||||
|
getById(@Param('adminId') adminId: string, @RestId() restId: string): Promise<Admin | null> {
|
||||||
|
return this.adminService.findById(adminId, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('admin/admins/:adminId')
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@Permissions(Permission.MANAGE_ADMINS)
|
||||||
|
@ApiOperation({ summary: 'Delete an admin by ID' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Admin ID' })
|
||||||
|
deleteById(@Param('adminId') adminId: string, @RestId() restId: string): Promise<void> {
|
||||||
|
return this.adminService.remove(adminId, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Super Admin Endpoints */
|
||||||
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
@Get('super-admin/restaurants/:restaurantId/admins')
|
||||||
|
@ApiOperation({ summary: 'Get admins for a specific restaurant (Super Admin only)' })
|
||||||
|
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
|
||||||
|
getAdminForRestaurant(@Param('restaurantId') restaurantId: string): Promise<Admin[]> {
|
||||||
|
return this.adminService.getAdminsForRestaurantBySuperAdmin(restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
@Post('super-admin/restaurants/:restaurantId/admins')
|
||||||
|
@ApiOperation({ summary: 'Create admin for a specific restaurant (Super Admin only)' })
|
||||||
|
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
|
||||||
|
@ApiBody({ type: CreateMyRestaurantAdminDto })
|
||||||
|
createAdminForRestaurant(
|
||||||
|
@Param('restaurantId') restaurantId: string,
|
||||||
|
@Body() dto: CreateMyRestaurantAdminDto,
|
||||||
|
): Promise<Admin> {
|
||||||
|
return this.adminService.createAdminForRestaurantBySuperAdmin(restaurantId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
@Delete('super-admin/restaurants/:restaurantId/admins/:adminId')
|
||||||
|
@ApiOperation({ summary: 'Revoke admin role from a restaurant (Super Admin only)' })
|
||||||
|
@ApiParam({ name: 'restaurantId', description: 'Restaurant ID' })
|
||||||
|
@ApiParam({ name: 'adminId', description: 'Admin ID' })
|
||||||
|
revokeAdminFromRestaurant(
|
||||||
|
@Param('restaurantId') restaurantId: string,
|
||||||
|
@Param('adminId') adminId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.adminService.remove(adminId, restaurantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAdminDto {
|
||||||
|
@ApiProperty({ description: 'Mobile phone number' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'First name', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last name', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Role id for the admin' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
roleId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateMyRestaurantAdminDto {
|
||||||
|
@ApiProperty({ description: 'Mobile phone number' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'First name', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last name', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Role id for the admin' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
roleId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateAdminDto } from './create-admin.dto';
|
||||||
|
|
||||||
|
export class UpdateAdminDto extends PartialType(CreateAdminDto) {}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Cascade, Collection, Entity, OneToMany, Property } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
|
import { AdminRole } from './adminRole.entity';
|
||||||
|
import { normalizePhone } from '../../utils/phone.util';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'admins' })
|
||||||
|
export class Admin extends BaseEntity {
|
||||||
|
@Property({ nullable: true })
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@Property({ nullable: true })
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
private _phone!: string;
|
||||||
|
|
||||||
|
@Property({ unique: true })
|
||||||
|
get phone(): string {
|
||||||
|
return this._phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
set phone(value: string) {
|
||||||
|
this._phone = normalizePhone(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new role property
|
||||||
|
@OneToMany(() => AdminRole, adminRole => adminRole.admin, {
|
||||||
|
cascade: [Cascade.ALL],
|
||||||
|
orphanRemoval: true,
|
||||||
|
})
|
||||||
|
roles = new Collection<AdminRole>(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Entity, Index, ManyToOne, Unique } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
|
import { Role } from '../../roles/entities/role.entity';
|
||||||
|
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
||||||
|
import { Admin } from './admin.entity';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'admin_roles' })
|
||||||
|
@Unique({ properties: ['admin', 'restaurant'] })
|
||||||
|
@Index({ properties: ['admin', 'restaurant'] })
|
||||||
|
@Index({ properties: ['admin'] })
|
||||||
|
@Index({ properties: ['restaurant'] })
|
||||||
|
export class AdminRole extends BaseEntity {
|
||||||
|
@ManyToOne(() => Admin)
|
||||||
|
admin!: Admin;
|
||||||
|
|
||||||
|
@ManyToOne(() => Role)
|
||||||
|
role!: Role;
|
||||||
|
|
||||||
|
@ManyToOne(() => Restaurant, { nullable: true })
|
||||||
|
restaurant?: Restaurant;
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||||
|
import { EntityRepository } from '@mikro-orm/core';
|
||||||
|
import { Admin } from '../entities/admin.entity';
|
||||||
|
import { Role } from '../../roles/entities/role.entity';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { CacheService } from '../../utils/cache.service';
|
||||||
|
import { CreateMyRestaurantAdminDto } from '../dto/create-my-restaurant-admin.dto';
|
||||||
|
import { UpdateAdminDto } from '../dto/update-admin.dto';
|
||||||
|
import { AdminRole } from '../entities/adminRole.entity';
|
||||||
|
import { normalizePhone } from '../../utils/phone.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Admin)
|
||||||
|
private readonly adminRepository: EntityRepository<Admin>,
|
||||||
|
@InjectRepository(AdminRole)
|
||||||
|
private readonly adminRoleRepository: EntityRepository<AdminRole>,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findByPhone(phone: string): Promise<Admin | null> {
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
return this.adminRepository.findOne({ phone: normalizedPhone });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(adminId: string, restId: string): Promise<Admin | null> {
|
||||||
|
const admin = await this.adminRepository.findOne(
|
||||||
|
{ id: adminId, roles: { restaurant: { id: restId } } },
|
||||||
|
{ populate: ['roles', 'roles.role'] },
|
||||||
|
);
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllByRestaurantId(restId: string): Promise<Admin[]> {
|
||||||
|
return this.adminRepository.find({ roles: { restaurant: { id: restId } } }, { populate: ['roles', 'roles.role'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdminForMyRestaurant(restId: string, dto: CreateMyRestaurantAdminDto) {
|
||||||
|
const { phone, firstName, lastName, roleId } = dto;
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
let admin: Admin | null = null;
|
||||||
|
admin = await this.adminRepository.findOne({
|
||||||
|
phone: normalizedPhone,
|
||||||
|
});
|
||||||
|
if (!admin) {
|
||||||
|
admin = this.adminRepository.create({
|
||||||
|
phone: normalizedPhone,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
await this.em.persistAndFlush(admin);
|
||||||
|
}
|
||||||
|
const role = await this.em.findOne(Role, { id: roleId, isSystem: false });
|
||||||
|
if (!role) throw new NotFoundException('Role not found');
|
||||||
|
|
||||||
|
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
||||||
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
|
|
||||||
|
let adminRole = await this.adminRoleRepository.findOne({
|
||||||
|
admin: admin,
|
||||||
|
restaurant: restaurant,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
adminRole = this.adminRoleRepository.create({
|
||||||
|
admin: admin,
|
||||||
|
role,
|
||||||
|
restaurant,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
adminRole.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(adminRole);
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAdminForRestaurantBySuperAdmin(restId: string, dto: CreateMyRestaurantAdminDto) {
|
||||||
|
const { phone, firstName, lastName, roleId } = dto;
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
let admin: Admin | null = null;
|
||||||
|
admin = await this.adminRepository.findOne({
|
||||||
|
phone: normalizedPhone,
|
||||||
|
});
|
||||||
|
if (!admin) {
|
||||||
|
admin = this.adminRepository.create({
|
||||||
|
phone: normalizedPhone,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
await this.em.persistAndFlush(admin);
|
||||||
|
}
|
||||||
|
const role = await this.em.findOne(Role, { id: roleId });
|
||||||
|
if (!role) throw new NotFoundException('Role* not found' + roleId);
|
||||||
|
|
||||||
|
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
||||||
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
|
|
||||||
|
let adminRole = await this.adminRoleRepository.findOne({
|
||||||
|
admin: admin,
|
||||||
|
restaurant: restaurant,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
adminRole = this.adminRoleRepository.create({
|
||||||
|
admin: admin,
|
||||||
|
role,
|
||||||
|
restaurant,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
adminRole.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(adminRole);
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminsForRestaurantBySuperAdmin(restId: string): Promise<Admin[]> {
|
||||||
|
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
||||||
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
|
|
||||||
|
return this.adminRepository.find({ roles: { restaurant: restaurant } }, { populate: ['roles', 'roles.role'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(adminId: string, restId: string, dto: UpdateAdminDto): Promise<Admin> {
|
||||||
|
const admin = await this.adminRepository.findOne(
|
||||||
|
{ id: adminId, roles: { restaurant: { id: restId } } },
|
||||||
|
{ populate: ['roles', 'roles.role', 'roles.restaurant'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
throw new NotFoundException('Admin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { roleId, ...rest } = dto;
|
||||||
|
|
||||||
|
// Update scalar fields (firstName, lastName, phone)
|
||||||
|
if (rest.firstName !== undefined) {
|
||||||
|
admin.firstName = rest.firstName;
|
||||||
|
}
|
||||||
|
if (rest.lastName !== undefined) {
|
||||||
|
admin.lastName = rest.lastName;
|
||||||
|
}
|
||||||
|
if (rest.phone !== undefined && rest.phone !== admin.phone) {
|
||||||
|
const normalizedPhone = normalizePhone(rest.phone);
|
||||||
|
const exists = await this.adminRepository.findOne({ phone: normalizedPhone });
|
||||||
|
if (exists) {
|
||||||
|
throw new ConflictException('This Phone Number is already taken');
|
||||||
|
}
|
||||||
|
admin.phone = normalizedPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update role if roleId is provided
|
||||||
|
if (roleId) {
|
||||||
|
const role = await this.em.findOne(Role, { id: roleId, isSystem: false });
|
||||||
|
if (!role) {
|
||||||
|
throw new NotFoundException('Role not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing AdminRole for this admin and restaurant
|
||||||
|
const existingAdminRole = await this.em.findOne(AdminRole, {
|
||||||
|
admin: { id: adminId },
|
||||||
|
restaurant: { id: restId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingAdminRole) {
|
||||||
|
// Update existing role
|
||||||
|
existingAdminRole.role = role;
|
||||||
|
} else {
|
||||||
|
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
||||||
|
if (!restaurant) throw new NotFoundException('Restaurant not found');
|
||||||
|
// Create new AdminRole
|
||||||
|
const newAdminRole = this.em.create(AdminRole, {
|
||||||
|
admin,
|
||||||
|
role,
|
||||||
|
restaurant,
|
||||||
|
});
|
||||||
|
admin.roles.add(newAdminRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(admin);
|
||||||
|
return admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(adminId: string, restId: string): Promise<void> {
|
||||||
|
const adminRole = await this.adminRoleRepository.findOne({ admin: { id: adminId }, restaurant: { id: restId } });
|
||||||
|
if (!adminRole) {
|
||||||
|
throw new NotFoundException('Admin role not found');
|
||||||
|
}
|
||||||
|
return this.em.removeAndFlush(adminRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||||
|
import { Admin } from '../entities/admin.entity';
|
||||||
|
import { AdminRole } from '../entities/adminRole.entity';
|
||||||
|
import { RestRepository } from '../../restaurants/repositories/rest.repository';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { normalizePhone } from '../../utils/phone.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminRepository extends EntityRepository<Admin> {
|
||||||
|
constructor(
|
||||||
|
readonly em: EntityManager,
|
||||||
|
private readonly restRepository: RestRepository,
|
||||||
|
) {
|
||||||
|
super(em, Admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByPhoneAndRestaurantSlug(phone: string, slug: string): Promise<Admin | null> {
|
||||||
|
console.log('phone', phone);
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
// First, find the restaurant by slug using the same repository as auth service
|
||||||
|
const restaurant = await this.restRepository.findOne({ slug });
|
||||||
|
if (!restaurant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find AdminRole that matches the admin phone and restaurant
|
||||||
|
const adminRole = await this.em.findOne(
|
||||||
|
AdminRole,
|
||||||
|
{
|
||||||
|
admin: { phone: normalizedPhone },
|
||||||
|
restaurant: { id: restaurant.id },
|
||||||
|
},
|
||||||
|
{ populate: ['admin', 'admin.roles', 'role', 'role.permissions', 'restaurant'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!adminRole || !adminRole.admin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all roles are populated
|
||||||
|
await adminRole.admin.roles.loadItems();
|
||||||
|
for (const role of adminRole.admin.roles.getItems()) {
|
||||||
|
if (role.role && role.role.permissions && !role.role.permissions.isInitialized()) {
|
||||||
|
await role.role.permissions.loadItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return adminRole.admin;
|
||||||
|
}
|
||||||
|
async findAdminsWithPermission(restaurantId: string, permission: string): Promise<Admin[]> {
|
||||||
|
const admins = await this.em.find(
|
||||||
|
Admin,
|
||||||
|
{ roles: { restaurant: { id: restaurantId }, role: { permissions: { name: permission } } } },
|
||||||
|
{ populate: ['roles', 'roles.role', 'roles.role.permissions'] },
|
||||||
|
);
|
||||||
|
return admins;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Admin } from '../entities/admin.entity';
|
||||||
|
|
||||||
|
export interface AdminDetailResponse {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone: string;
|
||||||
|
role: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
permissions: string[];
|
||||||
|
restaurant?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminDetailTransformer {
|
||||||
|
static transform(admin: Admin): AdminDetailResponse | null {
|
||||||
|
if (!admin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract role information
|
||||||
|
const role = admin.roles.getItems().find(r => r.role)?.role;
|
||||||
|
if (!role) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
firstName: admin.firstName,
|
||||||
|
lastName: admin.lastName,
|
||||||
|
phone: admin.phone,
|
||||||
|
role: {
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
},
|
||||||
|
permissions: role.permissions.getItems().map(p => p.name) || [],
|
||||||
|
restaurant: {
|
||||||
|
id: role.restaurant?.id || '',
|
||||||
|
name: role.restaurant?.name || '',
|
||||||
|
slug: role.restaurant?.slug || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract permissions - ensure collection is loaded if needed
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { AuthService } from './services/auth.service';
|
||||||
|
import { AuthController } from './controllers/auth.controller';
|
||||||
|
import { UtilsModule } from '../utils/utils.module';
|
||||||
|
import { UserModule } from '../users/user.module';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AdminModule } from '../admin/admin.module';
|
||||||
|
import { TokensService } from './services/tokens.service';
|
||||||
|
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||||
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
|
import { User } from '../users/entities/user.entity';
|
||||||
|
import { AdminAuthGuard } from './guards/adminAuth.guard';
|
||||||
|
import { RefreshToken } from './entities/refresh-token.entity';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
UtilsModule,
|
||||||
|
forwardRef(() => UserModule),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
const expiresIn = configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
||||||
|
return {
|
||||||
|
global: true,
|
||||||
|
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||||
|
signOptions: {
|
||||||
|
// Use string format with time unit for explicit expiration (value should be in seconds)
|
||||||
|
expiresIn: `${expiresIn}s`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
}),
|
||||||
|
forwardRef(() => AdminModule),
|
||||||
|
forwardRef(() => RestaurantsModule),
|
||||||
|
MikroOrmModule.forFeature([User, RefreshToken]),
|
||||||
|
forwardRef(() => NotificationsModule),
|
||||||
|
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, TokensService, AdminAuthGuard],
|
||||||
|
exports: [AdminAuthGuard],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthService } from '../services/auth.service';
|
||||||
|
import { RequestOtpDto } from '../dto/request-otp.dto';
|
||||||
|
import { DirectLoginDto } from '../dto/direct-login.dto';
|
||||||
|
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
|
||||||
|
import { VerifyOtpDto } from '../dto/verify-otp.dto';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import { RefreshTokenRateLimit } from 'src/common/decorators/rate-limit.decorator';
|
||||||
|
import { RefreshTokenDto } from '../dto/refresh-token.dto';
|
||||||
|
import { SuperAdminAuthGuard } from '../guards/superAdminAuth.guard';
|
||||||
|
|
||||||
|
@ApiTags('auth')
|
||||||
|
@Controller()
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) { }
|
||||||
|
|
||||||
|
@Throttle({ default: { limit: 3, ttl: 180_000 } })
|
||||||
|
@Post('public/auth/otp/request')
|
||||||
|
@ApiOperation({ summary: 'Request OTP for login or signup' })
|
||||||
|
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
|
||||||
|
otpRequest(@Body() dto: RequestOtpDto) {
|
||||||
|
return this.authService.requestOtp(dto, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('public/auth/otp/verify')
|
||||||
|
@ApiOperation({ summary: 'Verify OTP code' })
|
||||||
|
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
||||||
|
otpVerify(@Body() dto: VerifyOtpDto) {
|
||||||
|
return this.authService.verifyOtp(dto.phone, dto.slug, dto.otp); // assuming dto has `otp` property
|
||||||
|
}
|
||||||
|
|
||||||
|
@RefreshTokenRateLimit()
|
||||||
|
@ApiOperation({ summary: 'refresh the user access token / refresh token' })
|
||||||
|
@Post('public/auth/refresh')
|
||||||
|
refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throttle({ default: { limit: 3, ttl: 180_000 } })
|
||||||
|
@Post('admin/auth/otp/request')
|
||||||
|
@ApiOperation({ summary: 'Request OTP for login or signup' })
|
||||||
|
@ApiBody({ type: RequestOtpDto, description: 'Mobile number to receive OTP' })
|
||||||
|
otpRequestAdmin(@Body() dto: RequestOtpDto) {
|
||||||
|
return this.authService.requestOtp(dto, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('admin/auth/otp/verify')
|
||||||
|
@ApiOperation({ summary: 'Verify OTP code' })
|
||||||
|
@ApiBody({ type: VerifyOtpDto, description: 'Mobile number and OTP code' })
|
||||||
|
otpVerifyAdmin(@Body() dto: VerifyOtpDto) {
|
||||||
|
return this.authService.verifyOtpAdmin(dto.phone, dto.slug, dto.otp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RefreshTokenRateLimit()
|
||||||
|
@ApiOperation({ summary: 'refresh the admin access token / refresh token' })
|
||||||
|
@Post('admin/auth/refresh')
|
||||||
|
refreshTokenAdmin(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
//super admin routes
|
||||||
|
|
||||||
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
@Post('super-admin/auth/direct-login')
|
||||||
|
@ApiOperation({ summary: 'Direct login for DSC - returns login credentials' })
|
||||||
|
@ApiBody({ type: DirectLoginDto, description: 'Phone number and restaurant slug for direct login' })
|
||||||
|
directloginForDsc(@Body() dto: DirectLoginDto) {
|
||||||
|
return this.authService.loginAdminForDsc(dto.phone, dto.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsNotEmpty, IsString, IsMobilePhone } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class DirectLoginDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||||
|
@IsMobilePhone('fa-IR')
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: 'zhivan', description: 'Restaurant slug' })
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@ApiProperty({ description: 'Refresh token' })
|
||||||
|
@IsString({ message: 'Refresh token must be a string' })
|
||||||
|
@IsNotEmpty({ message: 'Refresh token is required' })
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsNotEmpty, IsString, IsMobilePhone } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class RequestOtpDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||||
|
@IsMobilePhone('fa-IR')
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsNotEmpty, IsString, Length, IsMobilePhone } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class VerifyOtpDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: '09362532122', description: 'Mobile number' })
|
||||||
|
@IsMobilePhone('fa-IR')
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: '', description: 'Otp' })
|
||||||
|
@Length(5)
|
||||||
|
otp: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: 'zhivan', description: 'restaurant slug' })
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Entity, Enum, Index, Property } from '@mikro-orm/core';
|
||||||
|
|
||||||
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
|
|
||||||
|
export enum RefreshTokenType {
|
||||||
|
USER = 'user',
|
||||||
|
ADMIN = 'admin',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ tableName: 'refreshtokens' })
|
||||||
|
@Index({ properties: ['ownerId', 'restId', 'type'] })
|
||||||
|
@Index({ properties: ['hashedToken'] })
|
||||||
|
@Index({ properties: ['expiresAt'] })
|
||||||
|
export class RefreshToken extends BaseEntity {
|
||||||
|
@Property({ type: 'varchar', length: 255 })
|
||||||
|
hashedToken!: string;
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
restId!: string;
|
||||||
|
|
||||||
|
@Enum(() => RefreshTokenType)
|
||||||
|
type!: RefreshTokenType;
|
||||||
|
|
||||||
|
@Property({ type: 'timestamptz' })
|
||||||
|
expiresAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
Inject,
|
||||||
|
Logger,
|
||||||
|
ForbiddenException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IAdminTokenPayload } from '../interfaces/IToken-payload';
|
||||||
|
import { PERMISSIONS_KEY } from '../../../common/decorators/permissions.decorator';
|
||||||
|
import { PermissionsService } from 'src/modules/roles/providers/permissions.service';
|
||||||
|
|
||||||
|
export interface AdminAuthRequest extends Request {
|
||||||
|
adminId: string;
|
||||||
|
restId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(AdminAuthGuard.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(JwtService)
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
@Inject(ConfigService)
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly permissionsService: PermissionsService,
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const request = context.switchToHttp().getRequest<AdminAuthRequest>();
|
||||||
|
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
if (!token) {
|
||||||
|
this.logger.warn('No token provided', {
|
||||||
|
hasAuthHeader: !!request.headers.authorization,
|
||||||
|
authHeader: request.headers.authorization ? 'present' : 'missing',
|
||||||
|
headers: Object.keys(request.headers),
|
||||||
|
});
|
||||||
|
throw new UnauthorizedException('No token provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the JWT secret from config
|
||||||
|
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||||
|
|
||||||
|
// Verify token with the secret
|
||||||
|
const payload = await this.jwtService.verifyAsync<IAdminTokenPayload>(token, {
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payload.adminId || !payload.restId) {
|
||||||
|
this.logger.error('Invalid token payload structure', payload);
|
||||||
|
throw new UnauthorizedException('Invalid token payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
request['adminId'] = payload.adminId;
|
||||||
|
request['restId'] = payload.restId;
|
||||||
|
|
||||||
|
// check if the user has the required permissions
|
||||||
|
const requiredPermissions =
|
||||||
|
this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [context.getHandler(), context.getClass()]) ?? [];
|
||||||
|
|
||||||
|
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminPermission = await this.permissionsService.getAdminPermissions(payload.adminId, payload.restId);
|
||||||
|
if (!adminPermission || !Array.isArray(adminPermission)) {
|
||||||
|
this.logger.error('No permissions found', { adminId: payload.adminId, restId: payload.restId });
|
||||||
|
throw new ForbiddenException('No permissions found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = requiredPermissions.every(p => adminPermission.includes(p));
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
this.logger.warn('Insufficient permissions', {
|
||||||
|
adminId: payload.adminId,
|
||||||
|
restId: payload.restId,
|
||||||
|
required: requiredPermissions,
|
||||||
|
has: adminPermission,
|
||||||
|
});
|
||||||
|
throw new ForbiddenException('You are not authorized to access this resource');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ForbiddenException || err instanceof UnauthorizedException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||||
|
const errorStack = err instanceof Error ? err.stack : undefined;
|
||||||
|
this.logger.error('Token verification error in AdminAuthGuard', {
|
||||||
|
error: errorMessage,
|
||||||
|
stack: errorStack,
|
||||||
|
});
|
||||||
|
throw new UnauthorizedException('Invalid or expired token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const authHeader = request.headers.authorization || request.headers['authorization'];
|
||||||
|
if (!authHeader) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, token] = authHeader.split(' ');
|
||||||
|
if (type?.toLowerCase() !== 'bearer' || !token) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ITokenPayload } from '../interfaces/IToken-payload';
|
||||||
|
|
||||||
|
export interface UserAuthRequest extends Request {
|
||||||
|
userId: string;
|
||||||
|
restId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(AuthGuard.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(JwtService)
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
@Inject(ConfigService)
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext) {
|
||||||
|
const request = context.switchToHttp().getRequest<UserAuthRequest>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
const slug = this.extractSlugFromHeader(request);
|
||||||
|
if (!slug) {
|
||||||
|
throw new UnauthorizedException('Slug is required');
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
if (!payload.userId || !payload.restId) {
|
||||||
|
this.logger.error('Invalid token payload structure', payload);
|
||||||
|
throw new UnauthorizedException('Invalid token payload');
|
||||||
|
}
|
||||||
|
if (payload.slug !== slug) {
|
||||||
|
throw new UnauthorizedException('Invalid slug');
|
||||||
|
}
|
||||||
|
request['userId'] = payload.userId;
|
||||||
|
request['restId'] = payload.restId;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.logger.error('error in AuthGuard', err);
|
||||||
|
throw new UnauthorizedException('Invalid or expired token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
private extractSlugFromHeader(request: Request): string | undefined {
|
||||||
|
// Fastify normalizes headers to lowercase, so check both cases
|
||||||
|
const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string;
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ITokenPayload } from '../interfaces/IToken-payload';
|
||||||
|
|
||||||
|
export interface UserOptionalAuthRequest extends Request {
|
||||||
|
userId?: string;
|
||||||
|
restId?: string;
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OptionalAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(OptionalAuthGuard.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(JwtService)
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
@Inject(ConfigService)
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<UserOptionalAuthRequest>();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
const slug = this.extractSlugFromHeader(request);
|
||||||
|
|
||||||
|
// If no token is provided, allow the request without authentication
|
||||||
|
if (!slug) {
|
||||||
|
console.log('No slug provided');
|
||||||
|
this.logger.warn('No slug provided');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
request['slug'] = slug;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to verify the token if it exists
|
||||||
|
try {
|
||||||
|
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||||
|
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
||||||
|
secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate payload structure
|
||||||
|
if (!payload.userId || !payload.restId) {
|
||||||
|
this.logger.warn('Invalid token payload structure', payload);
|
||||||
|
// Allow request but don't set user info
|
||||||
|
request['slug'] = slug;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate slug if provided
|
||||||
|
if (slug && payload.slug !== slug) {
|
||||||
|
this.logger.warn('Token slug does not match header slug', {
|
||||||
|
tokenSlug: payload.slug,
|
||||||
|
headerSlug: slug,
|
||||||
|
});
|
||||||
|
// Allow request but don't set user info
|
||||||
|
request['slug'] = slug;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is valid, set user info
|
||||||
|
request['userId'] = payload.userId;
|
||||||
|
request['restId'] = payload.restId;
|
||||||
|
request['slug'] = slug;
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
// Token is invalid or expired, but allow the request anyway
|
||||||
|
this.logger.debug('Token verification failed in OptionalAuthGuard', {
|
||||||
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
// Set restId from slug
|
||||||
|
|
||||||
|
request['slug'] = slug;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractSlugFromHeader(request: Request): string | undefined {
|
||||||
|
// Fastify normalizes headers to lowercase, so check both cases
|
||||||
|
const slug = (request.headers['x-slug'] || request.headers['X-Slug']) as string;
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SuperAdminAuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(SuperAdminAuthGuard.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(ConfigService)
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentials = this.extractBasicAuthCredentials(request);
|
||||||
|
if (!credentials) {
|
||||||
|
throw new UnauthorizedException('Basic authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = credentials;
|
||||||
|
const expectedUsername = this.configService.get<string>('SUPER_ADMIN_USERNAME') ?? 'danak@dsc.com';
|
||||||
|
const expectedPassword = this.configService.get<string>('SUPER_ADMIN_PASSWORD') ?? 'DsCdAnAk?@ABC';
|
||||||
|
if (username !== expectedUsername || password !== expectedPassword) {
|
||||||
|
this.logger.warn(`Invalid super admin credentials attempt for username: ${username}`);
|
||||||
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnauthorizedException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
this.logger.error('error in SuperAdminAuthGuard', err);
|
||||||
|
throw new UnauthorizedException('Authentication failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractBasicAuthCredentials(request: Request): { username: string; password: string } | null {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, encoded] = authHeader.split(' ');
|
||||||
|
if (type?.toLowerCase() !== 'basic' || !encoded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(encoded, 'base64').toString('utf-8');
|
||||||
|
const [username, password] = decoded.split(':');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { username, password };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to decode Basic Auth credentials', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
export interface ITokenPayload {
|
||||||
|
userId: string;
|
||||||
|
restId: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAdminTokenPayload {
|
||||||
|
adminId: string;
|
||||||
|
restId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
|
import { RequestOtpDto } from '../dto/request-otp.dto';
|
||||||
|
import { CacheService } from '../../utils/cache.service';
|
||||||
|
import { SmsService } from '../../notifications/services/sms.service';
|
||||||
|
import { UserService } from '../../users/providers/user.service';
|
||||||
|
import { randomInt } from 'crypto';
|
||||||
|
import { TokensService } from './tokens.service';
|
||||||
|
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
|
||||||
|
import { AuthMessage, RestMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||||
|
import { AdminLoginTransformer } from '../transformers/admin-login.transformer';
|
||||||
|
import { UserLoginTransformer } from '../transformers/user-login.transformer';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { normalizePhone } from '../../utils/phone.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
readonly ADMIN_PERMISSIONS_KEY = 'admin-perms';
|
||||||
|
private OTP_EXPIRATION_TIME: number;
|
||||||
|
constructor(
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
private readonly smsService: SmsService,
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private readonly tokensService: TokensService,
|
||||||
|
private readonly restRepository: RestRepository,
|
||||||
|
private readonly adminRepository: AdminRepository,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {
|
||||||
|
this.OTP_EXPIRATION_TIME = this.configService.get<number>('OTP_EXPIRATION_TIME') ?? 240;
|
||||||
|
}
|
||||||
|
|
||||||
|
private userOtpKey(restaurantSlug: string, phone: string) {
|
||||||
|
return `otp:${restaurantSlug}:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private adminOtpKey(restaurantSlug: string, phone: string) {
|
||||||
|
return `otp-admin:${restaurantSlug}:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestOtp(dto: RequestOtpDto, isAdmin: boolean) {
|
||||||
|
const { phone, slug } = dto;
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
const code = this.generateOtpCode();
|
||||||
|
const key = isAdmin ? this.adminOtpKey(slug, normalizedPhone) : this.userOtpKey(slug, normalizedPhone);
|
||||||
|
await this.cacheService.set(key, code, this.OTP_EXPIRATION_TIME);
|
||||||
|
|
||||||
|
await this.smsService.sendotp(normalizedPhone, code);
|
||||||
|
|
||||||
|
return { code: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyOtp(phone: string, slug: string, code: string) {
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
const key = this.userOtpKey(slug, normalizedPhone);
|
||||||
|
const cachedCode = await this.cacheService.get(key);
|
||||||
|
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
||||||
|
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
||||||
|
|
||||||
|
await this.cacheService.del(key);
|
||||||
|
|
||||||
|
const user = await this.userService.findOrCreateByPhone(normalizedPhone);
|
||||||
|
|
||||||
|
const rest = await this.restRepository.findOne({ slug });
|
||||||
|
|
||||||
|
if (!rest) {
|
||||||
|
throw new BadRequestException(RestMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.tokensService.generateAccessAndRefreshToken(user.id, rest.id, false, slug);
|
||||||
|
|
||||||
|
const userResponse = UserLoginTransformer.transform(user, rest);
|
||||||
|
|
||||||
|
return { tokens, user: userResponse };
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyOtpAdmin(phone: string, slug: string, code: string) {
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
const key = this.adminOtpKey(slug, normalizedPhone);
|
||||||
|
const cachedCode = await this.cacheService.get(key);
|
||||||
|
|
||||||
|
if (!cachedCode) throw new BadRequestException('OTP expired or not found');
|
||||||
|
|
||||||
|
if (cachedCode !== code) throw new BadRequestException('Invalid OTP');
|
||||||
|
await this.cacheService.del(key);
|
||||||
|
|
||||||
|
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = await this.restRepository.findOne({ slug });
|
||||||
|
|
||||||
|
if (!rest) {
|
||||||
|
throw new BadRequestException(RestMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, rest.id, true, slug);
|
||||||
|
|
||||||
|
const adminResponse = await AdminLoginTransformer.transform(admin, rest);
|
||||||
|
|
||||||
|
return { tokens, admin: adminResponse };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
to use for login directly from DSC
|
||||||
|
*/
|
||||||
|
async loginAdminForDsc(phone: string, slug: string) {
|
||||||
|
const normalizedPhone = normalizePhone(phone);
|
||||||
|
const admin = await this.adminRepository.findByPhoneAndRestaurantSlug(normalizedPhone, slug);
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
throw new BadRequestException(AuthMessage.ADMIN_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = await this.restRepository.findOne({ slug });
|
||||||
|
|
||||||
|
if (!rest) {
|
||||||
|
throw new BadRequestException(RestMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.tokensService.generateAccessAndRefreshToken(admin.id, rest.id, true, slug);
|
||||||
|
|
||||||
|
const adminResponse = await AdminLoginTransformer.transform(admin, rest);
|
||||||
|
|
||||||
|
return { tokens, admin: adminResponse };
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateOtpCode(): string {
|
||||||
|
const code = randomInt(10000, 100000);
|
||||||
|
return code.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken(oldRefreshToken: string) {
|
||||||
|
return this.tokensService.refreshToken(oldRefreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+145
@@ -0,0 +1,145 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { LockMode } from '@mikro-orm/core';
|
||||||
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { AuthMessage } from '../../../common/enums/message.enum';
|
||||||
|
import { RefreshToken, RefreshTokenType } from '../entities/refresh-token.entity';
|
||||||
|
import { IAdminTokenPayload, ITokenPayload } from '../interfaces/IToken-payload';
|
||||||
|
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TokensService {
|
||||||
|
private readonly logger = new Logger(TokensService.name);
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generateAccessAndRefreshToken(
|
||||||
|
ownerId: string,
|
||||||
|
restaurantId: string,
|
||||||
|
isAdmin: boolean,
|
||||||
|
slug: string,
|
||||||
|
em?: EntityManager,
|
||||||
|
) {
|
||||||
|
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
|
||||||
|
const accessExpire = this.configService.getOrThrow<number>('JWT_EXPIRATION_TIME');
|
||||||
|
|
||||||
|
const payload: ITokenPayload | IAdminTokenPayload = isAdmin
|
||||||
|
? { adminId: ownerId, restId: restaurantId }
|
||||||
|
: { userId: ownerId, restId: restaurantId, slug };
|
||||||
|
|
||||||
|
const accessToken = await this.generateAccessToken(payload, accessExpire);
|
||||||
|
const refreshToken = this.generateRefreshToken();
|
||||||
|
const type = isAdmin ? RefreshTokenType.ADMIN : RefreshTokenType.USER;
|
||||||
|
|
||||||
|
// Only pass em if it's a transaction manager (not this.em), otherwise pass undefined to trigger flush
|
||||||
|
await this.storeRefreshToken(ownerId, restaurantId, refreshToken, type, em);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, 'seconds').valueOf() },
|
||||||
|
refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, 'day').valueOf() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateAccessToken(payload: ITokenPayload | IAdminTokenPayload, expiresIn: number) {
|
||||||
|
// Ensure expiresIn is passed as a string with time unit for reliability
|
||||||
|
// JWT library accepts: number (seconds) or string with unit (e.g., "3600s", "1h")
|
||||||
|
// Using string format is more explicit and prevents unit confusion
|
||||||
|
return this.jwtService.signAsync(payload, { expiresIn: `${expiresIn}s` });
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeRefreshToken(
|
||||||
|
ownerId: string,
|
||||||
|
restId: string,
|
||||||
|
refreshToken: string,
|
||||||
|
type: RefreshTokenType,
|
||||||
|
em?: EntityManager,
|
||||||
|
) {
|
||||||
|
const entityManager = em || this.em;
|
||||||
|
const refreshExpire = this.configService.getOrThrow<number>('REFRESH_TOKEN_EXPIRE');
|
||||||
|
const expiresAt = dayjs().add(refreshExpire, 'day').toDate();
|
||||||
|
|
||||||
|
const hashedToken = this.hashToken(refreshToken);
|
||||||
|
|
||||||
|
const token = entityManager.create(RefreshToken, {
|
||||||
|
hashedToken,
|
||||||
|
ownerId,
|
||||||
|
restId,
|
||||||
|
type,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (em) {
|
||||||
|
// Within transaction, just persist (flush will be called by transaction)
|
||||||
|
entityManager.persist(token);
|
||||||
|
} else {
|
||||||
|
// Outside transaction, persist and flush immediately
|
||||||
|
await entityManager.persistAndFlush(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(oldRefreshToken: string) {
|
||||||
|
const hashedToken = this.hashToken(oldRefreshToken);
|
||||||
|
|
||||||
|
// Use transaction to ensure atomicity and prevent race conditions
|
||||||
|
return this.em.transactional(async em => {
|
||||||
|
// Lock the token row to prevent concurrent refresh attempts
|
||||||
|
// Using pessimistic write lock (FOR UPDATE) to prevent race conditions
|
||||||
|
const token = await em.findOne(RefreshToken, { hashedToken }, { lockMode: LockMode.PESSIMISTIC_WRITE });
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
||||||
|
em.remove(token);
|
||||||
|
await em.flush();
|
||||||
|
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store token data before removal
|
||||||
|
const ownerId = token.ownerId;
|
||||||
|
const restId = token.restId;
|
||||||
|
const isAdmin = token.type === RefreshTokenType.ADMIN;
|
||||||
|
|
||||||
|
// Verify restaurant still exists
|
||||||
|
const restaurant = await em.findOne(Restaurant, { id: restId });
|
||||||
|
if (!restaurant) {
|
||||||
|
throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate new tokens first (before removing old token)
|
||||||
|
// Pass the transaction EntityManager to ensure operations are within the transaction
|
||||||
|
const tokens = await this.generateAccessAndRefreshToken(ownerId, restaurant.id, isAdmin, restaurant.slug, em);
|
||||||
|
|
||||||
|
// Remove old token only after new token is created
|
||||||
|
em.remove(token);
|
||||||
|
|
||||||
|
// Persist changes atomically
|
||||||
|
await em.flush();
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to generate new tokens after refresh', error);
|
||||||
|
// Transaction will rollback automatically, preserving the old token
|
||||||
|
throw new UnauthorizedException('Failed to refresh token');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRefreshToken() {
|
||||||
|
return randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Admin } from '../../admin/entities/admin.entity';
|
||||||
|
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
|
||||||
|
export interface AdminLoginResponse {
|
||||||
|
id: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone: string;
|
||||||
|
role: string;
|
||||||
|
permissions: string[];
|
||||||
|
restaurant?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminLoginTransformer {
|
||||||
|
static async transform(admin: Admin, restaurant?: Restaurant): Promise<AdminLoginResponse> {
|
||||||
|
// Find the AdminRole that matches the restaurant (or get the first one)
|
||||||
|
let adminRole = admin.roles.getItems().find(r => (restaurant ? r.restaurant?.id === restaurant.id : true));
|
||||||
|
|
||||||
|
// If no match found, get the first one
|
||||||
|
if (!adminRole && admin.roles.getItems().length > 0) {
|
||||||
|
adminRole = admin.roles.getItems()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
firstName: admin.firstName,
|
||||||
|
lastName: admin.lastName,
|
||||||
|
phone: admin.phone,
|
||||||
|
role: '',
|
||||||
|
permissions: [],
|
||||||
|
restaurant: restaurant
|
||||||
|
? {
|
||||||
|
id: restaurant.id,
|
||||||
|
name: restaurant.name,
|
||||||
|
slug: restaurant.slug,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the role from AdminRole
|
||||||
|
const role = adminRole.role;
|
||||||
|
if (!role) {
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
firstName: admin.firstName,
|
||||||
|
lastName: admin.lastName,
|
||||||
|
phone: admin.phone,
|
||||||
|
role: '',
|
||||||
|
permissions: [],
|
||||||
|
restaurant: restaurant
|
||||||
|
? {
|
||||||
|
id: restaurant.id,
|
||||||
|
name: restaurant.name,
|
||||||
|
slug: restaurant.slug,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract permissions - ensure collection is loaded if needed
|
||||||
|
let permissions: string[] = [];
|
||||||
|
if (role.permissions) {
|
||||||
|
if (role.permissions.isInitialized()) {
|
||||||
|
permissions = role.permissions.getItems().map(p => p.name);
|
||||||
|
} else {
|
||||||
|
await role.permissions.loadItems();
|
||||||
|
permissions = role.permissions.getItems().map(p => p.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: admin.id,
|
||||||
|
firstName: admin.firstName,
|
||||||
|
lastName: admin.lastName,
|
||||||
|
phone: admin.phone,
|
||||||
|
role: role.name,
|
||||||
|
permissions,
|
||||||
|
restaurant: restaurant
|
||||||
|
? {
|
||||||
|
id: restaurant.id,
|
||||||
|
name: restaurant.name,
|
||||||
|
slug: restaurant.slug,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { User } from '../../users/entities/user.entity';
|
||||||
|
import type { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
|
||||||
|
export interface UserLoginResponse {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
isActive?: boolean;
|
||||||
|
restaurant: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserLoginTransformer {
|
||||||
|
static transform(user: User, restaurant: Restaurant): UserLoginResponse {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
phone: user.phone,
|
||||||
|
isActive: user.isActive,
|
||||||
|
restaurant: {
|
||||||
|
id: restaurant.id,
|
||||||
|
name: restaurant.name,
|
||||||
|
slug: restaurant.slug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
|
import { CartService } from './providers/cart.service';
|
||||||
|
import { CartController } from './controllers/cart.controller';
|
||||||
|
import { Food } from '../foods/entities/food.entity';
|
||||||
|
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||||
|
import { PaymentMethod } from '../payments/entities/payment-method.entity';
|
||||||
|
import { UserAddress } from '../users/entities/user-address.entity';
|
||||||
|
import { User } from '../users/entities/user.entity';
|
||||||
|
import { Delivery } from '../delivery/entities/delivery.entity';
|
||||||
|
import { Order } from '../orders/entities/order.entity';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { UserModule } from '../users/user.module';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { UtilsModule } from '../utils/utils.module';
|
||||||
|
import { CouponModule } from '../coupons/coupon.module';
|
||||||
|
import { CartRepository } from './repositories/cart.repository';
|
||||||
|
import { CartValidationService } from './providers/cart-validation.service';
|
||||||
|
import { CartCalculationService } from './providers/cart-calculation.service';
|
||||||
|
import { CartItemService } from './providers/cart-item.service';
|
||||||
|
import { PointTransaction } from '../users/entities/point-transaction.entity';
|
||||||
|
import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MikroOrmModule.forFeature([
|
||||||
|
Food,
|
||||||
|
Restaurant,
|
||||||
|
PaymentMethod,
|
||||||
|
UserAddress,
|
||||||
|
User,
|
||||||
|
PointTransaction,
|
||||||
|
WalletTransaction,
|
||||||
|
Delivery,
|
||||||
|
Order,
|
||||||
|
]),
|
||||||
|
AuthModule,
|
||||||
|
UserModule,
|
||||||
|
JwtModule,
|
||||||
|
UtilsModule,
|
||||||
|
CouponModule,
|
||||||
|
],
|
||||||
|
controllers: [CartController],
|
||||||
|
providers: [
|
||||||
|
CartService,
|
||||||
|
CartRepository,
|
||||||
|
CartValidationService,
|
||||||
|
CartCalculationService,
|
||||||
|
CartItemService,
|
||||||
|
],
|
||||||
|
exports: [CartService],
|
||||||
|
})
|
||||||
|
export class CartModule { }
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Controller, Get, Post, Body, Patch, Delete, UseGuards, Param } from '@nestjs/common';
|
||||||
|
import { CartService } from '../providers/cart.service';
|
||||||
|
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
|
||||||
|
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiParam, ApiHeader } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||||
|
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||||
|
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||||
|
import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto';
|
||||||
|
import { API_HEADER_SLUG } from 'src/common/constants/index';
|
||||||
|
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiTags('cart')
|
||||||
|
@Controller('public/cart')
|
||||||
|
export class CartController {
|
||||||
|
constructor(private readonly cartService: CartService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'Get cart for current user and restaurant' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
async findOne(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||||
|
return this.cartService.getOrCreateCart(userId, restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('items/:foodId/increment')
|
||||||
|
@ApiOperation({ summary: 'Increment item quantity in cart by 1' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiParam({ name: 'foodId', description: 'Food ID to increment in cart' })
|
||||||
|
async incrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
|
||||||
|
return this.cartService.incrementItem(userId, restaurantId, foodId, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('items/:foodId/decrement')
|
||||||
|
@ApiOperation({ summary: 'Decrement item quantity in cart by 1 (removes item if quantity reaches 0)' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiParam({ name: 'foodId', description: 'Food ID to decrement in cart' })
|
||||||
|
async decrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
|
||||||
|
return this.cartService.decrementItem(userId, restaurantId, foodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('items/bulk')
|
||||||
|
@ApiOperation({ summary: 'Bulk add items to cart (increments quantity if items exist)' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiBody({ type: BulkAddItemsToCartDto })
|
||||||
|
bulkAddItems(
|
||||||
|
@UserId() userId: string,
|
||||||
|
@RestId() restaurantId: string,
|
||||||
|
@Body() bulkAddItemsDto: BulkAddItemsToCartDto,
|
||||||
|
) {
|
||||||
|
return this.cartService.bulkAddItems(userId, restaurantId, bulkAddItemsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('items/:foodId')
|
||||||
|
@ApiOperation({ summary: 'Remove item from cart' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiParam({ name: 'foodId', description: 'Food ID in the cart' })
|
||||||
|
async removeItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
|
||||||
|
return this.cartService.removeItem(userId, restaurantId, foodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@ApiOperation({ summary: 'Clear entire cart' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
async clearCart(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||||
|
return this.cartService.clearCart(userId, restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('apply-coupon')
|
||||||
|
@ApiOperation({ summary: 'Apply coupon to cart' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiBody({ type: ApplyCouponDto })
|
||||||
|
async applyCoupon(@UserId() userId: string, @RestId() restaurantId: string, @Body() applyCouponDto: ApplyCouponDto) {
|
||||||
|
return this.cartService.applyCoupon(userId, restaurantId, applyCouponDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('coupon')
|
||||||
|
@ApiOperation({ summary: 'Remove coupon from cart' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
async removeCoupon(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||||
|
return this.cartService.removeCoupon(userId, restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Patch('all')
|
||||||
|
@ApiOperation({ summary: 'Set all cart params' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiBody({ type: SetAllCartParmsDto })
|
||||||
|
setAllCartParams(@UserId() userId: string, @RestId() restaurantId: string, @Body() dto: SetAllCartParmsDto) {
|
||||||
|
return this.cartService.setAllCartParams(userId, restaurantId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class ApplyCouponDto {
|
||||||
|
@ApiProperty({ description: 'Coupon code', example: 'DISCOUNT10' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, Min, IsString } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
class AddItemToCartDto {
|
||||||
|
@ApiProperty({ description: 'Quantity of the food item', example: 1, minimum: 1 })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Food ID' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
foodId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BulkAddItemsToCartDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Array of items to add to cart',
|
||||||
|
type: [AddItemToCartDto],
|
||||||
|
example: [
|
||||||
|
{ foodId: 'food-123', quantity: 2 },
|
||||||
|
{ foodId: 'food-456', quantity: 1 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1, { message: 'At least one item is required' })
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AddItemToCartDto)
|
||||||
|
items!: AddItemToCartDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString, IsArray, ValidateNested, IsInt, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CartItemDto {
|
||||||
|
@ApiProperty({ description: 'Food ID' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
foodId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Quantity of the food item', example: 2, minimum: 1 })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
quantity!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateCartDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'List of cart items',
|
||||||
|
type: [CartItemDto],
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CartItemDto)
|
||||||
|
items!: CartItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { SetCarDeliveryDto } from './set-car-delivery.dto';
|
||||||
|
|
||||||
|
|
||||||
|
export class SetAllCartParmsDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Cart description or notes',
|
||||||
|
required: false,
|
||||||
|
example: 'Please deliver to the back door',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Delivery method ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
deliveryMethodId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'User address ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
addressId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Payment method ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
paymentMethodId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Table number for dine-in orders', example: '5', required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
tableNumber?: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
carAddress?: SetCarDeliveryDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetCarDeliveryDto {
|
||||||
|
@ApiProperty({ description: 'Car model', example: 'Toyota Camry' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
carModel!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Car color', example: 'White' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
carColor!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'License plate number', example: '12ABC345' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
plateNumber!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PartialType, ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { CreateCartDto, CartItemDto } from './create-cart.dto';
|
||||||
|
|
||||||
|
export class UpdateCartItemDto extends PartialType(CartItemDto) {}
|
||||||
|
|
||||||
|
export class UpdateCartDto {
|
||||||
|
@ApiProperty({ description: 'List of cart items to update', type: [UpdateCartItemDto], required: false })
|
||||||
|
items?: UpdateCartItemDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { OrderCouponDetail, OrderUserAddress, OrderCarAddress } from 'src/modules/orders/interface/order.interface';
|
||||||
|
|
||||||
|
export interface CartItem {
|
||||||
|
foodId: string;
|
||||||
|
foodTitle?: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
discount: number;
|
||||||
|
totalPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cart {
|
||||||
|
userId: string;
|
||||||
|
restaurantId: string;
|
||||||
|
restaurantName?: string;
|
||||||
|
|
||||||
|
items: CartItem[];
|
||||||
|
coupon?: OrderCouponDetail;
|
||||||
|
paymentMethodId?: string;
|
||||||
|
deliveryMethodId?: string;
|
||||||
|
description?: string;
|
||||||
|
tableNumber?: string;
|
||||||
|
|
||||||
|
deliveryFee: number;
|
||||||
|
subTotal: number;
|
||||||
|
tax: number;
|
||||||
|
couponDiscount: number;
|
||||||
|
itemsDiscount: number;
|
||||||
|
totalDiscount: number;
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
carAddress?: OrderCarAddress | null;
|
||||||
|
userAddress?: OrderUserAddress | null;
|
||||||
|
|
||||||
|
totalItems: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||||
|
import { DeliveryFeeTypeEnum } from '../../delivery/interface/delivery';
|
||||||
|
import { CouponType } from '../../coupons/interface/coupon';
|
||||||
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
|
import { Cart } from '../interfaces/cart.interface';
|
||||||
|
import { CouponService } from '../../coupons/providers/coupon.service';
|
||||||
|
import { GeographicUtils } from '../utils/geographic.utils';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CartCalculationService {
|
||||||
|
constructor(
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
private readonly couponService: CouponService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total price for a cart item
|
||||||
|
*/
|
||||||
|
calculateItemTotalPrice(unitPrice: number, unitDiscount: number, quantity: number): number {
|
||||||
|
const safeUnitPrice = Number(unitPrice) || 0;
|
||||||
|
const safeUnitDiscount = Math.min(Number(unitDiscount) || 0, safeUnitPrice);
|
||||||
|
const itemTotalPrice = safeUnitPrice * quantity;
|
||||||
|
const itemTotalDiscount = safeUnitDiscount * quantity;
|
||||||
|
return itemTotalPrice - itemTotalDiscount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate items totals (subtotal, items discount, total items)
|
||||||
|
*/
|
||||||
|
calculateItemsTotals(cart: Cart): { subTotal: number; itemsDiscount: number; totalItems: number } {
|
||||||
|
let subTotal = 0;
|
||||||
|
let itemsDiscount = 0;
|
||||||
|
let totalItems = 0;
|
||||||
|
|
||||||
|
for (const item of cart.items) {
|
||||||
|
const unitPrice = Number(item.price) || 0;
|
||||||
|
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||||
|
const itemPrice = unitPrice * item.quantity;
|
||||||
|
const itemDiscount = unitDiscount * item.quantity;
|
||||||
|
subTotal += itemPrice;
|
||||||
|
itemsDiscount += itemDiscount;
|
||||||
|
totalItems += item.quantity;
|
||||||
|
item.totalPrice = itemPrice - itemDiscount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subTotal, itemsDiscount, totalItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate coupon discount
|
||||||
|
*/
|
||||||
|
async calculateCouponDiscount(cart: Cart, subTotal: number, itemsDiscount: number): Promise<number> {
|
||||||
|
if (!cart.coupon) return 0;
|
||||||
|
|
||||||
|
const coupon = await this.getCouponRestrictionsOrClear(cart);
|
||||||
|
if (!cart.coupon || !coupon) return 0;
|
||||||
|
|
||||||
|
const hasCategoryRestriction = (coupon.foodCategories?.length ?? 0) > 0;
|
||||||
|
const hasFoodRestriction = (coupon.foods?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
let eligibleItemsTotal = subTotal;
|
||||||
|
let eligibleItemsDiscount = itemsDiscount;
|
||||||
|
|
||||||
|
if (hasCategoryRestriction || hasFoodRestriction) {
|
||||||
|
eligibleItemsTotal = 0;
|
||||||
|
eligibleItemsDiscount = 0;
|
||||||
|
|
||||||
|
const foodMap = await this.getFoodsInCartWithCategories(cart);
|
||||||
|
|
||||||
|
for (const item of cart.items) {
|
||||||
|
const food = foodMap.get(item.foodId);
|
||||||
|
if (!food) continue;
|
||||||
|
|
||||||
|
const matchesCategory =
|
||||||
|
hasCategoryRestriction && food.category && (coupon.foodCategories ?? []).includes(food.category.id);
|
||||||
|
const matchesFood = hasFoodRestriction && (coupon.foods ?? []).includes(food.id);
|
||||||
|
|
||||||
|
if (matchesCategory || matchesFood) {
|
||||||
|
const unitPrice = Number(item.price) || 0;
|
||||||
|
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||||
|
eligibleItemsTotal += unitPrice * item.quantity;
|
||||||
|
eligibleItemsDiscount += unitDiscount * item.quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceAfterItemDiscount = Math.max(0, eligibleItemsTotal - eligibleItemsDiscount);
|
||||||
|
|
||||||
|
if (cart.coupon.type === CouponType.PERCENTAGE) {
|
||||||
|
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
|
||||||
|
if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) {
|
||||||
|
discount = cart.coupon.maxDiscount;
|
||||||
|
}
|
||||||
|
return discount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(cart.coupon.value, priceAfterItemDiscount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate tax
|
||||||
|
*/
|
||||||
|
async calculateTax(restaurantId: string, amountAfterDiscounts: number): Promise<number> {
|
||||||
|
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||||
|
const vat = restaurant?.vat ? Number(restaurant.vat) : 0;
|
||||||
|
if (!vat || vat <= 0) return 0;
|
||||||
|
return (Math.max(0, amountAfterDiscounts) * vat) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate delivery fee
|
||||||
|
*/
|
||||||
|
async calculateDeliveryFee(cart: Cart): Promise<number> {
|
||||||
|
const deliveryMethodId = cart.deliveryMethodId;
|
||||||
|
if (!deliveryMethodId) return 0;
|
||||||
|
|
||||||
|
const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId }, { populate: ['restaurant'] });
|
||||||
|
if (!deliveryMethod || !deliveryMethod.enabled) return 0;
|
||||||
|
|
||||||
|
// If not distance based, return fixed delivery fee
|
||||||
|
if (deliveryMethod.deliveryFeeType !== DeliveryFeeTypeEnum.DISTANCE_BASED) {
|
||||||
|
return Number(deliveryMethod.deliveryFee) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For distance based calculation we need restaurant and user coordinates
|
||||||
|
const restaurant = (deliveryMethod as any).restaurant as Restaurant | undefined;
|
||||||
|
const userAddr = cart.userAddress;
|
||||||
|
|
||||||
|
if (!restaurant || restaurant.latitude == null || restaurant.longitude == null) {
|
||||||
|
// fallback to configured fixed fee when coordinates are missing
|
||||||
|
return Number(deliveryMethod.deliveryFee) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userAddr || userAddr.latitude == null || userAddr.longitude == null) {
|
||||||
|
return Number(deliveryMethod.deliveryFee) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const restLat = Number(restaurant.latitude);
|
||||||
|
const restLng = Number(restaurant.longitude);
|
||||||
|
const userLat = Number(userAddr.latitude);
|
||||||
|
const userLng = Number(userAddr.longitude);
|
||||||
|
|
||||||
|
const distanceKm = GeographicUtils.getDistanceKmRounded(restLat, restLng, userLat, userLng);
|
||||||
|
|
||||||
|
// Try to read either possible property names (legacy vs new)
|
||||||
|
const perKm = Number(deliveryMethod.perKilometerFee);
|
||||||
|
const minFee = Number(deliveryMethod.distanceBasedMinCost);
|
||||||
|
|
||||||
|
let fee = 0;
|
||||||
|
if (perKm <= 0) {
|
||||||
|
fee = Number(deliveryMethod.deliveryFee) || 0;
|
||||||
|
} else {
|
||||||
|
fee = distanceKm * perKm;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minFee > 0 && fee < minFee) fee = minFee;
|
||||||
|
|
||||||
|
return Math.max(0, Number(fee));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate cart totals (including coupon discount and tax)
|
||||||
|
*/
|
||||||
|
async recalculateCartTotals(cart: Cart): Promise<void> {
|
||||||
|
const { subTotal, itemsDiscount, totalItems } = this.calculateItemsTotals(cart);
|
||||||
|
cart.subTotal = subTotal;
|
||||||
|
cart.itemsDiscount = itemsDiscount;
|
||||||
|
|
||||||
|
const couponDiscount = await this.calculateCouponDiscount(cart, subTotal, itemsDiscount);
|
||||||
|
cart.couponDiscount = couponDiscount;
|
||||||
|
cart.totalDiscount = couponDiscount + itemsDiscount;
|
||||||
|
|
||||||
|
cart.tax = await this.calculateTax(cart.restaurantId, Math.max(0, subTotal - cart.totalDiscount));
|
||||||
|
cart.deliveryFee = await this.calculateDeliveryFee(cart);
|
||||||
|
|
||||||
|
// total = subtotal – totalDiscount + tax + deliveryFee
|
||||||
|
cart.total = Math.max(0, subTotal - cart.totalDiscount) + cart.tax + cart.deliveryFee;
|
||||||
|
cart.totalItems = totalItems;
|
||||||
|
cart.updatedAt = this.nowIso();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get coupon restrictions or clear if invalid
|
||||||
|
*/
|
||||||
|
private async getCouponRestrictionsOrClear(cart: Cart): Promise<{
|
||||||
|
isActive?: boolean;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
foodCategories?: string[];
|
||||||
|
foods?: string[];
|
||||||
|
} | null> {
|
||||||
|
if (!cart.coupon) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const c = await this.couponService.findById(cart.coupon.couponId);
|
||||||
|
const now = new Date();
|
||||||
|
if (c.isActive === false) {
|
||||||
|
cart.coupon = undefined;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (c.startDate && now < c.startDate) {
|
||||||
|
cart.coupon = undefined;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (c.endDate && now > c.endDate) {
|
||||||
|
cart.coupon = undefined;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive: c.isActive,
|
||||||
|
startDate: c.startDate,
|
||||||
|
endDate: c.endDate,
|
||||||
|
foodCategories: c.foodCategories,
|
||||||
|
foods: c.foods,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
cart.coupon = undefined;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get foods in cart with categories
|
||||||
|
*/
|
||||||
|
private async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Food>> {
|
||||||
|
const foodIds = cart.items.map(item => item.foodId);
|
||||||
|
if (foodIds.length === 0) return new Map();
|
||||||
|
|
||||||
|
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] });
|
||||||
|
return new Map(foods.map(f => [f.id, f]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current ISO timestamp
|
||||||
|
*/
|
||||||
|
private nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
|
import { Cart, CartItem } from '../interfaces/cart.interface';
|
||||||
|
import { CartValidationService } from './cart-validation.service';
|
||||||
|
import { CartCalculationService } from './cart-calculation.service';
|
||||||
|
import { CartMessage } from 'src/common/enums/message.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CartItemService {
|
||||||
|
constructor(
|
||||||
|
private readonly validationService: CartValidationService,
|
||||||
|
private readonly calculationService: CartCalculationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new cart item from food
|
||||||
|
*/
|
||||||
|
createCartItem(food: Food, quantity: number): CartItem {
|
||||||
|
const itemPrice = food.price || 0;
|
||||||
|
const itemDiscount = food.discount || 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
foodId: food.id,
|
||||||
|
foodTitle: food.title,
|
||||||
|
quantity,
|
||||||
|
price: itemPrice,
|
||||||
|
discount: itemDiscount,
|
||||||
|
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build cart item from food (updating existing item if provided)
|
||||||
|
*/
|
||||||
|
buildCartItemFromFood(food: Food, quantity: number, previous?: CartItem): CartItem {
|
||||||
|
const itemPrice = food.price || 0;
|
||||||
|
const itemDiscount = food.discount || 0;
|
||||||
|
return {
|
||||||
|
...(previous ?? ({} as CartItem)),
|
||||||
|
foodId: food.id,
|
||||||
|
foodTitle: food.title,
|
||||||
|
quantity,
|
||||||
|
price: itemPrice,
|
||||||
|
discount: itemDiscount,
|
||||||
|
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get item index in cart
|
||||||
|
*/
|
||||||
|
getItemIndex(cart: Cart, foodId: string): number {
|
||||||
|
return cart.items.findIndex(item => item.foodId === foodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or increment item in cart
|
||||||
|
*/
|
||||||
|
async addOrIncrementItem(cart: Cart, foodId: string, restaurantId: string, quantityToAdd: number): Promise<void> {
|
||||||
|
const food = await this.validationService.validateAndGetFood(foodId, restaurantId, quantityToAdd);
|
||||||
|
|
||||||
|
// Validate meal type compatibility
|
||||||
|
this.validationService.assertMealTypeCompatibility(food);
|
||||||
|
|
||||||
|
// Validate weekday compatibility
|
||||||
|
this.validationService.assertWeekdayCompatibility(food);
|
||||||
|
|
||||||
|
const index = this.getItemIndex(cart, food.id);
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
cart.items.push(this.createCartItem(food, quantityToAdd));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingItem = cart.items[index];
|
||||||
|
const newQuantity = existingItem.quantity + quantityToAdd;
|
||||||
|
this.validationService.validateStock(food, newQuantity);
|
||||||
|
cart.items[index] = this.buildCartItemFromFood(food, newQuantity, existingItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove item from cart
|
||||||
|
*/
|
||||||
|
removeItemOrFail(cart: Cart, foodId: string): void {
|
||||||
|
const itemIndex = this.getItemIndex(cart, foodId);
|
||||||
|
if (itemIndex < 0) {
|
||||||
|
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
|
||||||
|
}
|
||||||
|
cart.items.splice(itemIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement item quantity or remove if quantity reaches 0
|
||||||
|
*/
|
||||||
|
async decrementOrRemoveItem(cart: Cart, foodId: string): Promise<void> {
|
||||||
|
const itemIndex = this.getItemIndex(cart, foodId);
|
||||||
|
if (itemIndex < 0) {
|
||||||
|
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingItem = cart.items[itemIndex];
|
||||||
|
const newQuantity = existingItem.quantity - 1;
|
||||||
|
|
||||||
|
if (newQuantity <= 0) {
|
||||||
|
cart.items.splice(itemIndex, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const food = await this.validationService.getFoodOrFail(foodId);
|
||||||
|
cart.items[itemIndex] = this.buildCartItemFromFood(food, newQuantity, existingItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
import { UserAddress } from '../../users/entities/user-address.entity';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||||
|
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||||
|
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
||||||
|
import { PointTransaction } from '../../users/entities/point-transaction.entity';
|
||||||
|
import { Order } from '../../orders/entities/order.entity';
|
||||||
|
import { OrderStatus } from '../../orders/interface/order.interface';
|
||||||
|
import { Cart } from '../interfaces/cart.interface';
|
||||||
|
import { GeographicUtils } from '../utils/geographic.utils';
|
||||||
|
import { MealType } from '../../foods/interface/food.interface';
|
||||||
|
import { CartMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { WalletTransactionRepository } from 'src/modules/users/repositories/wallet-transaction.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CartValidationService {
|
||||||
|
constructor(
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
private readonly walletTransactionRepository: WalletTransactionRepository,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate food exists, belongs to restaurant, has inventory, and check stock
|
||||||
|
*/
|
||||||
|
async validateAndGetFood(foodId: string, restaurantId: string, quantity: number): Promise<Food> {
|
||||||
|
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant', 'inventory'] });
|
||||||
|
if (!food) {
|
||||||
|
throw new NotFoundException(CartMessage.FOOD_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (food.restaurant.id !== restaurantId) {
|
||||||
|
throw new BadRequestException(CartMessage.FOOD_NOT_BELONGS_TO_RESTAURANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!food.inventory) {
|
||||||
|
throw new BadRequestException(CartMessage.FOOD_NO_INVENTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateStock(food, quantity);
|
||||||
|
return food;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate stock availability for food
|
||||||
|
*/
|
||||||
|
validateStock(food: Food, quantity: number): void {
|
||||||
|
const availableStock = food.inventory!.availableStock;
|
||||||
|
if (availableStock < quantity) {
|
||||||
|
throw new BadRequestException(CartMessage.INSUFFICIENT_STOCK + food.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user or throw if not found
|
||||||
|
*/
|
||||||
|
async getUserOrFail(userId: string): Promise<User> {
|
||||||
|
const user = await this.em.findOne(User, { id: userId });
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(CartMessage.USER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user address or throw if not found
|
||||||
|
*/
|
||||||
|
async getUserAddressOrFail(addressId: string): Promise<UserAddress> {
|
||||||
|
const address = await this.em.findOne(UserAddress, { id: addressId }, { populate: ['user'] });
|
||||||
|
if (!address) {
|
||||||
|
throw new NotFoundException(CartMessage.ADDRESS_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get food or throw if not found
|
||||||
|
*/
|
||||||
|
async getFoodOrFail(foodId: string): Promise<Food> {
|
||||||
|
const food = await this.em.findOne(Food, { id: foodId });
|
||||||
|
if (!food) {
|
||||||
|
throw new NotFoundException(CartMessage.FOOD_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return food;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get restaurant or throw if not found
|
||||||
|
*/
|
||||||
|
async getRestaurantOrFail(restaurantId: string): Promise<Restaurant> {
|
||||||
|
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||||
|
if (!restaurant) {
|
||||||
|
throw new NotFoundException(CartMessage.RESTAURANT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return restaurant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate address belongs to user
|
||||||
|
*/
|
||||||
|
validateAddressOwnership(address: UserAddress, userId: string): void {
|
||||||
|
if (address.user.id !== userId) {
|
||||||
|
throw new BadRequestException(CartMessage.ADDRESS_NOT_BELONGS_TO_USER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert address is inside restaurant service area
|
||||||
|
*/
|
||||||
|
async assertAddressInsideServiceArea(
|
||||||
|
restaurantId: string,
|
||||||
|
latitude?: number | null,
|
||||||
|
longitude?: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
|
||||||
|
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restaurant = await this.getRestaurantOrFail(restaurantId);
|
||||||
|
|
||||||
|
const serviceArea = restaurant.serviceArea;
|
||||||
|
// If no service area is defined, assume service is available everywhere
|
||||||
|
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
|
||||||
|
|
||||||
|
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
|
||||||
|
const rings = serviceArea.coordinates;
|
||||||
|
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
|
||||||
|
|
||||||
|
const ring = rings[0];
|
||||||
|
const point: [number, number] = [Number(longitude), Number(latitude)];
|
||||||
|
|
||||||
|
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
|
||||||
|
const polygon: [number, number][] = ring.map(
|
||||||
|
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!GeographicUtils.isPointInPolygon(point, polygon)) {
|
||||||
|
throw new BadRequestException(CartMessage.ADDRESS_OUTSIDE_SERVICE_AREA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delivery method for restaurant or throw if not found
|
||||||
|
*/
|
||||||
|
async getDeliveryMethodForRestaurantOrFail(
|
||||||
|
restaurantId: string,
|
||||||
|
deliveryMethodId: string,
|
||||||
|
): Promise<Delivery> {
|
||||||
|
const deliveryMethod = await this.em.findOne(Delivery, {
|
||||||
|
id: deliveryMethodId,
|
||||||
|
restaurant: { id: restaurantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deliveryMethod) {
|
||||||
|
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND_FOR_RESTAURANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deliveryMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled delivery method or throw if not found or disabled
|
||||||
|
*/
|
||||||
|
async getEnabledDeliveryMethodOrFail(restaurantId: string, deliveryMethodId: string): Promise<Delivery> {
|
||||||
|
const deliveryMethod = await this.em.findOne(
|
||||||
|
Delivery,
|
||||||
|
{ id: deliveryMethodId, restaurant: { id: restaurantId } },
|
||||||
|
{ populate: ['restaurant'] },
|
||||||
|
);
|
||||||
|
if (!deliveryMethod) {
|
||||||
|
throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!deliveryMethod.enabled) {
|
||||||
|
throw new BadRequestException(CartMessage.DELIVERY_METHOD_NOT_ENABLED);
|
||||||
|
}
|
||||||
|
return deliveryMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert delivery method matches expected type
|
||||||
|
*/
|
||||||
|
assertDeliveryMethod(actual: DeliveryMethodEnum, expected: DeliveryMethodEnum, message: string): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require delivery method ID or throw
|
||||||
|
*/
|
||||||
|
requireDeliveryMethodId(cart: Cart, message: string): string {
|
||||||
|
if (!cart.deliveryMethodId) {
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
return cart.deliveryMethodId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled payment method or throw if not found or disabled
|
||||||
|
*/
|
||||||
|
async getEnabledPaymentMethodOrFail(restaurantId: string, paymentMethodId: string): Promise<PaymentMethod> {
|
||||||
|
const paymentMethod = await this.em.findOne(
|
||||||
|
PaymentMethod,
|
||||||
|
{ id: paymentMethodId, restaurant: { id: restaurantId } },
|
||||||
|
{ populate: ['restaurant'] },
|
||||||
|
);
|
||||||
|
if (!paymentMethod) {
|
||||||
|
throw new NotFoundException(CartMessage.PAYMENT_METHOD_NOT_FOUND);
|
||||||
|
}
|
||||||
|
if (!paymentMethod.enabled) {
|
||||||
|
throw new BadRequestException(CartMessage.PAYMENT_METHOD_NOT_ENABLED);
|
||||||
|
}
|
||||||
|
return paymentMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert wallet has enough balance
|
||||||
|
*/
|
||||||
|
async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise<void> {
|
||||||
|
const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restaurantId);
|
||||||
|
|
||||||
|
if (balance < amount) {
|
||||||
|
throw new BadRequestException(CartMessage.WALLET_INSUFFICIENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert coupon usage limit
|
||||||
|
*/
|
||||||
|
async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise<void> {
|
||||||
|
if (!maxUsesPerUser) return;
|
||||||
|
|
||||||
|
const ordersThatUsedTheCouponCount = await this.em.count(Order, {
|
||||||
|
user: { id: userId },
|
||||||
|
couponDetail: { couponId: couponId },
|
||||||
|
status: { $ne: OrderStatus.CANCELED },
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ordersThatUsedTheCouponCount >= maxUsesPerUser) {
|
||||||
|
throw new BadRequestException(CartMessage.COUPON_MAX_USES_REACHED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert coupon matches cart if restricted
|
||||||
|
*/
|
||||||
|
async assertCouponMatchesCartIfRestricted(
|
||||||
|
cart: Cart,
|
||||||
|
foodCategories?: string[] | null,
|
||||||
|
foods?: string[] | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const categoryRestriction = foodCategories?.filter(Boolean) ?? [];
|
||||||
|
const foodRestriction = foods?.filter(Boolean) ?? [];
|
||||||
|
const hasCategoryRestriction = categoryRestriction.length > 0;
|
||||||
|
const hasFoodRestriction = foodRestriction.length > 0;
|
||||||
|
if (!hasCategoryRestriction && !hasFoodRestriction) return;
|
||||||
|
|
||||||
|
const foodMap = await this.getFoodsInCartWithCategories(cart);
|
||||||
|
|
||||||
|
for (const item of cart.items) {
|
||||||
|
const food = foodMap.get(item.foodId);
|
||||||
|
if (!food) continue;
|
||||||
|
|
||||||
|
const matchesCategory = hasCategoryRestriction && food.category && categoryRestriction.includes(food.category.id);
|
||||||
|
const matchesFood = hasFoodRestriction && foodRestriction.includes(food.id);
|
||||||
|
if (matchesCategory || matchesFood) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(CartMessage.COUPON_CANNOT_BE_APPLIED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get foods in cart with categories
|
||||||
|
*/
|
||||||
|
async getFoodsInCartWithCategories(cart: Cart): Promise<Map<string, Food>> {
|
||||||
|
const foodIds = cart.items.map(item => item.foodId);
|
||||||
|
if (foodIds.length === 0) return new Map();
|
||||||
|
|
||||||
|
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] });
|
||||||
|
return new Map(foods.map(f => [f.id, f]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert all foods in cart have pickupServe true when delivery method is courier
|
||||||
|
*/
|
||||||
|
async assertAllFoodsHavePickupServeForCourier(cart: Cart, deliveryMethod: DeliveryMethodEnum): Promise<void> {
|
||||||
|
if (deliveryMethod !== DeliveryMethodEnum.DeliveryCourier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cart.items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const foodIds = cart.items.map(item => item.foodId);
|
||||||
|
const foods = await this.em.find(Food, { id: { $in: foodIds } });
|
||||||
|
|
||||||
|
const foodsWithoutPickupServe = foods.filter(food => !food.pickupServe);
|
||||||
|
|
||||||
|
if (foodsWithoutPickupServe.length > 0) {
|
||||||
|
const foodTitles = foodsWithoutPickupServe.map(f => f.title || f.id).join(', ');
|
||||||
|
throw new BadRequestException(
|
||||||
|
CartMessage.FOODS_MUST_HAVE_PICKUP_SERVE_FOR_COURIER.replace('[foodTitles]', foodTitles),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current Iran time context (weekday and meal type)
|
||||||
|
*/
|
||||||
|
private getCurrentIranTimeContext(): { iranWeekDay: number; mealType: MealType | null } {
|
||||||
|
const nowInIran = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Tehran' }));
|
||||||
|
const weekDay = nowInIran.getDay(); // 0 = Sunday, 6 = Saturday
|
||||||
|
const currentHour = nowInIran.getHours();
|
||||||
|
|
||||||
|
// Convert to Iran weekday: Saturday=0, Sunday=1, ..., Friday=6
|
||||||
|
// JavaScript: Sunday=0, Monday=1, ..., Saturday=6
|
||||||
|
// Iran week: Saturday=0, Sunday=1, ..., Friday=6
|
||||||
|
const iranWeekDay = (weekDay + 1) % 7;
|
||||||
|
|
||||||
|
const mealType = this.getMealTypeByHour(currentHour);
|
||||||
|
|
||||||
|
return { iranWeekDay, mealType };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine meal type based on current hour in Iran timezone.
|
||||||
|
* @param hour - Current hour (0-23)
|
||||||
|
* @returns Meal type or null if outside meal hours
|
||||||
|
*/
|
||||||
|
private getMealTypeByHour(hour: number): MealType | null {
|
||||||
|
const MEAL_TIME_RANGES = {
|
||||||
|
BREAKFAST: { start: 6, end: 11 },
|
||||||
|
LUNCH: { start: 11, end: 15 },
|
||||||
|
SNACK: { start: 15, end: 19 },
|
||||||
|
DINNER: { start: 19, end: 24 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (hour >= MEAL_TIME_RANGES.BREAKFAST.start && hour < MEAL_TIME_RANGES.BREAKFAST.end) {
|
||||||
|
return MealType.BREAKFAST;
|
||||||
|
}
|
||||||
|
if (hour >= MEAL_TIME_RANGES.LUNCH.start && hour < MEAL_TIME_RANGES.LUNCH.end) {
|
||||||
|
return MealType.LUNCH;
|
||||||
|
}
|
||||||
|
if (hour >= MEAL_TIME_RANGES.SNACK.start && hour < MEAL_TIME_RANGES.SNACK.end) {
|
||||||
|
return MealType.SNACK;
|
||||||
|
}
|
||||||
|
if (hour >= MEAL_TIME_RANGES.DINNER.start && hour < MEAL_TIME_RANGES.DINNER.end) {
|
||||||
|
return MealType.DINNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert meal type compatibility - if food has mealTypes, current meal time must be in that array
|
||||||
|
*/
|
||||||
|
assertMealTypeCompatibility(food: Food): void {
|
||||||
|
// If food has no meal type restrictions, allow it
|
||||||
|
if (!food.mealTypes || food.mealTypes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mealType } = this.getCurrentIranTimeContext();
|
||||||
|
|
||||||
|
// If current time is outside meal hours, throw error
|
||||||
|
if (mealType === null) {
|
||||||
|
const foodTitle = food.title || food.id;
|
||||||
|
throw new BadRequestException(
|
||||||
|
CartMessage.FOOD_ONLY_AVAILABLE_DURING_MEAL_TIMES.replace('[foodTitle]', foodTitle),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current meal type is in food's allowed meal types
|
||||||
|
if (!food.mealTypes.includes(mealType)) {
|
||||||
|
const foodTitle = food.title || food.id;
|
||||||
|
const mealTypeMap: Record<MealType, string> = {
|
||||||
|
[MealType.BREAKFAST]: 'صبحانه',
|
||||||
|
[MealType.LUNCH]: 'ناهار',
|
||||||
|
[MealType.SNACK]: 'عصرانه',
|
||||||
|
[MealType.DINNER]: 'شام',
|
||||||
|
};
|
||||||
|
const allowedMealTypes = food.mealTypes.map(mt => mealTypeMap[mt]).join(', ');
|
||||||
|
const currentMealType = mealType ? mealTypeMap[mealType] : mealType;
|
||||||
|
throw new BadRequestException(
|
||||||
|
CartMessage.FOOD_ONLY_AVAILABLE_FOR_MEAL_TYPES.replace('[foodTitle]', foodTitle)
|
||||||
|
.replace('[allowedMealTypes]', allowedMealTypes)
|
||||||
|
.replace('[mealType]', currentMealType),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert weekday compatibility - current weekday must be in food's weekDays array
|
||||||
|
*/
|
||||||
|
assertWeekdayCompatibility(food: Food): void {
|
||||||
|
// If food has no weekday restrictions (empty array or all days), allow it
|
||||||
|
if (!food.weekDays || food.weekDays.length === 0 || food.weekDays.length === 7) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { iranWeekDay } = this.getCurrentIranTimeContext();
|
||||||
|
|
||||||
|
// Check if current weekday is in food's allowed weekdays
|
||||||
|
if (!food.weekDays.includes(iranWeekDay)) {
|
||||||
|
const dayNames = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
||||||
|
const currentDayName = dayNames[iranWeekDay];
|
||||||
|
const allowedDays = food.weekDays.map(day => dayNames[day]).join(', ');
|
||||||
|
const foodTitle = food.title || food.id;
|
||||||
|
throw new BadRequestException(
|
||||||
|
CartMessage.FOOD_ONLY_AVAILABLE_ON_DAYS.replace('[foodTitle]', foodTitle)
|
||||||
|
.replace('[allowedDays]', allowedDays)
|
||||||
|
.replace('[currentDay]', currentDayName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
||||||
|
import { PaymentMethodEnum } from '../../payments/interface/payment';
|
||||||
|
import { OrderCouponDetail } from '../../orders/interface/order.interface';
|
||||||
|
import { Cart } from '../interfaces/cart.interface';
|
||||||
|
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
|
||||||
|
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
|
||||||
|
import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto';
|
||||||
|
import { CouponService } from '../../coupons/providers/coupon.service';
|
||||||
|
import { CartRepository } from '../repositories/cart.repository';
|
||||||
|
import { CartValidationService } from './cart-validation.service';
|
||||||
|
import { CartCalculationService } from './cart-calculation.service';
|
||||||
|
import { CartItemService } from './cart-item.service';
|
||||||
|
import { SetCarDeliveryDto } from '../dto/set-car-delivery.dto';
|
||||||
|
import { CartMessage } from 'src/common/enums/message.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CartService {
|
||||||
|
constructor(
|
||||||
|
private readonly cartRepository: CartRepository,
|
||||||
|
private readonly validationService: CartValidationService,
|
||||||
|
private readonly calculationService: CartCalculationService,
|
||||||
|
private readonly itemService: CartItemService,
|
||||||
|
private readonly couponService: CouponService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set all cart parameters at once
|
||||||
|
*/
|
||||||
|
async setAllCartParams(userId: string, restaurantId: string, params: SetAllCartParmsDto): Promise<Cart> {
|
||||||
|
const { deliveryMethodId, paymentMethodId, addressId, carAddress, description, tableNumber } = params;
|
||||||
|
|
||||||
|
// get existing cart (or throw)
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
// Validate and get delivery method
|
||||||
|
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
|
||||||
|
restaurantId,
|
||||||
|
deliveryMethodId,
|
||||||
|
);
|
||||||
|
cart.deliveryMethodId = deliveryMethodId;
|
||||||
|
|
||||||
|
// Handle each delivery method with its specific requirements
|
||||||
|
switch (deliveryMethod.method) {
|
||||||
|
case DeliveryMethodEnum.DineIn:
|
||||||
|
// DineIn requires table number and clears incompatible fields
|
||||||
|
if (!tableNumber) {
|
||||||
|
throw new BadRequestException(CartMessage.TABLE_NUMBER_REQUIRED);
|
||||||
|
}
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
||||||
|
cart.tableNumber = tableNumber;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DeliveryMethodEnum.CustomerPickup:
|
||||||
|
// CustomerPickup just clears incompatible fields
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.CustomerPickup);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DeliveryMethodEnum.DeliveryCar:
|
||||||
|
// DeliveryCar requires carAddress and clears incompatible fields
|
||||||
|
if (!carAddress) {
|
||||||
|
throw new BadRequestException(CartMessage.CAR_ADDRESS_REQUIRED);
|
||||||
|
}
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
||||||
|
const user = await this.validationService.getUserOrFail(userId);
|
||||||
|
cart.carAddress = {
|
||||||
|
carModel: carAddress.carModel,
|
||||||
|
carColor: carAddress.carColor,
|
||||||
|
plateNumber: carAddress.plateNumber,
|
||||||
|
phone: user.phone,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DeliveryMethodEnum.DeliveryCourier:
|
||||||
|
// DeliveryCourier requires addressId and clears incompatible fields
|
||||||
|
if (!addressId) {
|
||||||
|
throw new BadRequestException(CartMessage.ADDRESS_REQUIRED);
|
||||||
|
}
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
|
||||||
|
const address = await this.validationService.getUserAddressOrFail(addressId);
|
||||||
|
this.validationService.validateAddressOwnership(address, userId);
|
||||||
|
|
||||||
|
// ensure address is within restaurant service area
|
||||||
|
await this.validationService.assertAddressInsideServiceArea(
|
||||||
|
restaurantId,
|
||||||
|
address.latitude,
|
||||||
|
address.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
cart.userAddress = {
|
||||||
|
address: address.address,
|
||||||
|
latitude: address.latitude,
|
||||||
|
longitude: address.longitude,
|
||||||
|
city: address.city,
|
||||||
|
province: address.province || '',
|
||||||
|
postalCode: address.postalCode || undefined,
|
||||||
|
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
|
||||||
|
phone: address.user.phone,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and set payment method
|
||||||
|
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
|
||||||
|
restaurantId,
|
||||||
|
paymentMethodId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recalculate totals first so wallet check uses up-to-date total (delivery method may have changed above).
|
||||||
|
await this.calculationService.recalculateCartTotals(cart);
|
||||||
|
|
||||||
|
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
|
||||||
|
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||||
|
}
|
||||||
|
|
||||||
|
cart.paymentMethodId = paymentMethodId;
|
||||||
|
|
||||||
|
// Set description (if provided)
|
||||||
|
if (description !== undefined) {
|
||||||
|
cart.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final validation: if effective delivery method is courier, ensure all foods have pickupServe
|
||||||
|
await this.validationService.assertAllFoodsHavePickupServeForCourier(cart, deliveryMethod.method);
|
||||||
|
|
||||||
|
// Final recalculation + save and return cart
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create cart for user and restaurant
|
||||||
|
*/
|
||||||
|
async getOrCreateCart(userId: string, restaurantId: string): Promise<Cart> {
|
||||||
|
const existingCart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId);
|
||||||
|
if (existingCart) {
|
||||||
|
return existingCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find restaurant
|
||||||
|
const restaurant = await this.validationService.getRestaurantOrFail(restaurantId);
|
||||||
|
|
||||||
|
// Create new cart
|
||||||
|
const now = this.nowIso();
|
||||||
|
const cart: Cart = {
|
||||||
|
userId,
|
||||||
|
restaurantId,
|
||||||
|
restaurantName: restaurant.name,
|
||||||
|
items: [],
|
||||||
|
deliveryFee: 0,
|
||||||
|
subTotal: 0,
|
||||||
|
itemsDiscount: 0,
|
||||||
|
couponDiscount: 0,
|
||||||
|
totalDiscount: 0,
|
||||||
|
tax: 0,
|
||||||
|
total: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.cartRepository.save(cart);
|
||||||
|
return cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find cart by user and restaurant
|
||||||
|
*/
|
||||||
|
async findOneOrFail(userId: string, restaurantId: string): Promise<Cart> {
|
||||||
|
const cart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId);
|
||||||
|
if (!cart) {
|
||||||
|
throw new NotFoundException(CartMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment item quantity in cart
|
||||||
|
*/
|
||||||
|
async incrementItem(userId: string, restaurantId: string, foodId: string, quantity: number): Promise<Cart> {
|
||||||
|
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||||||
|
await this.itemService.addOrIncrementItem(cart, foodId, restaurantId, quantity);
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement item quantity in cart by 1 (removes item if quantity reaches 0)
|
||||||
|
*/
|
||||||
|
async decrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
await this.itemService.decrementOrRemoveItem(cart, foodId);
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk add items to cart (increments quantity if items exist)
|
||||||
|
*/
|
||||||
|
async bulkAddItems(userId: string, restaurantId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise<Cart> {
|
||||||
|
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||||||
|
|
||||||
|
for (const addItemDto of bulkAddItemsDto.items) {
|
||||||
|
await this.itemService.addOrIncrementItem(cart, addItemDto.foodId, restaurantId, addItemDto.quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove item from cart
|
||||||
|
*/
|
||||||
|
async removeItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
this.itemService.removeItemOrFail(cart, foodId);
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply coupon to cart
|
||||||
|
*/
|
||||||
|
async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
const orderAmount = cart.subTotal - cart.itemsDiscount;
|
||||||
|
const user = await this.validationService.getUserOrFail(userId);
|
||||||
|
// check if coupon is valid and belong to the restaurant
|
||||||
|
const { valid, coupon, message } = await this.couponService.validateCoupon(
|
||||||
|
applyCouponDto.code,
|
||||||
|
restaurantId,
|
||||||
|
Number(orderAmount),
|
||||||
|
user.phone,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
throw new BadRequestException(message || CartMessage.COUPON_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
throw new BadRequestException(CartMessage.COUPON_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check maxUsesPerUser limit by counting how many orders this user has with this coupon
|
||||||
|
await this.validationService.assertCouponUsageLimit(userId, coupon.id, coupon.maxUsesPerUser);
|
||||||
|
await this.validationService.assertCouponMatchesCartIfRestricted(
|
||||||
|
cart,
|
||||||
|
coupon.foodCategories,
|
||||||
|
coupon.foods,
|
||||||
|
);
|
||||||
|
|
||||||
|
const couponDetail: OrderCouponDetail = {
|
||||||
|
couponId: coupon.id,
|
||||||
|
couponName: coupon.name,
|
||||||
|
couponCode: coupon.code,
|
||||||
|
type: coupon.type,
|
||||||
|
value: coupon.value,
|
||||||
|
maxDiscount: coupon.maxDiscount,
|
||||||
|
};
|
||||||
|
|
||||||
|
cart.coupon = couponDetail;
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove coupon from cart
|
||||||
|
*/
|
||||||
|
async removeCoupon(userId: string, restaurantId: string): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
cart.coupon = undefined;
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set address for cart
|
||||||
|
*/
|
||||||
|
async setAddress(userId: string, restaurantId: string, addressId: string): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||||
|
cart,
|
||||||
|
'Delivery method must be set before setting address',
|
||||||
|
);
|
||||||
|
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||||
|
restaurantId,
|
||||||
|
deliveryMethodId,
|
||||||
|
);
|
||||||
|
this.validationService.assertDeliveryMethod(
|
||||||
|
deliveryMethod.method,
|
||||||
|
DeliveryMethodEnum.DeliveryCourier,
|
||||||
|
'Delivery method must be DeliveryCourier',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find and validate address belongs to user
|
||||||
|
const address = await this.validationService.getUserAddressOrFail(addressId);
|
||||||
|
|
||||||
|
// Verify address belongs to the user
|
||||||
|
this.validationService.validateAddressOwnership(address, userId);
|
||||||
|
|
||||||
|
// ensure address is within restaurant service area
|
||||||
|
await this.validationService.assertAddressInsideServiceArea(
|
||||||
|
restaurantId,
|
||||||
|
address.latitude,
|
||||||
|
address.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
|
||||||
|
cart.userAddress = {
|
||||||
|
address: address.address,
|
||||||
|
latitude: address.latitude,
|
||||||
|
longitude: address.longitude,
|
||||||
|
city: address.city,
|
||||||
|
province: address.province || '',
|
||||||
|
postalCode: address.postalCode || undefined,
|
||||||
|
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
|
||||||
|
phone: address.user.phone,
|
||||||
|
};
|
||||||
|
return this.saveTouchedCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set payment method for cart
|
||||||
|
*/
|
||||||
|
async setPaymentMethod(
|
||||||
|
userId: string,
|
||||||
|
restaurantId: string,
|
||||||
|
paymentMethodId: string,
|
||||||
|
): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
|
||||||
|
restaurantId,
|
||||||
|
paymentMethodId,
|
||||||
|
);
|
||||||
|
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
|
||||||
|
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||||
|
}
|
||||||
|
cart.paymentMethodId = paymentMethodId;
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set delivery method for cart
|
||||||
|
*/
|
||||||
|
async setDeliveryMethod(
|
||||||
|
userId: string,
|
||||||
|
restaurantId: string,
|
||||||
|
deliveryMethodId: string,
|
||||||
|
): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
|
||||||
|
restaurantId,
|
||||||
|
deliveryMethodId,
|
||||||
|
);
|
||||||
|
|
||||||
|
cart.deliveryMethodId = deliveryMethodId;
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method);
|
||||||
|
|
||||||
|
return this.recalculateAndSaveCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set description for cart
|
||||||
|
*/
|
||||||
|
async setDescription(userId: string, restaurantId: string, description: string): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
cart.description = description;
|
||||||
|
return this.saveTouchedCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set table number for cart
|
||||||
|
*/
|
||||||
|
async setTableNumber(userId: string, restaurantId: string, tableNumber: string): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||||
|
cart,
|
||||||
|
'Delivery method must be set before setting table number',
|
||||||
|
);
|
||||||
|
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||||
|
restaurantId,
|
||||||
|
deliveryMethodId,
|
||||||
|
);
|
||||||
|
this.validationService.assertDeliveryMethod(
|
||||||
|
deliveryMethod.method,
|
||||||
|
DeliveryMethodEnum.DineIn,
|
||||||
|
'Delivery method must be DineIn',
|
||||||
|
);
|
||||||
|
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
||||||
|
cart.tableNumber = tableNumber;
|
||||||
|
return this.saveTouchedCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set car delivery for cart
|
||||||
|
*/
|
||||||
|
async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise<Cart> {
|
||||||
|
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||||
|
|
||||||
|
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||||
|
cart,
|
||||||
|
'Delivery method must be set before setting car delivery',
|
||||||
|
);
|
||||||
|
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||||
|
restaurantId,
|
||||||
|
deliveryMethodId,
|
||||||
|
);
|
||||||
|
this.validationService.assertDeliveryMethod(
|
||||||
|
deliveryMethod.method,
|
||||||
|
DeliveryMethodEnum.DeliveryCar,
|
||||||
|
'Delivery method must be DeliveryCar',
|
||||||
|
);
|
||||||
|
const user = await this.validationService.getUserOrFail(userId);
|
||||||
|
|
||||||
|
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
||||||
|
cart.carAddress = {
|
||||||
|
carModel: setCarDeliveryDto.carModel,
|
||||||
|
carColor: setCarDeliveryDto.carColor,
|
||||||
|
plateNumber: setCarDeliveryDto.plateNumber,
|
||||||
|
phone: user.phone,
|
||||||
|
};
|
||||||
|
return this.saveTouchedCart(cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cart from cache
|
||||||
|
*/
|
||||||
|
async clearCart(userId: string, restaurantId: string): Promise<void> {
|
||||||
|
return this.cartRepository.delete(userId, restaurantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears cart fields that are not applicable for a given delivery method.
|
||||||
|
*/
|
||||||
|
private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void {
|
||||||
|
if (method === DeliveryMethodEnum.DineIn) {
|
||||||
|
cart.userAddress = null;
|
||||||
|
cart.carAddress = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === DeliveryMethodEnum.CustomerPickup) {
|
||||||
|
cart.userAddress = null;
|
||||||
|
cart.carAddress = null;
|
||||||
|
cart.tableNumber = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === DeliveryMethodEnum.DeliveryCourier) {
|
||||||
|
cart.carAddress = null;
|
||||||
|
cart.tableNumber = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === DeliveryMethodEnum.DeliveryCar) {
|
||||||
|
cart.userAddress = null;
|
||||||
|
cart.tableNumber = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save cart with updated timestamp
|
||||||
|
*/
|
||||||
|
private async saveTouchedCart(cart: Cart): Promise<Cart> {
|
||||||
|
cart.updatedAt = this.nowIso();
|
||||||
|
await this.cartRepository.save(cart);
|
||||||
|
return cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate cart totals and save
|
||||||
|
*/
|
||||||
|
private async recalculateAndSaveCart(cart: Cart): Promise<Cart> {
|
||||||
|
await this.calculationService.recalculateCartTotals(cart);
|
||||||
|
await this.cartRepository.save(cart);
|
||||||
|
return cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current ISO timestamp
|
||||||
|
*/
|
||||||
|
private nowIso(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CacheService } from '../../utils/cache.service';
|
||||||
|
import { Cart } from '../interfaces/cart.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CartRepository {
|
||||||
|
private readonly CART_TTL = 3600; // 1 hour in seconds
|
||||||
|
private readonly CART_KEY_PREFIX = 'cart';
|
||||||
|
|
||||||
|
constructor(private readonly cacheService: CacheService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cart by user and restaurant
|
||||||
|
*/
|
||||||
|
async findByUserAndRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
|
||||||
|
const cacheKey = this.getCacheKey(userId, restaurantId);
|
||||||
|
const cachedCart = await this.cacheService.get<string>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedCart) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(cachedCart);
|
||||||
|
if (this.isCart(parsed) && parsed.userId === userId && parsed.restaurantId === restaurantId) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If parsing fails, return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save cart to cache
|
||||||
|
*/
|
||||||
|
async save(cart: Cart): Promise<void> {
|
||||||
|
const cacheKey = this.getCacheKey(cart.userId, cart.restaurantId);
|
||||||
|
await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cart from cache
|
||||||
|
*/
|
||||||
|
async delete(userId: string, restaurantId: string): Promise<void> {
|
||||||
|
const cacheKey = this.getCacheKey(userId, restaurantId);
|
||||||
|
await this.cacheService.del(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate cache key for cart
|
||||||
|
*/
|
||||||
|
private getCacheKey(userId: string, restaurantId: string): string {
|
||||||
|
return `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if an object is a Cart
|
||||||
|
*/
|
||||||
|
private isCart(obj: unknown): obj is Cart {
|
||||||
|
if (typeof obj !== 'object' || obj === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cart = obj as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof cart.userId === 'string' &&
|
||||||
|
typeof cart.restaurantId === 'string' &&
|
||||||
|
Array.isArray(cart.items) &&
|
||||||
|
typeof cart.subTotal === 'number' &&
|
||||||
|
typeof cart.itemsDiscount === 'number' &&
|
||||||
|
typeof cart.couponDiscount === 'number' &&
|
||||||
|
typeof cart.totalDiscount === 'number' &&
|
||||||
|
typeof cart.tax === 'number' &&
|
||||||
|
typeof cart.deliveryFee === 'number' &&
|
||||||
|
typeof cart.total === 'number' &&
|
||||||
|
typeof cart.totalItems === 'number' &&
|
||||||
|
typeof cart.createdAt === 'string' &&
|
||||||
|
typeof cart.updatedAt === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Utility class for geographic calculations
|
||||||
|
*/
|
||||||
|
export class GeographicUtils {
|
||||||
|
/**
|
||||||
|
* Check if a point is inside a polygon using ray-casting algorithm
|
||||||
|
* @param point - [longitude, latitude]
|
||||||
|
* @param polygon - Array of [longitude, latitude] pairs
|
||||||
|
*/
|
||||||
|
static isPointInPolygon(point: [number, number], polygon: [number, number][]): boolean {
|
||||||
|
const x = point[0];
|
||||||
|
const y = point[1];
|
||||||
|
let inside = false;
|
||||||
|
|
||||||
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||||
|
const xi = polygon[i][0];
|
||||||
|
const yi = polygon[i][1];
|
||||||
|
const xj = polygon[j][0];
|
||||||
|
const yj = polygon[j][1];
|
||||||
|
|
||||||
|
const intersect =
|
||||||
|
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert degrees to radians
|
||||||
|
*/
|
||||||
|
private static toRad(value: number): number {
|
||||||
|
return (value * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two points in kilometers using Haversine formula
|
||||||
|
*/
|
||||||
|
static getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
|
const R = 6371; // Earth radius in KM
|
||||||
|
|
||||||
|
const dLat = this.toRad(lat2 - lat1);
|
||||||
|
const dLng = this.toRad(lng2 - lng1);
|
||||||
|
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
|
return R * c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distance between two points in kilometers (rounded)
|
||||||
|
*/
|
||||||
|
static getDistanceKmRounded(
|
||||||
|
lat1: number,
|
||||||
|
lng1: number,
|
||||||
|
lat2: number,
|
||||||
|
lng2: number,
|
||||||
|
precision = 2,
|
||||||
|
): number {
|
||||||
|
const distance = this.getDistanceKm(lat1, lng1, lat2, lng2);
|
||||||
|
return Number(distance.toFixed(precision));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ContactService } from './providers/contact.service';
|
||||||
|
import { ContactController } from './controllers/contact.controller';
|
||||||
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
|
import { Contact } from './entities/contact.entity';
|
||||||
|
import { ContactRepository } from './repositories/contact.repository';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MikroOrmModule.forFeature([Contact]), JwtModule],
|
||||||
|
controllers: [ContactController],
|
||||||
|
providers: [ContactService, ContactRepository],
|
||||||
|
exports: [ContactRepository],
|
||||||
|
})
|
||||||
|
export class ContactModule {}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Patch,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Delete,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ContactService } from '../providers/contact.service';
|
||||||
|
import { CreateContactDto } from '../dto/create-contact.dto';
|
||||||
|
import { FindContactsDto } from '../dto/find-contacts.dto';
|
||||||
|
import { UpdateContactStatusDto } from '../dto/update-contact-status.dto';
|
||||||
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiHeader } from '@nestjs/swagger';
|
||||||
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
|
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
||||||
|
import { OptionalAuthGuard } from 'src/modules/auth/guards/optinalAuth.guard';
|
||||||
|
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||||
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
|
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||||
|
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
|
||||||
|
|
||||||
|
@ApiTags('contact')
|
||||||
|
@Controller()
|
||||||
|
export class ContactController {
|
||||||
|
constructor(private readonly contactService: ContactService) { }
|
||||||
|
|
||||||
|
@UseGuards(OptionalAuthGuard)
|
||||||
|
@Post('public/contact')
|
||||||
|
@ApiOperation({ summary: 'Create a new contact (public endpoint)' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
async create(@Body() createContactDto: CreateContactDto, @RestSlug() slug: string, @RestId() restId?: string) {
|
||||||
|
return this.contactService.create(createContactDto, restId, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_CONTACTS)
|
||||||
|
@Get('admin/contact')
|
||||||
|
@ApiOperation({ summary: 'Get paginated list of contacts (admin only)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'search', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||||
|
@ApiQuery({ name: 'status', required: false, enum: ['new', 'seen'] })
|
||||||
|
async findAllPaginated(@RestId() restId: string, @Query() dto: FindContactsDto) {
|
||||||
|
return this.contactService.findAllPaginatedAdmin(dto, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_CONTACTS)
|
||||||
|
@Get('admin/contact/:id')
|
||||||
|
@ApiOperation({ summary: 'Get contact details by ID (admin only)' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Contact ID' })
|
||||||
|
async findOne(@RestId() restId: string, @Param('id') id: string) {
|
||||||
|
return this.contactService.findOne(id, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_CONTACTS)
|
||||||
|
@Patch('admin/contact/:id/status')
|
||||||
|
@ApiOperation({ summary: 'Update contact status (admin only)' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Contact ID' })
|
||||||
|
async updateStatus(@RestId() restId: string, @Param('id') id: string, @Body() dto: UpdateContactStatusDto) {
|
||||||
|
return this.contactService.updateStatus(id, dto, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_CONTACTS)
|
||||||
|
@Delete('admin/contact/:id')
|
||||||
|
@ApiOperation({ summary: 'Hard delete a contact (admin only)' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Contact ID' })
|
||||||
|
async remove(@RestId() restId: string, @Param('id') id: string) {
|
||||||
|
await this.contactService.remove(id, restId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*** Super Admin ***/
|
||||||
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Get('super-admin/contact')
|
||||||
|
@ApiOperation({ summary: 'Get paginated list of contacts (admin only)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'search', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||||
|
@ApiQuery({ name: 'status', required: false, enum: ['new', 'seen'] })
|
||||||
|
async findAllPaginatedForSuper(@Query() dto: FindContactsDto) {
|
||||||
|
return this.contactService.findAllPaginatedSuper(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(SuperAdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Get('super-admin/contact/:id')
|
||||||
|
@ApiOperation({ summary: 'Get contact details by ID (admin only)' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Contact ID' })
|
||||||
|
async findOneForSuper(@Param('id') id: string) {
|
||||||
|
return this.contactService.findOne(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsNotEmpty, IsString, IsOptional, IsMobilePhone, IsEnum } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { ContactScope } from '../interface/interface';
|
||||||
|
|
||||||
|
export class CreateContactDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'Question about menu', description: 'Contact subject' })
|
||||||
|
subject?: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: 'I have a question about your menu items', description: 'Contact content/message' })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(['application', 'restaurant'])
|
||||||
|
@ApiProperty({ description: 'Contact Scope' })
|
||||||
|
scope!: ContactScope;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsMobilePhone('fa-IR')
|
||||||
|
@ApiPropertyOptional({ example: '09362532122', description: 'Phone number' })
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { IsOptional, IsNumber, IsString, IsIn, IsEnum } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ContactStatusEnum } from '../interface/interface';
|
||||||
|
|
||||||
|
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||||
|
type SortOrder = (typeof sortOrderOptions)[number];
|
||||||
|
|
||||||
|
export class FindContactsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 })
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 })
|
||||||
|
limit?: number = 10;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'menu', description: 'Search in subject, content, or phone' })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' })
|
||||||
|
orderBy?: string = 'createdAt';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(sortOrderOptions)
|
||||||
|
@ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' })
|
||||||
|
order?: SortOrder = 'desc';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ContactStatusEnum)
|
||||||
|
@ApiPropertyOptional({ enum: ContactStatusEnum, description: 'Filter by status' })
|
||||||
|
status?: ContactStatusEnum;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { ContactStatusEnum } from '../interface/interface';
|
||||||
|
|
||||||
|
export class UpdateContactStatusDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(ContactStatusEnum)
|
||||||
|
@ApiProperty({ enum: ContactStatusEnum, description: 'New status for the contact' })
|
||||||
|
status!: ContactStatusEnum;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateContactDto } from './create-contact.dto';
|
||||||
|
|
||||||
|
export class UpdateContactDto extends PartialType(CreateContactDto) {}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Entity, Enum, ManyToOne, Property } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
|
import { ContactScope, ContactStatusEnum } from '../interface/interface';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'contacts' })
|
||||||
|
export class Contact extends BaseEntity {
|
||||||
|
@Property({ nullable: true })
|
||||||
|
subject?: string | null = null;
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@Property({ nullable: true })
|
||||||
|
phone?: string | null = null;
|
||||||
|
|
||||||
|
@Enum(() => ContactScope)
|
||||||
|
scope: ContactScope
|
||||||
|
|
||||||
|
@ManyToOne(() => Restaurant, { nullable: true })
|
||||||
|
restaurant?: Restaurant | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
@Property({ default: ContactStatusEnum.New })
|
||||||
|
status: ContactStatusEnum = ContactStatusEnum.New;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export enum ContactStatusEnum {
|
||||||
|
New = 'new',
|
||||||
|
Seen = 'seen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ContactScope {
|
||||||
|
APPLICATION = 'application',
|
||||||
|
RESTAURANT = 'restaurant',
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { CreateContactDto } from '../dto/create-contact.dto';
|
||||||
|
import { FindContactsDto } from '../dto/find-contacts.dto';
|
||||||
|
import { UpdateContactStatusDto } from '../dto/update-contact-status.dto';
|
||||||
|
import { ContactRepository } from '../repositories/contact.repository';
|
||||||
|
import { Contact } from '../entities/contact.entity';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
import { ContactStatusEnum } from '../interface/interface';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { ContactScope } from '../interface/interface';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContactService {
|
||||||
|
constructor(
|
||||||
|
private readonly contactRepository: ContactRepository,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async create(dto: CreateContactDto, restId?: string, slug?: string): Promise<Contact> {
|
||||||
|
|
||||||
|
|
||||||
|
let restaurant: Restaurant | null = null;
|
||||||
|
|
||||||
|
if ((restId || slug) && dto.scope === ContactScope.RESTAURANT) {
|
||||||
|
restaurant = await this.em.findOne(Restaurant, {
|
||||||
|
...(restId ? { id: restId } : {})
|
||||||
|
, ...(slug ? { slug: slug } : {})
|
||||||
|
});
|
||||||
|
if (!restaurant) {
|
||||||
|
throw new NotFoundException(`Restaurant with ID ${restId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = this.contactRepository.create({
|
||||||
|
...dto,
|
||||||
|
status: ContactStatusEnum.New,
|
||||||
|
restaurant,
|
||||||
|
});
|
||||||
|
await this.em.persistAndFlush(contact);
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllPaginatedAdmin(dto: FindContactsDto, restaurantId?: string): Promise<PaginatedResult<Contact>> {
|
||||||
|
return this.contactRepository.findAllPaginated({
|
||||||
|
page: dto.page,
|
||||||
|
limit: dto.limit,
|
||||||
|
search: dto.search,
|
||||||
|
orderBy: dto.orderBy,
|
||||||
|
order: dto.order,
|
||||||
|
status: dto.status,
|
||||||
|
scope: ContactScope.RESTAURANT,
|
||||||
|
restaurantId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async findAllPaginatedSuper(dto: FindContactsDto): Promise<PaginatedResult<Contact>> {
|
||||||
|
return this.contactRepository.findAllPaginated({
|
||||||
|
page: dto.page,
|
||||||
|
limit: dto.limit,
|
||||||
|
search: dto.search,
|
||||||
|
orderBy: dto.orderBy,
|
||||||
|
order: dto.order,
|
||||||
|
status: dto.status,
|
||||||
|
scope: ContactScope.APPLICATION
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string, restaurantId?: string): Promise<Contact> {
|
||||||
|
const where: any = { id };
|
||||||
|
if (restaurantId) {
|
||||||
|
where.restaurant = restaurantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await this.contactRepository.findOne(where);
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException(`Contact with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, dto: UpdateContactStatusDto, restaurantId?: string): Promise<Contact> {
|
||||||
|
const where: any = { id };
|
||||||
|
if (restaurantId) {
|
||||||
|
where.restaurant = restaurantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await this.contactRepository.findOne(where);
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException(`Contact with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
contact.status = dto.status;
|
||||||
|
await this.em.persistAndFlush(contact);
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, restaurantId?: string): Promise<void> {
|
||||||
|
const where: any = { id };
|
||||||
|
if (restaurantId) {
|
||||||
|
where.restaurant = restaurantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = await this.contactRepository.findOne(where);
|
||||||
|
if (!contact) {
|
||||||
|
throw new NotFoundException(`Contact with ID ${id} not found`);
|
||||||
|
}
|
||||||
|
await this.em.removeAndFlush(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||||
|
import { FilterQuery } from '@mikro-orm/core';
|
||||||
|
import { Contact } from '../entities/contact.entity';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
import { ContactScope, ContactStatusEnum } from '../interface/interface';
|
||||||
|
|
||||||
|
type FindContactsOpts = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
orderBy?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
status?: ContactStatusEnum;
|
||||||
|
scope?: ContactScope;
|
||||||
|
restaurantId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContactRepository extends EntityRepository<Contact> {
|
||||||
|
constructor(readonly em: EntityManager) {
|
||||||
|
super(em, Contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find contacts with pagination and optional filters.
|
||||||
|
* Supports: search (subject/content/phone), status, ordering.
|
||||||
|
*/
|
||||||
|
async findAllPaginated(opts: FindContactsOpts = {}): Promise<PaginatedResult<Contact>> {
|
||||||
|
const { page = 1, limit = 10, search, scope, orderBy = 'createdAt', order = 'desc', status, restaurantId } = opts;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: FilterQuery<Contact> = {};
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status;
|
||||||
|
}
|
||||||
|
if (scope) {
|
||||||
|
where.scope = scope;
|
||||||
|
}
|
||||||
|
if (restaurantId) {
|
||||||
|
where.restaurant = restaurantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
where.$or = [{ subject: { $ilike: pattern } }, { content: { $ilike: pattern } }, { phone: { $ilike: pattern } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await this.findAndCount(where, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { CouponService } from '../providers/coupon.service';
|
||||||
|
import { CreateCouponDto } from '../dto/create-coupon.dto';
|
||||||
|
import { UpdateCouponDto } from '../dto/update-coupon.dto';
|
||||||
|
import { FindCouponsDto } from '../dto/find-coupons.dto';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
ApiQuery,
|
||||||
|
ApiBody,
|
||||||
|
ApiParam,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiHeader,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
|
import { RestId } from 'src/common/decorators';
|
||||||
|
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||||
|
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||||
|
import { Permission } from 'src/common/enums/permission.enum';
|
||||||
|
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||||
|
|
||||||
|
@ApiTags('coupons')
|
||||||
|
@Controller()
|
||||||
|
export class CouponController {
|
||||||
|
constructor(private readonly couponService: CouponService) { }
|
||||||
|
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Get('public/coupons/me')
|
||||||
|
@ApiOperation({ summary: 'Get paginated list of restaurant coupons' })
|
||||||
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'search', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||||
|
@ApiQuery({ name: 'type', required: false, enum: ['PERCENTAGE', 'FIXED'] })
|
||||||
|
@ApiQuery({ name: 'isActive', required: false, type: Boolean })
|
||||||
|
getMyCoupons(@Query() dto: FindCouponsDto, @RestId() restId: string) {
|
||||||
|
return this.couponService.findAll(restId, dto);
|
||||||
|
}
|
||||||
|
/*** Admin ***/
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_COUPONS)
|
||||||
|
@Post('admin/coupons')
|
||||||
|
@ApiOperation({ summary: 'Create a new coupon' })
|
||||||
|
@ApiCreatedResponse({ description: 'The coupon has been successfully created.', type: CreateCouponDto })
|
||||||
|
@ApiBody({ type: CreateCouponDto })
|
||||||
|
create(@Body() createCouponDto: CreateCouponDto, @RestId() restId: string) {
|
||||||
|
return this.couponService.create(restId, createCouponDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_COUPONS)
|
||||||
|
@Get('admin/coupons')
|
||||||
|
@ApiOperation({ summary: 'Get a paginated list of coupons' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'search', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||||
|
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||||
|
@ApiQuery({ name: 'type', required: false, enum: ['PERCENTAGE', 'FIXED'] })
|
||||||
|
@ApiQuery({ name: 'isActive', required: false, type: Boolean })
|
||||||
|
findAll(@Query() dto: FindCouponsDto, @RestId() restId: string) {
|
||||||
|
return this.couponService.findAll(restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_COUPONS)
|
||||||
|
@Get('admin/coupons/:id')
|
||||||
|
@ApiOperation({ summary: 'Get a coupon by id' })
|
||||||
|
@ApiParam({ name: 'id', required: true })
|
||||||
|
findById(@Param('id') id: string) {
|
||||||
|
return this.couponService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_COUPONS)
|
||||||
|
@Patch('admin/coupons/:id')
|
||||||
|
@ApiOperation({ summary: 'Update a coupon' })
|
||||||
|
@ApiParam({ name: 'id', required: true })
|
||||||
|
@ApiBody({ type: UpdateCouponDto })
|
||||||
|
update(@Param('id') id: string, @Body() updateCouponDto: UpdateCouponDto) {
|
||||||
|
return this.couponService.update(id, updateCouponDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_COUPONS)
|
||||||
|
@Delete('admin/coupons/:id')
|
||||||
|
@ApiOperation({ summary: 'Delete (soft) a coupon' })
|
||||||
|
@ApiParam({ name: 'id', required: true })
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.couponService.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AdminAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Permissions(Permission.MANAGE_COUPONS)
|
||||||
|
@Get('admin/coupons/generate-code')
|
||||||
|
@ApiOperation({ summary: 'generate a coupon code' })
|
||||||
|
generateCouponCode(@RestId() restId: string) {
|
||||||
|
return this.couponService.generateCouponCode(restId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CouponService } from './providers/coupon.service';
|
||||||
|
import { CouponController } from './controllers/coupon.controller';
|
||||||
|
import { CouponRepository } from './repositories/coupon.repository';
|
||||||
|
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||||
|
import { Coupon } from './entities/coupon.entity';
|
||||||
|
import { RestaurantsModule } from '../restaurants/restaurants.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MikroOrmModule.forFeature([Coupon]), RestaurantsModule, AuthModule, JwtModule],
|
||||||
|
controllers: [CouponController],
|
||||||
|
providers: [CouponService, CouponRepository],
|
||||||
|
exports: [CouponRepository, CouponService],
|
||||||
|
})
|
||||||
|
export class CouponModule {}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
Min,
|
||||||
|
IsArray,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { CouponType } from '../interface/coupon';
|
||||||
|
|
||||||
|
export class CreateCouponDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: 'DISCOUNT10', description: 'Unique coupon code' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: '10% Off', description: 'Coupon name' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'Get 10% off on your order', description: 'Coupon description' })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum(CouponType)
|
||||||
|
@ApiProperty({ enum: CouponType, example: CouponType.PERCENTAGE, description: 'Coupon type' })
|
||||||
|
type!: CouponType;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiProperty({ example: 10, description: 'Discount value (amount or percentage)' })
|
||||||
|
value!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 50000, description: 'Maximum discount amount (for percentage coupons)' })
|
||||||
|
maxDiscount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 100000, description: 'Minimum order amount to use coupon' })
|
||||||
|
minOrderAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 100, description: 'Maximum number of times coupon can be used' })
|
||||||
|
maxUses?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 1, description: 'Maximum uses per user' })
|
||||||
|
maxUsesPerUser?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
@ApiPropertyOptional({ example: '2024-01-01T00:00:00Z', description: 'Coupon validity start date' })
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
@ApiPropertyOptional({ example: '2024-12-31T23:59:59Z', description: 'Coupon validity end date' })
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@ApiPropertyOptional({ example: true, description: 'Whether coupon is active' })
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: ['category-id-1', 'category-id-2'],
|
||||||
|
description: 'Array of food category IDs. If empty, coupon applies to all categories.',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
foodCategories?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: ['food-id-1', 'food-id-2'],
|
||||||
|
description: 'Array of food IDs. If empty, coupon applies to all foods.',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
foods?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: '09123456789',
|
||||||
|
description: 'Phone number of the user who can use this coupon. If empty, coupon can be used by any user.',
|
||||||
|
})
|
||||||
|
userPhone?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { IsOptional, IsString, IsBoolean, IsNumber, IsEnum } from 'class-validator';
|
||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { CouponType } from '../interface/coupon';
|
||||||
|
|
||||||
|
export class FindCouponsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 1, description: 'Page number' })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 10, description: 'Items per page' })
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'DISCOUNT', description: 'Search term for code or name' })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CouponType)
|
||||||
|
@ApiPropertyOptional({ enum: CouponType, description: 'Filter by coupon type' })
|
||||||
|
type?: CouponType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Type(() => Boolean)
|
||||||
|
@ApiPropertyOptional({ example: true, description: 'Filter by active status' })
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'createdAt', description: 'Field to order by' })
|
||||||
|
orderBy?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['asc', 'desc'])
|
||||||
|
@ApiPropertyOptional({ enum: ['asc', 'desc'], example: 'desc', description: 'Sort order' })
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateCouponDto } from './create-coupon.dto';
|
||||||
|
|
||||||
|
export class UpdateCouponDto extends PartialType(CreateCouponDto) {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { IsNotEmpty, IsString, IsNumber, IsOptional } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class ValidateCouponDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ example: 'DISCOUNT10', description: 'Coupon code to validate' })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ example: 150000, description: 'Order total amount for validation' })
|
||||||
|
orderAmount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@ApiPropertyOptional({ example: 'user-id', description: 'User ID for per-user usage limits' })
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Entity, Index, ManyToOne, Property, Enum, Unique } from '@mikro-orm/core';
|
||||||
|
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||||
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
|
import { normalizePhone } from '../../utils/phone.util';
|
||||||
|
import { CouponType } from '../interface/coupon';
|
||||||
|
|
||||||
|
@Entity({ tableName: 'coupons' })
|
||||||
|
@Unique({ properties: ['code', 'restaurant'] })
|
||||||
|
@Index({ properties: ['restaurant', 'code', 'isActive'] })
|
||||||
|
@Index({ properties: ['restaurant', 'isActive'] })
|
||||||
|
@Index({ properties: ['code'] })
|
||||||
|
export class Coupon extends BaseEntity {
|
||||||
|
@ManyToOne(() => Restaurant)
|
||||||
|
restaurant!: Restaurant;
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Property()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Property({ type: 'text', nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Enum(() => CouponType)
|
||||||
|
type!: CouponType;
|
||||||
|
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 2 })
|
||||||
|
value!: number; // Discount amount or percentage
|
||||||
|
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
maxDiscount?: number; // Maximum discount for percentage coupons
|
||||||
|
|
||||||
|
@Property({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||||
|
minOrderAmount?: number; // Minimum order amount to use coupon
|
||||||
|
|
||||||
|
@Property({ type: 'int', nullable: true })
|
||||||
|
maxUses?: number; // Maximum number of times coupon can be used
|
||||||
|
|
||||||
|
@Property({ type: 'int', default: 0 })
|
||||||
|
usedCount: number = 0; // Number of times coupon has been used
|
||||||
|
|
||||||
|
@Property({ type: 'int', nullable: true })
|
||||||
|
maxUsesPerUser?: number; // Maximum uses per user
|
||||||
|
|
||||||
|
@Property({ type: 'timestamptz', nullable: true })
|
||||||
|
startDate?: Date; // Coupon validity start date
|
||||||
|
|
||||||
|
@Property({ type: 'timestamptz', nullable: true })
|
||||||
|
endDate?: Date; // Coupon validity end date
|
||||||
|
|
||||||
|
@Property({ type: 'boolean', default: true })
|
||||||
|
isActive: boolean = true;
|
||||||
|
|
||||||
|
@Property({ type: 'json', nullable: true })
|
||||||
|
foodCategories?: string[]; // Array of category IDs
|
||||||
|
|
||||||
|
@Property({ type: 'json', nullable: true })
|
||||||
|
foods?: string[]; // Array of food IDs
|
||||||
|
|
||||||
|
private _userPhone?: string;
|
||||||
|
|
||||||
|
@Property({ nullable: true })
|
||||||
|
get userPhone(): string | undefined {
|
||||||
|
return this._userPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
set userPhone(value: string | undefined) {
|
||||||
|
this._userPhone = value ? normalizePhone(value) : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum CouponType {
|
||||||
|
PERCENTAGE = 'PERCENTAGE',
|
||||||
|
FIXED = 'FIXED',
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { CreateCouponDto } from '../dto/create-coupon.dto';
|
||||||
|
import { UpdateCouponDto } from '../dto/update-coupon.dto';
|
||||||
|
import { FindCouponsDto } from '../dto/find-coupons.dto';
|
||||||
|
import { CouponRepository } from '../repositories/coupon.repository';
|
||||||
|
import { RestRepository } from '../../restaurants/repositories/rest.repository';
|
||||||
|
import { EntityManager } from '@mikro-orm/postgresql';
|
||||||
|
import { RequiredEntityData } from '@mikro-orm/core';
|
||||||
|
import { Coupon } from '../entities/coupon.entity';
|
||||||
|
import { CouponType } from '../interface/coupon';
|
||||||
|
import { CouponMessage, RestMessage } from 'src/common/enums/message.enum';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CouponService {
|
||||||
|
constructor(
|
||||||
|
private readonly couponRepository: CouponRepository,
|
||||||
|
private readonly restRepository: RestRepository,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(restId: string, createCouponDto: CreateCouponDto): Promise<Coupon> {
|
||||||
|
const restaurant = await this.restRepository.findOne({ id: restId });
|
||||||
|
if (!restaurant) {
|
||||||
|
throw new NotFoundException(RestMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if code already exists
|
||||||
|
const existingCoupon = await this.couponRepository.findOne({ code: createCouponDto.code });
|
||||||
|
if (existingCoupon) {
|
||||||
|
throw new BadRequestException(CouponMessage.CODE_ALREADY_EXISTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates
|
||||||
|
if (createCouponDto.startDate && createCouponDto.endDate) {
|
||||||
|
const startDate = new Date(createCouponDto.startDate);
|
||||||
|
const endDate = new Date(createCouponDto.endDate);
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
throw new BadRequestException(CouponMessage.END_DATE_MUST_BE_AFTER_START_DATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate percentage coupon
|
||||||
|
if (createCouponDto.type === CouponType.PERCENTAGE) {
|
||||||
|
if (createCouponDto.value < 0 || createCouponDto.value > 100) {
|
||||||
|
throw new BadRequestException(CouponMessage.PERCENTAGE_MUST_BE_BETWEEN_0_AND_100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: RequiredEntityData<Coupon> = {
|
||||||
|
restaurant,
|
||||||
|
code: createCouponDto.code,
|
||||||
|
name: createCouponDto.name,
|
||||||
|
description: createCouponDto.description,
|
||||||
|
type: createCouponDto.type,
|
||||||
|
value: createCouponDto.value,
|
||||||
|
maxDiscount: createCouponDto.maxDiscount,
|
||||||
|
minOrderAmount: createCouponDto.minOrderAmount,
|
||||||
|
maxUses: createCouponDto.maxUses,
|
||||||
|
maxUsesPerUser: createCouponDto.maxUsesPerUser,
|
||||||
|
startDate: createCouponDto.startDate ? new Date(createCouponDto.startDate) : undefined,
|
||||||
|
endDate: createCouponDto.endDate ? new Date(createCouponDto.endDate) : undefined,
|
||||||
|
isActive: createCouponDto.isActive ?? true,
|
||||||
|
usedCount: 0,
|
||||||
|
foodCategories: createCouponDto.foodCategories,
|
||||||
|
foods: createCouponDto.foods,
|
||||||
|
userPhone: createCouponDto.userPhone,
|
||||||
|
};
|
||||||
|
|
||||||
|
const coupon = this.couponRepository.create(data);
|
||||||
|
if (!coupon) {
|
||||||
|
throw new Error('Failed to create coupon entity');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(coupon);
|
||||||
|
return coupon;
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(restId: string, dto: FindCouponsDto) {
|
||||||
|
return this.couponRepository.findAllPaginated(restId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Coupon> {
|
||||||
|
const coupon = await this.couponRepository.findOne({ id }, { populate: ['restaurant'] });
|
||||||
|
if (!coupon) {
|
||||||
|
throw new NotFoundException(CouponMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return coupon;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string, restId: string): Promise<Coupon> {
|
||||||
|
const coupon = await this.couponRepository.findOne({ code, restaurant: { id: restId } });
|
||||||
|
if (!coupon) {
|
||||||
|
throw new NotFoundException(CouponMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
return coupon;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCouponDto): Promise<Coupon> {
|
||||||
|
const coupon = await this.couponRepository.findOne({ id });
|
||||||
|
if (!coupon) {
|
||||||
|
throw new NotFoundException(CouponMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if code is being changed and if it already exists
|
||||||
|
if (dto.code && dto.code !== coupon.code) {
|
||||||
|
const existingCoupon = await this.couponRepository.findOne({ code: dto.code });
|
||||||
|
if (existingCoupon) {
|
||||||
|
throw new BadRequestException(CouponMessage.CODE_ALREADY_EXISTS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates
|
||||||
|
const startDate = dto.startDate ? new Date(dto.startDate) : coupon.startDate;
|
||||||
|
const endDate = dto.endDate ? new Date(dto.endDate) : coupon.endDate;
|
||||||
|
if (startDate && endDate && endDate <= startDate) {
|
||||||
|
throw new BadRequestException(CouponMessage.END_DATE_MUST_BE_AFTER_START_DATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate percentage coupon
|
||||||
|
if (dto.type === CouponType.PERCENTAGE || coupon.type === CouponType.PERCENTAGE) {
|
||||||
|
const value = dto.value ?? coupon.value;
|
||||||
|
if (value < 0 || value > 100) {
|
||||||
|
throw new BadRequestException(CouponMessage.PERCENTAGE_MUST_BE_BETWEEN_0_AND_100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields
|
||||||
|
if (dto.code) coupon.code = dto.code;
|
||||||
|
if (dto.name) coupon.name = dto.name;
|
||||||
|
if (dto.description !== undefined) coupon.description = dto.description;
|
||||||
|
if (dto.type) coupon.type = dto.type;
|
||||||
|
if (dto.value !== undefined) coupon.value = dto.value;
|
||||||
|
if (dto.maxDiscount !== undefined) coupon.maxDiscount = dto.maxDiscount;
|
||||||
|
if (dto.minOrderAmount !== undefined) coupon.minOrderAmount = dto.minOrderAmount;
|
||||||
|
if (dto.maxUses !== undefined) coupon.maxUses = dto.maxUses;
|
||||||
|
if (dto.maxUsesPerUser !== undefined) coupon.maxUsesPerUser = dto.maxUsesPerUser;
|
||||||
|
if (dto.startDate !== undefined) coupon.startDate = dto.startDate ? new Date(dto.startDate) : undefined;
|
||||||
|
if (dto.endDate !== undefined) coupon.endDate = dto.endDate ? new Date(dto.endDate) : undefined;
|
||||||
|
if (dto.isActive !== undefined) coupon.isActive = dto.isActive;
|
||||||
|
if (dto.foodCategories !== undefined) coupon.foodCategories = dto.foodCategories;
|
||||||
|
if (dto.foods !== undefined) coupon.foods = dto.foods;
|
||||||
|
if (dto.userPhone !== undefined) coupon.userPhone = dto.userPhone;
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(coupon);
|
||||||
|
return coupon;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
const coupon = await this.couponRepository.findOne({ id });
|
||||||
|
if (!coupon) {
|
||||||
|
throw new NotFoundException(CouponMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
coupon.deletedAt = new Date();
|
||||||
|
await this.em.persistAndFlush(coupon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate coupon
|
||||||
|
*/
|
||||||
|
async validateCoupon(
|
||||||
|
code: string,
|
||||||
|
restId: string,
|
||||||
|
orderAmount: number,
|
||||||
|
userPhone: string,
|
||||||
|
): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
coupon?: Coupon;
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
const coupon = await this.couponRepository.findOne(
|
||||||
|
{ code, restaurant: { id: restId } },
|
||||||
|
{ populate: ['restaurant'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: CouponMessage.NOT_FOUND,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.userPhone) {
|
||||||
|
if (userPhone !== coupon.userPhone) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: CouponMessage.COUPON_RESTRICTED_TO_USER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if coupon is active
|
||||||
|
if (!coupon.isActive) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: CouponMessage.COUPON_INACTIVE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check date validity
|
||||||
|
const now = new Date();
|
||||||
|
if (coupon.startDate && now < coupon.startDate) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: CouponMessage.COUPON_NOT_STARTED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.endDate && now > coupon.endDate) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: CouponMessage.COUPON_EXPIRED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max uses
|
||||||
|
if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: CouponMessage.COUPON_MAX_USES_REACHED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum order amount
|
||||||
|
if (coupon.minOrderAmount && orderAmount < coupon.minOrderAmount) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
message: CouponMessage.MIN_ORDER_AMOUNT_NOT_MET,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
coupon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment coupon usage count
|
||||||
|
*/
|
||||||
|
async incrementUsage(id: string): Promise<void> {
|
||||||
|
const coupon = await this.couponRepository.findOne({ id });
|
||||||
|
if (coupon) {
|
||||||
|
coupon.usedCount += 1;
|
||||||
|
await this.em.persistAndFlush(coupon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateShortCode(restaurantSlug: string, length = 5) {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
result += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
const prefix = restaurantSlug.slice(0, 2).toUpperCase();
|
||||||
|
return prefix + '-' + result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateCouponCode(restId: string): Promise<string> {
|
||||||
|
const restaurant = await this.restRepository.findOne({ id: restId });
|
||||||
|
if (!restaurant) {
|
||||||
|
throw new NotFoundException(RestMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
const code = this.generateShortCode(restaurant.slug);
|
||||||
|
const existing = await this.couponRepository.findOne({ code, restaurant: { id: restId } });
|
||||||
|
|
||||||
|
if (existing) return this.generateCouponCode(restId);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||||
|
import { FilterQuery } from '@mikro-orm/core';
|
||||||
|
import { Coupon } from '../entities/coupon.entity';
|
||||||
|
import { CouponType } from '../interface/coupon';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
|
|
||||||
|
type FindCouponsOpts = {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
orderBy?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
type?: CouponType;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CouponRepository extends EntityRepository<Coupon> {
|
||||||
|
constructor(readonly em: EntityManager) {
|
||||||
|
super(em, Coupon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find coupons with pagination and optional filters.
|
||||||
|
* Supports: search (code/name), type, isActive, ordering.
|
||||||
|
*/
|
||||||
|
async findAllPaginated(restId: string, opts: FindCouponsOpts = {}): Promise<PaginatedResult<Coupon>> {
|
||||||
|
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', type, isActive } = opts;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const where: FilterQuery<Coupon> = { restaurant: { id: restId } };
|
||||||
|
|
||||||
|
if (typeof isActive === 'boolean') {
|
||||||
|
where.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
where.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
where.$or = [{ code: { $ilike: pattern } }, { name: { $ilike: pattern } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await this.findAndCount(where, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user