chore: first commit
This commit is contained in:
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
name: Build and Deploy Docker Images
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
DANAK_SERVER: "https://captain.dev.danakcorp.com"
|
||||||
|
APP_TOKEN: 397499a13f10e7633b4a5c8000595f3e439038aa6cd32a713f6dc7847005a6a7
|
||||||
|
APP_NAME: dmail-api
|
||||||
|
GITHUB_TOKEN: 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
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repositorys
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: zmihamid
|
||||||
|
password: ${{ env.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Preset Image Name
|
||||||
|
run: echo "IMAGE_URL=$(echo ghcr.io/zmihamid/${{ github.event.repository.name }}:$(echo ${{ github.sha }} | cut -c1-7) | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build and push Docker Image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.IMAGE_URL }}
|
||||||
|
|
||||||
|
- name: Install CapRover CLI
|
||||||
|
run: npm install -g caprover
|
||||||
|
|
||||||
|
- name: deploy to server
|
||||||
|
run: |
|
||||||
|
caprover deploy -a $APP_NAME -u $DANAK_SERVER --appToken $APP_TOKEN -i "$IMAGE_URL"
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
/not
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
npx --no-install commitlint --edit "$1"
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm dlx lint-staged
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"endOfLine": "auto",
|
||||||
|
"printWidth": 150,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Stage 1: Build Stage
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
RUN npm install -g corepack@latest
|
||||||
|
RUN corepack enable && corepack prepare pnpm --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 4000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# WildDuck Mail Server Gateway
|
||||||
|
|
||||||
|
A comprehensive NestJS gateway for interacting with the [WildDuck Email Server](https://wildduck.email/) HTTP API. This gateway provides a clean, type-safe interface for managing email accounts, messages, mailboxes, and all other WildDuck features.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Complete WildDuck API Coverage**: All major WildDuck API endpoints are implemented
|
||||||
|
- **Type Safety**: Full TypeScript support with comprehensive DTOs and interfaces
|
||||||
|
- **NestJS Best Practices**: Follows enterprise-grade patterns and conventions
|
||||||
|
- **Modular Architecture**: Clean separation of concerns with dedicated services
|
||||||
|
- **Swagger Documentation**: Auto-generated API documentation
|
||||||
|
- **Error Handling**: Robust error handling and logging
|
||||||
|
- **Observables**: RxJS-based reactive programming for better async handling
|
||||||
|
- **Configuration**: Environment-based configuration management
|
||||||
|
|
||||||
|
## API Coverage
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
- Create, read, update, delete users
|
||||||
|
- User authentication and authorization
|
||||||
|
- Password management and reset
|
||||||
|
- User quota management
|
||||||
|
- Authentication logs
|
||||||
|
|
||||||
|
### Email Messages
|
||||||
|
|
||||||
|
- List, search, and filter messages
|
||||||
|
- Send, receive, and manage messages
|
||||||
|
- Message attachments handling
|
||||||
|
- Draft management
|
||||||
|
- Message forwarding
|
||||||
|
- Bulk operations
|
||||||
|
|
||||||
|
### Mailboxes
|
||||||
|
|
||||||
|
- Create and manage mail folders
|
||||||
|
- Mailbox permissions and settings
|
||||||
|
- Message counts and statistics
|
||||||
|
- Mailbox retention policies
|
||||||
|
|
||||||
|
### Email Addresses
|
||||||
|
|
||||||
|
- Manage user email addresses and aliases
|
||||||
|
- Primary address configuration
|
||||||
|
- Address validation
|
||||||
|
|
||||||
|
### Filters and Rules
|
||||||
|
|
||||||
|
- Create email filters and rules
|
||||||
|
- Automatic message processing
|
||||||
|
- Spam filtering
|
||||||
|
- Custom actions and conditions
|
||||||
|
|
||||||
|
### Authentication & Security
|
||||||
|
|
||||||
|
- Two-factor authentication (TOTP)
|
||||||
|
- Application-specific passwords
|
||||||
|
- Session management
|
||||||
|
- Security logging
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
|
||||||
|
- Auto-replies and out-of-office messages
|
||||||
|
- Message archiving and restoration
|
||||||
|
- Audit trails and logging
|
||||||
|
- Webhook integrations
|
||||||
|
|
||||||
|
## Installation & Setup
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
This gateway is already integrated into your existing NestJS project. The required dependencies are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @nestjs/axios axios rxjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Configuration
|
||||||
|
|
||||||
|
Add the following environment variables to your `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# WildDuck Mail Server Configuration
|
||||||
|
WILDDUCK_BASE_URL=http://localhost:8080
|
||||||
|
WILDDUCK_ACCESS_TOKEN=your-access-token-here
|
||||||
|
WILDDUCK_TIMEOUT=30000
|
||||||
|
WILDDUCK_RETRIES=3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Module Import
|
||||||
|
|
||||||
|
The `MailServerModule` is already imported in your `AppModule`. The gateway is ready to use!
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Basic User Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MailServerService } from "./modules/mail-server/services/mail-server.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailService {
|
||||||
|
constructor(private readonly mailServer: MailServerService) {}
|
||||||
|
|
||||||
|
// Create a complete email account
|
||||||
|
async createEmailAccount(username: string, password: string, email: string) {
|
||||||
|
return this.mailServer
|
||||||
|
.createEmailAccount({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
name: "User Name",
|
||||||
|
quota: 1073741824, // 1GB
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate user and get profile
|
||||||
|
async authenticateUser(username: string, password: string) {
|
||||||
|
return this.mailServer.authenticateAndGetUser(username, password).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's mailboxes with message counts
|
||||||
|
async getUserMailboxes(userId: string) {
|
||||||
|
return this.mailServer.getUserMailboxes(userId).toPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Message Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Search messages across all folders
|
||||||
|
async searchUserMessages(userId: string, query: string) {
|
||||||
|
return this.mailServer.searchAllMessages(userId, query, 50).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a message
|
||||||
|
async sendMessage(userId: string, mailboxId: string, rawMessage: string) {
|
||||||
|
return this.mailServer.sendMessage(userId, mailboxId, {
|
||||||
|
raw: rawMessage,
|
||||||
|
seen: false,
|
||||||
|
flagged: false
|
||||||
|
}).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent messages
|
||||||
|
async getRecentMessages(userId: string, mailboxId: string) {
|
||||||
|
return this.mailServer.getRecentMessages(userId, mailboxId, 20).toPromise();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direct Service Access
|
||||||
|
|
||||||
|
For more granular control, you can access individual services directly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Injectable()
|
||||||
|
export class DetailedEmailService {
|
||||||
|
constructor(private readonly mailServer: MailServerService) {}
|
||||||
|
|
||||||
|
// Direct access to specific services
|
||||||
|
async createUser(userData: CreateUserDto) {
|
||||||
|
return this.mailServer.users.createUser(userData).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMessages(userId: string, mailboxId: string, options: ListMessagesQueryDto) {
|
||||||
|
return this.mailServer.messages.listMessages(userId, mailboxId, options).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFilter(userId: string, filterData: CreateFilterDto) {
|
||||||
|
return this.mailServer.filters.createFilter(userId, filterData).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
async setup2FA(userId: string, issuer: string) {
|
||||||
|
return this.mailServer.auth.setup2FA(userId, { issuer }).toPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The gateway exposes REST endpoints under `/mail-server`:
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
|
||||||
|
- `GET /mail-server/users` - List users
|
||||||
|
- `POST /mail-server/users` - Create user
|
||||||
|
- `GET /mail-server/users/:userId` - Get user details
|
||||||
|
- `PUT /mail-server/users/:userId` - Update user
|
||||||
|
- `DELETE /mail-server/users/:userId` - Delete user
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- `POST /mail-server/authenticate` - Authenticate user
|
||||||
|
- `POST /mail-server/users/:userId/2fa/totp/setup` - Setup 2FA
|
||||||
|
- `POST /mail-server/users/:userId/2fa/totp/enable` - Enable 2FA
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
- `GET /mail-server/users/:userId/mailboxes/:mailboxId/messages` - List messages
|
||||||
|
- `POST /mail-server/users/:userId/mailboxes/:mailboxId/messages` - Upload message
|
||||||
|
- `GET /mail-server/users/:userId/search` - Search messages
|
||||||
|
- `GET /mail-server/users/:userId/flagged` - List flagged messages
|
||||||
|
|
||||||
|
### Mailboxes
|
||||||
|
|
||||||
|
- `GET /mail-server/users/:userId/mailboxes` - List mailboxes
|
||||||
|
- `POST /mail-server/users/:userId/mailboxes` - Create mailbox
|
||||||
|
- `PUT /mail-server/users/:userId/mailboxes/:mailboxId` - Update mailbox
|
||||||
|
|
||||||
|
### High-Level Operations
|
||||||
|
|
||||||
|
- `POST /mail-server/accounts` - Create complete email account
|
||||||
|
- `POST /mail-server/auth-and-profile` - Authenticate and get profile
|
||||||
|
- `GET /mail-server/users/:userId/mailboxes-with-counts` - Get mailboxes with counts
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The gateway follows a clean, modular architecture:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/modules/mail-server/
|
||||||
|
├── dto/ # Data Transfer Objects
|
||||||
|
│ ├── create-user.dto.ts
|
||||||
|
│ ├── update-user.dto.ts
|
||||||
|
│ ├── authenticate.dto.ts
|
||||||
|
│ ├── message.dto.ts
|
||||||
|
│ ├── mailbox.dto.ts
|
||||||
|
│ ├── address.dto.ts
|
||||||
|
│ └── filter.dto.ts
|
||||||
|
├── interfaces/ # TypeScript interfaces
|
||||||
|
│ └── wildduck-response.interface.ts
|
||||||
|
├── services/ # Service layer
|
||||||
|
│ ├── wildduck-base.service.ts # Base HTTP service
|
||||||
|
│ ├── wildduck-users.service.ts # User management
|
||||||
|
│ ├── wildduck-messages.service.ts # Message operations
|
||||||
|
│ ├── wildduck-mailboxes.service.ts # Mailbox management
|
||||||
|
│ ├── wildduck-addresses.service.ts # Address management
|
||||||
|
│ ├── wildduck-auth.service.ts # Authentication
|
||||||
|
│ ├── wildduck-filters.service.ts # Filter management
|
||||||
|
│ └── mail-server.service.ts # Main orchestrator
|
||||||
|
├── mail-server.controller.ts # REST endpoints
|
||||||
|
└── mail-server.module.ts # Module definition
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Hierarchy
|
||||||
|
|
||||||
|
1. **WildDuckBaseService**: Provides common HTTP client functionality, error handling, and response processing
|
||||||
|
2. **Specialized Services**: Each service handles a specific domain (users, messages, etc.)
|
||||||
|
3. **MailServerService**: High-level service that orchestrates operations across multiple specialized services
|
||||||
|
4. **MailServerController**: REST API endpoints for external access
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The gateway includes comprehensive error handling:
|
||||||
|
|
||||||
|
- **HTTP Errors**: Automatic retry logic and timeout handling
|
||||||
|
- **WildDuck API Errors**: Proper error parsing and re-throwing
|
||||||
|
- **Validation Errors**: DTO validation with class-validator
|
||||||
|
- **Logging**: Detailed logging for debugging and monitoring
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use High-Level Methods**: Prefer `MailServerService` methods for common operations
|
||||||
|
2. **Handle Observables**: Convert to Promises with `.toPromise()` if needed
|
||||||
|
3. **Error Handling**: Always wrap calls in try-catch blocks
|
||||||
|
4. **Type Safety**: Use the provided DTOs for type safety
|
||||||
|
5. **Environment Variables**: Keep WildDuck credentials secure
|
||||||
|
|
||||||
|
## Swagger Documentation
|
||||||
|
|
||||||
|
The gateway automatically generates Swagger documentation. Access it at:
|
||||||
|
|
||||||
|
- Development: `http://localhost:3000/api`
|
||||||
|
- Production: `https://your-domain.com/api`
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Follow the existing code patterns and architecture
|
||||||
|
2. Add proper TypeScript types and DTOs
|
||||||
|
3. Include comprehensive error handling
|
||||||
|
4. Add Swagger documentation for new endpoints
|
||||||
|
5. Write tests for new functionality
|
||||||
|
|
||||||
|
## WildDuck Server Setup
|
||||||
|
|
||||||
|
This gateway requires a running WildDuck mail server. For setup instructions, visit:
|
||||||
|
|
||||||
|
- [WildDuck Documentation](https://wildduck.email/)
|
||||||
|
- [GitHub Repository](https://github.com/nodemailer/wildduck)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project follows the same license as your main application.
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
rules: {
|
||||||
|
"subject-case": [2, "always", ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case", "camel-case"]],
|
||||||
|
"type-enum": [2, "always", ["add", "build", "chore", "update", "docs", "feat", "fix", "refactor", "revert", "style", "test", "sample"]],
|
||||||
|
"scope-enum": [1, "always", ["common", "core", "app", "testing", "bug"]],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Options } from "@mikro-orm/core";
|
||||||
|
import { PostgreSqlDriver } from "@mikro-orm/postgresql";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const config: Options = {
|
||||||
|
driver: PostgreSqlDriver,
|
||||||
|
dbName: process.env.DB_NAME,
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
entities: ["./dist/**/*.entity.js"],
|
||||||
|
entitiesTs: ["./src/**/*.entity.ts"],
|
||||||
|
debug: process.env.NODE_ENV !== "production",
|
||||||
|
migrations: {
|
||||||
|
path: "./database/migrations",
|
||||||
|
pathTs: "./database/migrations",
|
||||||
|
tableName: "migrations",
|
||||||
|
transactional: true,
|
||||||
|
allOrNothing: true,
|
||||||
|
dropTables: false,
|
||||||
|
safe: true,
|
||||||
|
emit: "ts",
|
||||||
|
},
|
||||||
|
seeder: {
|
||||||
|
path: "./database/seeders",
|
||||||
|
pathTs: "./database/seeders",
|
||||||
|
defaultSeeder: "IndexSeeder",
|
||||||
|
glob: "!(*.d).{js,ts}",
|
||||||
|
emit: "ts",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
|
import { Seeder } from "@mikro-orm/seeder";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
// import { PaymentGatewaySeeder } from "./payment-gateway.seeder";
|
||||||
|
import { RoleSeeder } from "./role.seeder";
|
||||||
|
|
||||||
|
export class IndexSeeder extends Seeder {
|
||||||
|
private readonly logger = new Logger(IndexSeeder.name);
|
||||||
|
|
||||||
|
async run(em: EntityManager): Promise<void> {
|
||||||
|
this.logger.log("Starting seeding process...");
|
||||||
|
await new RoleSeeder().run(em);
|
||||||
|
// await new PaymentGatewaySeeder().run(em);
|
||||||
|
this.logger.log("Seeding process completed successfully.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// import { EntityManager, RequiredEntityData } from "@mikro-orm/postgresql";
|
||||||
|
// import { Seeder } from "@mikro-orm/seeder";
|
||||||
|
// import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
// import { PaymentGateway } from "../../src/modules/payments/entities/payment-gateway.entity";
|
||||||
|
// import { GatewayEnum } from "../../src/modules/payments/enums/gateway.enum";
|
||||||
|
|
||||||
|
// export class PaymentGatewaySeeder extends Seeder {
|
||||||
|
// private readonly logger = new Logger(PaymentGatewaySeeder.name);
|
||||||
|
|
||||||
|
// async run(em: EntityManager): Promise<void> {
|
||||||
|
// const paymentGateways: RequiredEntityData<PaymentGateway>[] = [
|
||||||
|
// {
|
||||||
|
// name: GatewayEnum.ZARINPAL,
|
||||||
|
// nameFa: "زرین پال",
|
||||||
|
// logoUrl: "https://storage.danakcorp.com/logo/fa-dark.png",
|
||||||
|
// description: "درگاه پرداخت زرین پال",
|
||||||
|
// },
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// for (const paymentGateway of paymentGateways) {
|
||||||
|
// const existingPaymentGateway = await em.findOne(PaymentGateway, { name: paymentGateway.name });
|
||||||
|
|
||||||
|
// if (!existingPaymentGateway) {
|
||||||
|
// const createdPaymentGateway = em.create(PaymentGateway, paymentGateway);
|
||||||
|
|
||||||
|
// await em.persistAndFlush(createdPaymentGateway);
|
||||||
|
// this.logger.log(`[PaymentGatewaySeeder] Created payment gateway: ${createdPaymentGateway.name}`);
|
||||||
|
// } else {
|
||||||
|
// this.logger.log(`[PaymentGatewaySeeder] Payment gateway already exists: ${paymentGateway.name}`);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { EntityManager, RequiredEntityData } from "@mikro-orm/postgresql";
|
||||||
|
import { Seeder } from "@mikro-orm/seeder";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { Role } from "../../src/modules/users/entities/role.entity";
|
||||||
|
import { RoleEnum } from "../../src/modules/users/enums/role.enum";
|
||||||
|
|
||||||
|
export class RoleSeeder extends Seeder {
|
||||||
|
private readonly logger = new Logger(RoleSeeder.name);
|
||||||
|
|
||||||
|
async run(em: EntityManager): Promise<void> {
|
||||||
|
const roles: RequiredEntityData<Role>[] = [
|
||||||
|
{
|
||||||
|
name: RoleEnum.ADMIN,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: RoleEnum.USER,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const roleData of roles) {
|
||||||
|
const existingRole = await em.findOne(Role, { name: roleData.name });
|
||||||
|
if (!existingRole) {
|
||||||
|
const role = em.create(Role, roleData);
|
||||||
|
await em.persistAndFlush(role);
|
||||||
|
this.logger.log(`[RoleSeeder] Created role: ${roleData.name}`);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[RoleSeeder] Role already exists: ${roleData.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import eslint from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import eslintPluginPrettier from "eslint-plugin-prettier";
|
||||||
|
import eslintPluginImport from "eslint-plugin-import";
|
||||||
|
import globals from "globals";
|
||||||
|
import prettierConfig from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"dist/**",
|
||||||
|
"test-email/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"jest.config.js",
|
||||||
|
"coverage/**",
|
||||||
|
"database/migrations/**",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
".prettierrc.js",
|
||||||
|
"commitlint.config.ts",
|
||||||
|
"eslint.config.mjs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
prettierConfig,
|
||||||
|
// Base configuration for all TypeScript files
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
parser: tseslint.parser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tseslint.plugin,
|
||||||
|
prettier: eslintPluginPrettier,
|
||||||
|
import: eslintPluginImport,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Import rules
|
||||||
|
"sort-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
ignoreCase: false,
|
||||||
|
ignoreDeclarationSort: true,
|
||||||
|
ignoreMemberSort: false,
|
||||||
|
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
|
||||||
|
allowSeparatedGroups: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"import/order": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
groups: ["builtin", "external", "internal", ["sibling", "parent"], "index", "unknown"],
|
||||||
|
"newlines-between": "always",
|
||||||
|
alphabetize: {
|
||||||
|
order: "asc",
|
||||||
|
caseInsensitive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// JavaScript Rules
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"no-multi-spaces": "error",
|
||||||
|
"space-in-parens": "error",
|
||||||
|
"no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }],
|
||||||
|
semi: ["error", "always"],
|
||||||
|
|
||||||
|
// Best Practices
|
||||||
|
"no-async-promise-executor": "warn",
|
||||||
|
"no-control-regex": "warn",
|
||||||
|
"no-empty": ["warn", { allowEmptyCatch: true }],
|
||||||
|
"no-extra-semi": "warn",
|
||||||
|
"no-prototype-builtins": "warn",
|
||||||
|
"no-regex-spaces": "warn",
|
||||||
|
"prefer-rest-params": "warn",
|
||||||
|
"prefer-spread": "warn",
|
||||||
|
|
||||||
|
// TypeScript-Specific Rules that don't need type information
|
||||||
|
"@typescript-eslint/ban-ts-comment": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-loss-of-precision": "warn",
|
||||||
|
"@typescript-eslint/no-misused-new": "warn",
|
||||||
|
"@typescript-eslint/no-namespace": "warn",
|
||||||
|
"@typescript-eslint/no-this-alias": "warn",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
|
||||||
|
"@typescript-eslint/no-unsafe-declaration-merging": "warn",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-var-requires": "warn",
|
||||||
|
"@typescript-eslint/triple-slash-reference": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Additional configuration for type-checked files - disabled for now
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
// Disable type-aware linting until we can configure it properly
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/await-thenable": "off",
|
||||||
|
"@typescript-eslint/no-floating-promises": "off",
|
||||||
|
"@typescript-eslint/no-misused-promises": "off",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-call": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-return": "off",
|
||||||
|
"@typescript-eslint/require-await": "off",
|
||||||
|
"@typescript-eslint/restrict-plus-operands": "off",
|
||||||
|
"@typescript-eslint/restrict-template-expressions": "off",
|
||||||
|
"@typescript-eslint/unbound-method": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm/oCsqFA8hF9TMv4UX5E
|
||||||
|
IeDv1KXzI+KFIafZ+gU1IWgEmWP16ee8Vw1+j7Ixl9Dd7Uxc9DhcWRB6ZDwqCYb3
|
||||||
|
S3z5zabOIka7wNNBpTEmRKrmV/EzihAw+E96Ksct0PHeN2YFydPimMndBD6VoRWi
|
||||||
|
WShQD4FklIQKqBYrua0NkuIkFshYLzC3rAbcT8GkLkcbHKrKIlyeucCLwym2nd5U
|
||||||
|
coI+fKqDB1/3JYEKJRaZgFqthokeFBN4SV9tMaLzeJDk3bNoTAxRtPbtuJ8mVTL9
|
||||||
|
P0phzjorGxTxV7SbANtmB3Trm6oIXeCqY40Auy+ZTUEVr+hD4Xxs8TMvjuT8vrMe
|
||||||
|
VQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"name": "dmail-api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start:nest": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start": "NODE_ENV=production node --trace-warnings dist/main",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --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",
|
||||||
|
"prepare": "husky || true",
|
||||||
|
"orm:create": "mikro-orm migration:create --config=database/mikro-orm.config.ts",
|
||||||
|
"orm:up": "mikro-orm migration:up --config=database/mikro-orm.config.ts",
|
||||||
|
"orm:down": "mikro-orm migration:down --config=database/mikro-orm.config.ts",
|
||||||
|
"orm:generate": "mikro-orm migration:create --blank --config=database/mikro-orm.config.ts",
|
||||||
|
"orm:update": "mikro-orm schema:update --run --config=database/mikro-orm.config.ts",
|
||||||
|
"orm:drop": "mikro-orm schema:drop --run --drop-migrations-table --drop-db --config=database/mikro-orm.config.ts",
|
||||||
|
"orm:seed": "mikro-orm seeder:create --config=database/mikro-orm.config.ts",
|
||||||
|
"seed:run": "mikro-orm seeder:run --config=database/mikro-orm.config.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/static": "^8.2.0",
|
||||||
|
"@keyv/redis": "^4.4.0",
|
||||||
|
"@mikro-orm/cli": "^6.4.16",
|
||||||
|
"@mikro-orm/core": "^6.4.16",
|
||||||
|
"@mikro-orm/nestjs": "^6.1.1",
|
||||||
|
"@mikro-orm/postgresql": "^6.4.16",
|
||||||
|
"@mikro-orm/seeder": "^6.4.16",
|
||||||
|
"@nest-lab/fastify-multer": "^1.3.0",
|
||||||
|
"@nest-lab/throttler-storage-redis": "^1.1.0",
|
||||||
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
|
"@nestjs/axios": "^4.0.0",
|
||||||
|
"@nestjs/bullmq": "^11.0.2",
|
||||||
|
"@nestjs/cache-manager": "^3.0.1",
|
||||||
|
"@nestjs/cli": "^11.0.7",
|
||||||
|
"@nestjs/common": "^11.1.3",
|
||||||
|
"@nestjs/config": "^4.0.2",
|
||||||
|
"@nestjs/core": "^11.1.3",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-fastify": "^11.1.3",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
|
"@nestjs/throttler": "^6.4.0",
|
||||||
|
"axios": "^1.10.0",
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"bullmq": "^5.54.1",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.2",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"decimal.js": "^10.5.0",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"fastify": "^5.4.0",
|
||||||
|
"nodemailer": "^7.0.3",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
|
"slugify": "^1.6.6",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^19.8.1",
|
||||||
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@eslint/js": "^9.29.0",
|
||||||
|
"@nestjs/cli": "^11.0.7",
|
||||||
|
"@nestjs/schematics": "^11.0.5",
|
||||||
|
"@nestjs/testing": "^11.1.3",
|
||||||
|
"@swc/cli": "^0.6.0",
|
||||||
|
"@swc/core": "^1.12.1",
|
||||||
|
"@types/bcrypt": "^5.0.2",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^22.15.32",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.34.1",
|
||||||
|
"@typescript-eslint/parser": "^8.34.1",
|
||||||
|
"eslint": "^9.29.0",
|
||||||
|
"eslint-config-prettier": "^10.1.5",
|
||||||
|
"eslint-import-resolver-typescript": "^3.10.1",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
|
"eslint-plugin-prettier": "^5.5.0",
|
||||||
|
"globals": "^16.2.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"lint-staged": "^15.5.2",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.1.1",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"typescript-eslint": "^8.34.1"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"**/*.ts": [
|
||||||
|
"pnpm format",
|
||||||
|
"pnpm lint:fix",
|
||||||
|
"git add ."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||||
|
}
|
||||||
Generated
+12478
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
|
import { CacheModule } from "@nestjs/cache-manager";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { ThrottlerModule } from "@nestjs/throttler";
|
||||||
|
|
||||||
|
import { bullMqConfig } from "./configs/bullmq.config";
|
||||||
|
import { cacheConfig } from "./configs/cache.config";
|
||||||
|
import { httpConfig } from "./configs/http.config";
|
||||||
|
import { databaseConfig } from "./configs/mikro-orm.config";
|
||||||
|
import { rateLimitConfig } from "./configs/rateLimit.config";
|
||||||
|
import { AuthModule } from "./modules/auth/auth.module";
|
||||||
|
import { DomainsModule } from "./modules/domains/domains.module";
|
||||||
|
import { MailServerModule } from "./modules/mail-server/mail-server.module";
|
||||||
|
import { UsersModule } from "./modules/users/users.module";
|
||||||
|
import { UtilsModule } from "./modules/utils/utils.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, cache: true }),
|
||||||
|
BullModule.forRootAsync(bullMqConfig()),
|
||||||
|
CacheModule.registerAsync(cacheConfig()),
|
||||||
|
HttpModule.registerAsync(httpConfig()),
|
||||||
|
ThrottlerModule.forRootAsync(rateLimitConfig()),
|
||||||
|
MikroOrmModule.forRootAsync(databaseConfig),
|
||||||
|
UtilsModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
DomainsModule,
|
||||||
|
MailServerModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsNotEmpty, IsNumber, IsOptional, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class PaginationDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ type: "number", required: false })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@Max(50)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiPropertyOptional({ type: "number", required: false })
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { ApiProperty, PartialType } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
import { CommonMessage } from "../enums/message.enum";
|
||||||
|
|
||||||
|
export class ParamDto {
|
||||||
|
@IsNotEmpty({ message: CommonMessage.ID_REQUIRED })
|
||||||
|
@IsUUID("7", { message: CommonMessage.ID_SHOULD_BE_UUID })
|
||||||
|
@ApiProperty({ description: "Id of the entity", example: "8b1e8b1e-8b1e-8b1e-8b1e-8b1e8b1e8b1e" })
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OptionalParamDto extends PartialType(ParamDto) {}
|
||||||
|
|
||||||
|
export class ParamNumberIdDto {
|
||||||
|
@IsNotEmpty({ message: CommonMessage.ID_REQUIRED })
|
||||||
|
@ApiProperty({ description: "Id of the entity", example: "20" })
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
// export const AUTH_THROTTLE = "AUTH_THROTTLE";
|
||||||
|
export const AUTH_THROTTLE_TTL = 5 * 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 DANAK_IMAPS_SERVER = "imaps.danakcorp.com";
|
||||||
|
export const DANAK_SMTPS_SERVER = "smtps.danakcorp.com";
|
||||||
|
export const DANAK_IMAPS_PORT = 993;
|
||||||
|
export const DANAK_SMTPS_PORT = 465;
|
||||||
|
export const DANAK_IMAPS_ENCRYPTION = "TLS";
|
||||||
|
export const DANAK_SMTPS_ENCRYPTION = "TLS";
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
export const ADMIN_ROUTE = "shouldAdmin";
|
||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
export const AdminRoute = (isAdminRoute: boolean = true) => SetMetadata(ADMIN_ROUTE, isAdminRoute);
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
import { UseGuards, applyDecorators } from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { JwtAuthGuard } from "../../modules/auth/guards/auth.guard";
|
||||||
|
|
||||||
|
export function AuthGuards() {
|
||||||
|
return applyDecorators(UseGuards(JwtAuthGuard), ApiBearerAuth("authorization"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||||
|
import { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the business ID from the request header
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* @Get()
|
||||||
|
* findAll(@BusinessIdHeader() businessId: string) {
|
||||||
|
* // Use businessId
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const BusinessIdHeader = createParamDecorator((headerName: string = "X-Business-Id", ctx: ExecutionContext) => {
|
||||||
|
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
return req.headers[headerName.toLocaleLowerCase()];
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||||
|
import { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
import { Business } from "../../modules/businesses/entities/business.entity";
|
||||||
|
|
||||||
|
export const BusinessDec = createParamDecorator((data: keyof Business | undefined, ctx: ExecutionContext) => {
|
||||||
|
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const business = req.business;
|
||||||
|
|
||||||
|
return data ? business?.[data] : business;
|
||||||
|
});
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, registerDecorator } from "class-validator";
|
||||||
|
|
||||||
|
export function isValidIranianNationalCode(nationalCode: string): boolean {
|
||||||
|
if (!/^\d{10}$/.test(nationalCode)) return false;
|
||||||
|
|
||||||
|
const digits = nationalCode.split("").map(Number);
|
||||||
|
const controlDigit = digits[9];
|
||||||
|
const sum = digits.slice(0, 9).reduce((acc, digit, index) => acc + digit * (10 - index), 0);
|
||||||
|
const remainder = sum % 11;
|
||||||
|
|
||||||
|
return (remainder < 2 && controlDigit === remainder) || (remainder >= 2 && controlDigit === 11 - remainder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ValidatorConstraint({ async: false })
|
||||||
|
export class IsNationalCodeConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown, _args: ValidationArguments) {
|
||||||
|
return typeof value === "string" && isValidIranianNationalCode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(_args: ValidationArguments) {
|
||||||
|
return `Invalid Iranian national code :${_args.value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsNationalCode(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName: propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: IsNationalCodeConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
import { applyDecorators } from "@nestjs/common";
|
||||||
|
import { ApiQuery } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export function Pagination() {
|
||||||
|
return applyDecorators(
|
||||||
|
ApiQuery({ name: "page", example: 1, required: false, type: "number" }),
|
||||||
|
ApiQuery({ name: "limit", example: 10, required: false, type: "number" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
// import { PermissionEnum } from "../../modules/users/enums/permission.enum";
|
||||||
|
|
||||||
|
// export const PERMISSION_KEY = "permissions";
|
||||||
|
// export const PermissionsDec = (...permissions: PermissionEnum[]) => SetMetadata(PERMISSION_KEY, permissions);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { UseGuards, applyDecorators } from "@nestjs/common";
|
||||||
|
import { Throttle, ThrottlerGuard } from "@nestjs/throttler";
|
||||||
|
|
||||||
|
import { 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(5, 60000); // 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
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
// import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
// export const ROLES_KEY = "roles";
|
||||||
|
// export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
import { SetMetadata } from "@nestjs/common";
|
||||||
|
|
||||||
|
export const SKIP_AUTH_KEY = "skipAuth";
|
||||||
|
|
||||||
|
export const SkipAuth = () => SetMetadata(SKIP_AUTH_KEY, true);
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||||
|
import { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
import { ITokenPayload } from "../../modules/auth/interfaces/IToken-payload";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
user?: ITokenPayload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserDec = createParamDecorator((data: keyof ITokenPayload | undefined, ctx: ExecutionContext) => {
|
||||||
|
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
});
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import { Opt, PrimaryKey, Property, sql } from "@mikro-orm/core";
|
||||||
|
import { v7 } from "uuid";
|
||||||
|
|
||||||
|
export abstract class BaseEntity {
|
||||||
|
@PrimaryKey({ type: "uuid" })
|
||||||
|
id = v7();
|
||||||
|
|
||||||
|
@Property({ default: sql.now(), type: "timestamptz" })
|
||||||
|
createdAt: Date & Opt;
|
||||||
|
|
||||||
|
@Property({ default: sql.now(), type: "timestamptz" })
|
||||||
|
updatedAt: Date & Opt;
|
||||||
|
|
||||||
|
@Property({ nullable: true })
|
||||||
|
deletedAt?: Date;
|
||||||
|
}
|
||||||
Executable
+222
@@ -0,0 +1,222 @@
|
|||||||
|
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 = "کاربری با این شماره وجود ندارد",
|
||||||
|
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 = "حساب کاربری شما غیر فعال است",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum UserMessage {
|
||||||
|
USER_NOT_FOUND = "کاربری با این شماره وجود ندارد",
|
||||||
|
USER_EXISTS = "با این شماره قبلا ثبت نام شده است",
|
||||||
|
USER_REGISTER_SUCCESS = "ثبت نام موفقیت آمیز بود",
|
||||||
|
INVALID_USER = "کاربری با این مشخصات یافت نشد",
|
||||||
|
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 = "خطا در به روز رسانی حجم ایمیل",
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "فایل معتبر نیست",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum EmailMessage {
|
||||||
|
EMAIL_VERIFICATION = "تایید ایمیل",
|
||||||
|
EMAIL_SENDING_FAILED = "ارسال ایمیل با خطا مواجه شد",
|
||||||
|
LOGIN = "ورود به سیستم",
|
||||||
|
INVOICE = "فاکتور",
|
||||||
|
WALLET = "اعلان کیف پول",
|
||||||
|
TICKET = "تیکت پشتیبانی",
|
||||||
|
ANNOUNCEMENT = "اعلان",
|
||||||
|
PAYMENT_REMINDER = "یادآوری پرداخت",
|
||||||
|
PAYMENT_CANCELLATION = "لغو پرداخت",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum SmsMessage {
|
||||||
|
SEND_SMS_ERROR = "خطا در ارسال پیامک",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum BusinessMessage {
|
||||||
|
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 = "توکن تایید دامنه مورد نیاز است",
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = "این کسب و کار قبلا دامنه ای دارد",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum MailServerMessage {
|
||||||
|
FAILED_TO_CREATE_ACCOUNT = "خطا در ایجاد ایمیل",
|
||||||
|
FAILED_TO_CREATE_DKIM_KEY = "FAILED_TO_CREATE_DKIM_KEY",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum DnsRecordMessage {
|
||||||
|
DNS_RECORD_NOT_FOUND = "رکورد DNS یافت نشد",
|
||||||
|
DNS_RECORD_DELETED = "رکورد DNS با موفقیت حذف شد",
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface ErrorResponse {
|
||||||
|
statusCode: number;
|
||||||
|
success: boolean;
|
||||||
|
error: {
|
||||||
|
message: string | string[];
|
||||||
|
timestamp?: string;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
export interface IPageFormat {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
prevPage: string | boolean;
|
||||||
|
nextPage: string | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationQuery {
|
||||||
|
page?: string;
|
||||||
|
limit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse {
|
||||||
|
paginate?: boolean;
|
||||||
|
count?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ValueProvider } from "@nestjs/common";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
|
||||||
|
export const MIKRO_ORM_QUERY_LOGGER = "MikroOrmQueryLogger";
|
||||||
|
|
||||||
|
export const MikroOrmQueryLogger: ValueProvider = {
|
||||||
|
provide: MIKRO_ORM_QUERY_LOGGER,
|
||||||
|
useValue: new Logger("mikro-orm"),
|
||||||
|
};
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { OnWorkerEvent, WorkerHost } from "@nestjs/bullmq";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { Job } from "bullmq";
|
||||||
|
|
||||||
|
export abstract class WorkerProcessor extends WorkerHost {
|
||||||
|
protected readonly logger = new Logger(WorkerProcessor.name);
|
||||||
|
|
||||||
|
@OnWorkerEvent("completed")
|
||||||
|
onCompleted(job: Job) {
|
||||||
|
const { id, name, queueName, finishedOn, returnvalue } = job;
|
||||||
|
const completionTime = finishedOn ? new Date(finishedOn).toISOString() : "";
|
||||||
|
this.logger.log(`Job id: ${id}, name: ${name} completed in queue ${queueName} on ${completionTime}. Result: ${returnvalue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent("progress")
|
||||||
|
onProgress(job: Job) {
|
||||||
|
const { id, name, progress } = job;
|
||||||
|
this.logger.log(`Job id: ${id}, name: ${name} completes ${progress}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent("failed")
|
||||||
|
onFailed(job: Job) {
|
||||||
|
const { id, name, queueName, failedReason } = job;
|
||||||
|
this.logger.error(`Job id: ${id}, name: ${name} failed in queue ${queueName}. Failed reason: ${failedReason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWorkerEvent("active")
|
||||||
|
onActive(job: Job) {
|
||||||
|
const { id, name, queueName, timestamp } = job;
|
||||||
|
const startTime = timestamp ? new Date(timestamp).toISOString() : "";
|
||||||
|
this.logger.log(`Job id: ${id}, name: ${name} starts in queue ${queueName} on ${startTime}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { SharedBullAsyncConfiguration } from "@nestjs/bullmq";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export function bullMqConfig(): SharedBullAsyncConfiguration {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: async (configService: ConfigService) => ({
|
||||||
|
connection: {
|
||||||
|
url: configService.getOrThrow<string>("REDIS_URI"),
|
||||||
|
},
|
||||||
|
prefix: "dmail",
|
||||||
|
defaultJobOptions: {
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: false,
|
||||||
|
attempts: 5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import KeyvRedis from "@keyv/redis";
|
||||||
|
import { CacheModuleAsyncOptions } from "@nestjs/cache-manager";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export function cacheConfig(): CacheModuleAsyncOptions {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
isGlobal: true,
|
||||||
|
useFactory: async (configService: ConfigService) => {
|
||||||
|
return {
|
||||||
|
ttl: configService.getOrThrow<string>("CACHE_TTL"),
|
||||||
|
stores: [new KeyvRedis(configService.getOrThrow<string>("REDIS_URI"), { namespace: "dmail:" })],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import { HttpModuleAsyncOptions } from "@nestjs/axios";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export function httpConfig(): HttpModuleAsyncOptions {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
global: true,
|
||||||
|
useFactory: async (configService: ConfigService) => {
|
||||||
|
return {
|
||||||
|
timeout: configService.getOrThrow<number>("AXIOS_TIMEOUT"),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
global: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { JwtModuleAsyncOptions } from "@nestjs/jwt";
|
||||||
|
|
||||||
|
export function jwtConfig(): JwtModuleAsyncOptions {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => {
|
||||||
|
return {
|
||||||
|
secret: configService.getOrThrow<string>("JWT_SECRET"),
|
||||||
|
signOptions: {
|
||||||
|
issuer: configService.getOrThrow<string>("JWT_ISSUER"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
import { MikroOrmModuleAsyncOptions } from "@mikro-orm/nestjs";
|
||||||
|
import { PostgreSqlDriver } from "@mikro-orm/postgresql";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
import { MIKRO_ORM_QUERY_LOGGER, MikroOrmQueryLogger } from "../common/providers/mikro-orm-logger";
|
||||||
|
|
||||||
|
export const databaseConfig: MikroOrmModuleAsyncOptions = {
|
||||||
|
inject: [ConfigService, MIKRO_ORM_QUERY_LOGGER],
|
||||||
|
providers: [MikroOrmQueryLogger],
|
||||||
|
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 encodedPassword = encodeURIComponent(DB_PASS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
driver: PostgreSqlDriver,
|
||||||
|
autoLoadEntities: true,
|
||||||
|
dbName: configService.getOrThrow<string>("DB_NAME"),
|
||||||
|
debug: configService.get<string>("NODE_ENV") !== "production",
|
||||||
|
clientUrl: `postgres://${DB_USER}:${encodedPassword}@${DB_HOST}:${DB_PORT}`,
|
||||||
|
ensureDatabase: { forceCheck: true, create: true, schema: "update" },
|
||||||
|
forceUtcTimezone: true,
|
||||||
|
pool: {
|
||||||
|
min: 2,
|
||||||
|
max: 15,
|
||||||
|
idleTimeoutMillis: 10000,
|
||||||
|
acquireTimeoutMillis: 10000,
|
||||||
|
reapIntervalMillis: 1000,
|
||||||
|
createTimeoutMillis: 3000,
|
||||||
|
destroyTimeoutMillis: 5000,
|
||||||
|
},
|
||||||
|
logger: (message) => logger.debug(message),
|
||||||
|
schemaGenerator: {
|
||||||
|
createForeignKey: true,
|
||||||
|
disableForeignKeys: false,
|
||||||
|
createIndex: true,
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
path: "./database/migrations",
|
||||||
|
pathTs: "./database/migrations",
|
||||||
|
tableName: "migrations",
|
||||||
|
transactional: true,
|
||||||
|
allOrNothing: true,
|
||||||
|
dropTables: false,
|
||||||
|
safe: true,
|
||||||
|
emit: "ts",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { ThrottlerAsyncOptions } from "@nestjs/throttler";
|
||||||
|
|
||||||
|
import { AuthMessage } from "../common/enums/message.enum";
|
||||||
|
|
||||||
|
export function rateLimitConfig(): ThrottlerAsyncOptions {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
throttlers: [
|
||||||
|
{
|
||||||
|
ttl: configService.getOrThrow("THROTTLE_TTL"),
|
||||||
|
limit: configService.getOrThrow("THROTTLE_LIMIT"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
errorMessage: AuthMessage.TOO_MANY_REQUESTS,
|
||||||
|
storage: new ThrottlerStorageRedisService(configService.getOrThrow("REDIS_URI")),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export function S3Configs() {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory(configService: ConfigService) {
|
||||||
|
return {
|
||||||
|
accessKeyId: configService.getOrThrow<string>("BUCKET_ACCESS_KEY"),
|
||||||
|
secretAccessKey: configService.getOrThrow<string>("BUCKET_SECRET_KEY"),
|
||||||
|
endpoint: configService.getOrThrow<string>("BUCKET_URL"),
|
||||||
|
region: configService.getOrThrow<string>("BUCKET_REGION"),
|
||||||
|
bucket: configService.getOrThrow<string>("BUCKET_NAME"),
|
||||||
|
url: configService.getOrThrow<string>("BUCKET_UPLOAD_URL"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IS3Configs {
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
endpoint: string;
|
||||||
|
region: string;
|
||||||
|
bucket: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export function smsConfigs() {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory(configService: ConfigService) {
|
||||||
|
return {
|
||||||
|
API_URL: configService.getOrThrow<string>("SMS_API_URL"),
|
||||||
|
API_KEY: configService.getOrThrow<string>("SMS_API_KEY"),
|
||||||
|
SMS_PATTERN_OTP: configService.getOrThrow<string>("SMS_PATTERN_OTP"),
|
||||||
|
SMS_PATTERN_INVOICE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE"),
|
||||||
|
SMS_PATTERN_LOGIN: configService.getOrThrow<string>("SMS_PATTERN_LOGIN"),
|
||||||
|
SMS_PATTERN_INVOICE_CREATED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_CREATED"),
|
||||||
|
SMS_PATTERN_ANNOUNCEMENT: configService.getOrThrow<string>("SMS_PATTERN_ANNOUNCEMENT"),
|
||||||
|
SMS_PATTERN_TICKET_CREATED: configService.getOrThrow<string>("SMS_PATTERN_TICKET_CREATED"),
|
||||||
|
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: configService.getOrThrow<string>("SMS_PATTERN_TICKET_ASSIGNED_ADMIN"),
|
||||||
|
SMS_PATTERN_INVOICE_APPROVED: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_APPROVED"),
|
||||||
|
SMS_PATTERN_INVOICE_PAID: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_PAID"),
|
||||||
|
SMS_PATTERN_RECURRING_INVOICE_DRAFT: configService.getOrThrow<string>("SMS_PATTERN_RECURRING_INVOICE_DRAFT"),
|
||||||
|
SMS_PATTERN_SUBSCRIPTION_CANCELLED: configService.getOrThrow<string>("SMS_PATTERN_SUBSCRIPTION_CANCELLED"),
|
||||||
|
SMS_PATTERN_INVOICE_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_REMINDER"),
|
||||||
|
SMS_PATTERN_INVOICE_OVERDUE: configService.getOrThrow<string>("SMS_PATTERN_INVOICE_OVERDUE"),
|
||||||
|
SMS_PATTERN_PAYMENT_REMINDER: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_REMINDER"),
|
||||||
|
SMS_PATTERN_PAYMENT_CANCELLATION: configService.getOrThrow<string>("SMS_PATTERN_PAYMENT_CANCELLATION"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISmsConfigs {
|
||||||
|
API_URL: string;
|
||||||
|
API_KEY: string;
|
||||||
|
SMS_PATTERN_OTP: string;
|
||||||
|
SMS_PATTERN_INVOICE: string;
|
||||||
|
SMS_PATTERN_LOGIN: string;
|
||||||
|
SMS_PATTERN_INVOICE_CREATED: string;
|
||||||
|
SMS_PATTERN_ANNOUNCEMENT: string;
|
||||||
|
SMS_PATTERN_TICKET_CREATED: string;
|
||||||
|
SMS_PATTERN_TICKET_ANSWERED: string;
|
||||||
|
SMS_PATTERN_TICKET_ASSIGNED_ADMIN: string;
|
||||||
|
SMS_PATTERN_INVOICE_APPROVED: string;
|
||||||
|
SMS_PATTERN_INVOICE_PAID: string;
|
||||||
|
SMS_PATTERN_RECURRING_INVOICE_DRAFT: string;
|
||||||
|
SMS_PATTERN_SUBSCRIPTION_CANCELLED: string;
|
||||||
|
SMS_PATTERN_INVOICE_REMINDER: string;
|
||||||
|
SMS_PATTERN_INVOICE_OVERDUE: string;
|
||||||
|
SMS_PATTERN_PAYMENT_REMINDER: string;
|
||||||
|
SMS_PATTERN_PAYMENT_CANCELLATION: string;
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||||
|
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export function getSwaggerDocument(app: NestFastifyApplication) {
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle("The dmail api document")
|
||||||
|
.setDescription("The dmail API description")
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "JWT",
|
||||||
|
name: "authorization",
|
||||||
|
in: "header",
|
||||||
|
},
|
||||||
|
"authorization",
|
||||||
|
)
|
||||||
|
|
||||||
|
.setVersion("1.0.0")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup("api-docs", app, swaggerDocument);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export const wildduckConfig = () => ({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
baseUrl: configService.getOrThrow<string>("WILDDUCK_BASE_URL"),
|
||||||
|
accessToken: configService.getOrThrow<string>("WILDDUCK_ACCESS_TOKEN"),
|
||||||
|
timeout: configService.getOrThrow<number>("WILDDUCK_TIMEOUT"),
|
||||||
|
retries: configService.getOrThrow<number>("WILDDUCK_RETRIES"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export function zarinpalConfig() {
|
||||||
|
return {
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
gatewayApiUrl: configService.getOrThrow<string>("ZARINPAL_API_URL"),
|
||||||
|
callBackUrl: configService.getOrThrow<string>("CALLBACK_URL"),
|
||||||
|
ipgType: configService.getOrThrow<string>("IPG_TYPE"),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IZarinpalConfig {
|
||||||
|
gatewayApiUrl: string;
|
||||||
|
callBackUrl: string;
|
||||||
|
ipgType: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { HttpException, HttpStatus } from "@nestjs/common";
|
||||||
|
|
||||||
|
export class MailServerException extends HttpException {
|
||||||
|
constructor(message: string, status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||||
|
super(message, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserNotFoundException extends MailServerException {
|
||||||
|
constructor(userId: string) {
|
||||||
|
super(`User with ID '${userId}' not found`, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MailboxNotFoundException extends MailServerException {
|
||||||
|
constructor(mailboxId: string) {
|
||||||
|
super(`Mailbox with ID '${mailboxId}' not found`, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageNotFoundException extends MailServerException {
|
||||||
|
constructor(messageId: number) {
|
||||||
|
super(`Message with ID '${messageId}' not found`, HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserAlreadyExistsException extends MailServerException {
|
||||||
|
constructor(username: string) {
|
||||||
|
super(`User '${username}' already exists`, HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddressAlreadyExistsException extends MailServerException {
|
||||||
|
constructor(address: string) {
|
||||||
|
super(`Address '${address}' already exists`, HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MailServerConnectionException extends MailServerException {
|
||||||
|
constructor(details?: string) {
|
||||||
|
super(`Mail server connection failed${details ? `: ${details}` : ""}`, HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidMailServerResponseException extends MailServerException {
|
||||||
|
constructor() {
|
||||||
|
super("Invalid response from mail server", HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
|
||||||
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
import { ErrorResponse } from "../../common/interfaces/IError-response";
|
||||||
|
|
||||||
|
@Catch(HttpException)
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
catch(exception: HttpException, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const reply = ctx.getResponse<FastifyReply>();
|
||||||
|
const request = ctx.getRequest<FastifyRequest>();
|
||||||
|
const status = exception.getStatus() || HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const exceptionResponse = exception.getResponse();
|
||||||
|
const message = this.extractMessage(exceptionResponse);
|
||||||
|
|
||||||
|
const response: ErrorResponse = {
|
||||||
|
statusCode: status,
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message,
|
||||||
|
path: request.url,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
reply.status(status).send(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMessage(response: string | Record<string, any>): string[] {
|
||||||
|
if (typeof response === "string") {
|
||||||
|
return [response];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response === "object" && response !== null) {
|
||||||
|
if ("message" in response) {
|
||||||
|
const message = response.message;
|
||||||
|
return Array.isArray(message) ? message : [message];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("error" in response) {
|
||||||
|
return [response.error];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["An unexpected error occurred"];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
|
||||||
|
import { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
import { MailServerException } from "../exceptions/mail-server.exceptions";
|
||||||
|
|
||||||
|
@Catch(MailServerException)
|
||||||
|
export class MailServerExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(MailServerExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: MailServerException, host: ArgumentsHost) {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<FastifyReply>();
|
||||||
|
const request = ctx.getRequest<FastifyRequest>();
|
||||||
|
|
||||||
|
const status = exception.getStatus();
|
||||||
|
const message = exception.message;
|
||||||
|
|
||||||
|
// Log the error for monitoring
|
||||||
|
this.logger.error({
|
||||||
|
message: "Mail server exception occurred",
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
status,
|
||||||
|
error: message,
|
||||||
|
userId: (request.params as any)?.userId || (request.params as any)?.id,
|
||||||
|
userAgent: request.headers["user-agent"],
|
||||||
|
ip: request.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return a consistent error response
|
||||||
|
response.status(status).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: exception.constructor.name,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { BadRequestException, CallHandler, ExecutionContext, ForbiddenException, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||||
|
import { FastifyRequest } from "fastify";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { BusinessMessage } from "../../common/enums/message.enum";
|
||||||
|
import { Business } from "../../modules/businesses/entities/business.entity";
|
||||||
|
import { BusinessesService } from "../../modules/businesses/services/businesses.service";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
business?: Business;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BusinessInterceptor implements NestInterceptor {
|
||||||
|
private readonly headerName = "x-business-id";
|
||||||
|
private readonly logger = new Logger(BusinessInterceptor.name);
|
||||||
|
|
||||||
|
constructor(private readonly businessesService: BusinessesService) {}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||||
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
|
||||||
|
const businessId = request.headers[this.headerName.toLocaleLowerCase()] as string;
|
||||||
|
|
||||||
|
if (businessId) {
|
||||||
|
try {
|
||||||
|
const business = await this.businessesService.getBusinessByDanakSubscriptionId(businessId);
|
||||||
|
|
||||||
|
request.business = business;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ForbiddenException(BusinessMessage.BUSINESS_ID_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { catchError, tap } from "rxjs/operators";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoggingInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(LoggingInterceptor.name);
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const response = context.switchToHttp().getResponse();
|
||||||
|
const { method, url, body, query, params, headers } = request;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const requestId = this.generateRequestId();
|
||||||
|
|
||||||
|
// Log request
|
||||||
|
this.logger.log({
|
||||||
|
message: "Incoming request",
|
||||||
|
requestId,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
query,
|
||||||
|
params,
|
||||||
|
userAgent: headers["user-agent"],
|
||||||
|
ip: request.ip,
|
||||||
|
// Don't log sensitive data
|
||||||
|
body: this.sanitizeRequestBody(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap((data) => {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log({
|
||||||
|
message: "Request completed",
|
||||||
|
requestId,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
responseSize: JSON.stringify(data).length,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
catchError((error) => {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error({
|
||||||
|
message: "Request failed",
|
||||||
|
requestId,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRequestId(): string {
|
||||||
|
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeRequestBody(body: any): any {
|
||||||
|
if (!body || typeof body !== "object") return body;
|
||||||
|
|
||||||
|
const sanitized = { ...body };
|
||||||
|
const sensitiveFields = ["password", "token", "authorization", "secret"];
|
||||||
|
|
||||||
|
for (const field of sensitiveFields) {
|
||||||
|
if (sanitized[field]) {
|
||||||
|
sanitized[field] = "***REDACTED***";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
import { tap } from "rxjs/operators";
|
||||||
|
|
||||||
|
interface RequestMetrics {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
statusCode: number;
|
||||||
|
responseTime: number;
|
||||||
|
timestamp: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(MetricsInterceptor.name);
|
||||||
|
private static metrics: Map<string, any> = new Map();
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const response = context.switchToHttp().getResponse();
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap(() => {
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
const metrics: RequestMetrics = {
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
responseTime,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
userAgent: request.headers["user-agent"],
|
||||||
|
ip: request.ip,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.collectMetrics(metrics);
|
||||||
|
|
||||||
|
// Log slow requests
|
||||||
|
if (responseTime > 1000) {
|
||||||
|
this.logger.warn(`Slow request detected: ${request.method} ${request.url} - ${responseTime}ms`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectMetrics(metrics: RequestMetrics): void {
|
||||||
|
const key = `${metrics.method}:${metrics.url}`;
|
||||||
|
|
||||||
|
if (!MetricsInterceptor.metrics.has(key)) {
|
||||||
|
MetricsInterceptor.metrics.set(key, {
|
||||||
|
count: 0,
|
||||||
|
totalResponseTime: 0,
|
||||||
|
avgResponseTime: 0,
|
||||||
|
maxResponseTime: 0,
|
||||||
|
minResponseTime: Infinity,
|
||||||
|
errorCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = MetricsInterceptor.metrics.get(key);
|
||||||
|
existing.count++;
|
||||||
|
existing.totalResponseTime += metrics.responseTime;
|
||||||
|
existing.avgResponseTime = existing.totalResponseTime / existing.count;
|
||||||
|
existing.maxResponseTime = Math.max(existing.maxResponseTime, metrics.responseTime);
|
||||||
|
existing.minResponseTime = Math.min(existing.minResponseTime, metrics.responseTime);
|
||||||
|
|
||||||
|
if (metrics.statusCode >= 400) {
|
||||||
|
existing.errorCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
MetricsInterceptor.metrics.set(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getMetrics(): Map<string, any> {
|
||||||
|
return MetricsInterceptor.metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
static resetMetrics(): void {
|
||||||
|
MetricsInterceptor.metrics.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from "@nestjs/common";
|
||||||
|
import { FastifyRequest } from "fastify";
|
||||||
|
import { Observable, map } from "rxjs";
|
||||||
|
|
||||||
|
import { IPageFormat, PaginatedResponse, PaginationQuery } from "../../common/interfaces/IPagination";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PaginationInterceptor implements NestInterceptor {
|
||||||
|
private readonly logger = new Logger(PaginationInterceptor.name);
|
||||||
|
private readonly DEFAULT_PAGE = 1;
|
||||||
|
private readonly DEFAULT_LIMIT = 10;
|
||||||
|
private readonly MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const request = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const { page, limit } = this.extractPaginationParams(request.query as PaginationQuery);
|
||||||
|
const { className, handlerName } = this.getContextInfo(context);
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data: PaginatedResponse) => {
|
||||||
|
if (!this.isPaginatedResponse(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Paginating response from ${className}.${handlerName}`);
|
||||||
|
const { count, paginate, ...response } = data;
|
||||||
|
const pager = this.formatPage(page, limit, count, request);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pager,
|
||||||
|
...response,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//******* Extract Pagination Params *******//
|
||||||
|
private extractPaginationParams(query: PaginationQuery): { page: number; limit: number } {
|
||||||
|
const page = this.validateNumber(query.page, this.DEFAULT_PAGE);
|
||||||
|
const limit = this.validateNumber(query.limit, this.DEFAULT_LIMIT);
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: Math.max(1, page),
|
||||||
|
limit: Math.min(Math.max(1, limit), this.MAX_LIMIT),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//******* Validate Number *******//
|
||||||
|
private validateNumber(value: string | undefined, defaultValue: number): number {
|
||||||
|
const parsed = parseInt(value || "", 10);
|
||||||
|
return isNaN(parsed) ? defaultValue : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
//******* Get Context Info *******//
|
||||||
|
private getContextInfo(context: ExecutionContext): { className: string; handlerName: string } {
|
||||||
|
return {
|
||||||
|
className: context.getClass().name,
|
||||||
|
handlerName: context.getHandler().name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//******* Check if Response is Paginated *******//
|
||||||
|
private isPaginatedResponse(data: PaginatedResponse): data is PaginatedResponse {
|
||||||
|
return data && (data.paginate || typeof data.count === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
//******* Format Page *******//
|
||||||
|
private formatPage(page: number, limit: number, totalItems: number | undefined, request: FastifyRequest): IPageFormat {
|
||||||
|
const count = totalItems || 0;
|
||||||
|
const totalPages = Math.ceil(count / limit);
|
||||||
|
const prevPage = page > 1 ? page - 1 : false;
|
||||||
|
const nextPage = page < totalPages ? page + 1 : false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalItems: count,
|
||||||
|
totalPages,
|
||||||
|
prevPage: this.generatePageLink(prevPage, limit, request),
|
||||||
|
nextPage: this.generatePageLink(nextPage, limit, request),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//******* Generate Page Link *******//
|
||||||
|
private generatePageLink(page: number | boolean, limit: number, request: FastifyRequest): string | boolean {
|
||||||
|
if (!page) return false;
|
||||||
|
|
||||||
|
const { protocol, hostname, url } = request;
|
||||||
|
const baseUrl = url.split("?")[0];
|
||||||
|
const queryParams = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
limit: limit.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${protocol}://${hostname}${baseUrl}?${queryParams.toString()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
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 {
|
||||||
|
//
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler<Record<string, unknown>>): Observable<unknown> {
|
||||||
|
const ctx = context.switchToHttp().getResponse<FastifyReply>();
|
||||||
|
const statusCode = ctx.statusCode;
|
||||||
|
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data) => {
|
||||||
|
if (data && data.data !== undefined) {
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
success: true,
|
||||||
|
data: data.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+30
@@ -0,0 +1,30 @@
|
|||||||
|
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: FastifyRequest, rep: FastifyReply["raw"], next: () => void): void {
|
||||||
|
const startAt = process.hrtime();
|
||||||
|
const { method, originalUrl } = req;
|
||||||
|
const ip = req.ip || req.socket.remoteAddress || "-";
|
||||||
|
const userAgent = req.headers["user-agent"] || "-";
|
||||||
|
const referer = req.headers.referer || "-";
|
||||||
|
|
||||||
|
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;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Apache-like format: IP - - [timestamp] "METHOD URL" STATUS SIZE "REFERER" "USER-AGENT" RESPONSE_TIME
|
||||||
|
this.logger.log(
|
||||||
|
`${ip} - - [${timestamp}] "${method} ${originalUrl}" ${statusCode} ${contentLength} "${referer}" "${userAgent}" ${responseTime.toFixed(2)}ms`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import { MikroORM } from "@mikro-orm/core";
|
||||||
|
import { Logger, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||||
|
|
||||||
|
import { AppModule } from "./app.module";
|
||||||
|
import { getSwaggerDocument } from "./configs/swagger.config";
|
||||||
|
import { HttpExceptionFilter } from "./core/filters/http-exception.filters";
|
||||||
|
import { MailServerExceptionFilter } from "./core/filters/mail-server-exception.filter";
|
||||||
|
import { PaginationInterceptor } from "./core/interceptors/pagination.interceptor";
|
||||||
|
import { ResponseInterceptor } from "./core/interceptors/response.interceptor";
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const logger = new Logger("APP");
|
||||||
|
|
||||||
|
const fastifyAdapter = new FastifyAdapter({
|
||||||
|
bodyLimit: 10 * 1024 * 1024,
|
||||||
|
trustProxy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
fastifyAdapter.getInstance().addContentTypeParser("multipart/form-data", (_request, _payload, done) => {
|
||||||
|
done(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastifyAdapter.getInstance().addContentTypeParser("text/plain", (_request, _payload, done) => {
|
||||||
|
done(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyAdapter);
|
||||||
|
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }));
|
||||||
|
|
||||||
|
app.useGlobalInterceptors(new ResponseInterceptor(), new PaginationInterceptor());
|
||||||
|
|
||||||
|
app.useGlobalFilters(new MailServerExceptionFilter(), new HttpExceptionFilter());
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||||
|
credentials: true,
|
||||||
|
optionsSuccessStatus: 204,
|
||||||
|
});
|
||||||
|
const configService = app.get<ConfigService>(ConfigService);
|
||||||
|
|
||||||
|
getSwaggerDocument(app);
|
||||||
|
|
||||||
|
const orm = app.get(MikroORM);
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
await app.get(MikroORM).getSchemaGenerator().ensureDatabase();
|
||||||
|
await orm.getSchemaGenerator().updateSchema();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = configService.getOrThrow<number>("PORT");
|
||||||
|
await app.listen(PORT, "0.0.0.0", () => {
|
||||||
|
logger.log(`Application is running on: http://localhost:${PORT}`);
|
||||||
|
logger.log(`Swagger documentation: http://localhost:${PORT}/api-docs`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
const logger = new Logger("Bootstrap");
|
||||||
|
logger.error("Failed to start application", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEmail, IsNotEmpty, IsString } from "class-validator";
|
||||||
|
|
||||||
|
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsNotEmpty({ message: AuthMessage.EMAIL_NOT_EMPTY })
|
||||||
|
@IsEmail({}, { message: AuthMessage.INVALID_EMAIL_FORMAT })
|
||||||
|
@ApiProperty({ description: "User's email address", example: "john.doe@example.com" })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsNotEmpty({ message: AuthMessage.PASSWORD_NOT_EMPTY })
|
||||||
|
@IsString({ message: AuthMessage.INVALID_PASS_FORMAT })
|
||||||
|
@ApiProperty({ description: "User's password", example: "SecurePassword123!" })
|
||||||
|
password: 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,41 @@
|
|||||||
|
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
|
||||||
|
import { ApiBadRequestResponse, ApiOperation, ApiResponse, ApiTags, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { LoginDto } from "./DTO/login.dto";
|
||||||
|
import { RefreshTokenDto } from "./DTO/refresh-token.dto";
|
||||||
|
import { AuthService } from "./services/auth.service";
|
||||||
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
|
import { RefreshTokenRateLimit } from "../../common/decorators/rate-limit.decorator";
|
||||||
|
import { UserDec } from "../../common/decorators/user.decorator";
|
||||||
|
|
||||||
|
@ApiTags("Authentication")
|
||||||
|
@Controller("auth")
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post("login")
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: "Login user" })
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, description: "User successfully logged in" })
|
||||||
|
@ApiUnauthorizedResponse({ description: "Invalid credentials" })
|
||||||
|
@ApiBadRequestResponse({ description: "Invalid input data" })
|
||||||
|
login(@Body() loginDto: LoginDto) {
|
||||||
|
return this.authService.login(loginDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RefreshTokenRateLimit()
|
||||||
|
@ApiOperation({ summary: "refresh the user access token / refresh token" })
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("refresh")
|
||||||
|
refreshToken(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshToken(refreshTokenDto.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthGuards()
|
||||||
|
@ApiOperation({ summary: "logout the user" })
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("logout")
|
||||||
|
logout(@UserDec("id") userId: string) {
|
||||||
|
return this.authService.logout(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
|
import { PassportModule } from "@nestjs/passport";
|
||||||
|
|
||||||
|
import { AuthController } from "./auth.controller";
|
||||||
|
import { jwtConfig } from "../../configs/jwt.config";
|
||||||
|
import { UtilsModule } from "../utils/utils.module";
|
||||||
|
import { AuthService } from "./services/auth.service";
|
||||||
|
import { TokensService } from "./services/tokens.service";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
import { ConsoleJwtStrategy } from "./strategies/console-jwt.strategy";
|
||||||
|
import { LocalJwtStrategy } from "./strategies/local-jwt.strategy";
|
||||||
|
import { BusinessesModule } from "../businesses/businesses.module";
|
||||||
|
import { User } from "../users/entities/user.entity";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MikroOrmModule.forFeature([User]), UtilsModule, UsersModule, PassportModule, JwtModule.registerAsync(jwtConfig()), BusinessesModule],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, TokensService, LocalJwtStrategy, ConsoleJwtStrategy],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
import { ADMIN_ROUTE } from "../../../common/decorators/admin.decorator";
|
||||||
|
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminRouteGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredAdmin = this.reflector.getAllAndOverride<boolean>(ADMIN_ROUTE, [context.getHandler(), context.getClass()]);
|
||||||
|
if (!requiredAdmin) return true;
|
||||||
|
|
||||||
|
const req = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||||
|
|
||||||
|
if (!user.role) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import { ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { Reflector } from "@nestjs/core";
|
||||||
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
|
import { CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
|
||||||
|
import { SKIP_AUTH_KEY } from "../../../common/decorators/skip-auth.decorator";
|
||||||
|
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard([CONSOLE_JWT_STRATEGY_NAME, LOCAL_JWT_STRATEGY_NAME]) {
|
||||||
|
constructor(private reflector: Reflector) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
const skipAuth = this.reflector.getAllAndOverride<boolean>(SKIP_AUTH_KEY, [context.getHandler(), context.getClass()]);
|
||||||
|
|
||||||
|
if (skipAuth) return true;
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRequest(err: unknown, user: any, _info: unknown) {
|
||||||
|
if (err || !user) throw new UnauthorizedException(AuthMessage.TOKEN_EXPIRED_OR_INVALID);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
// import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
|
// import { Reflector } from "@nestjs/core";
|
||||||
|
// import { FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
// import { ROLES_KEY } from "../../../common/decorators/roles.decorator";
|
||||||
|
// import { AuthMessage } from "../../../common/enums/message.enum";
|
||||||
|
// import { RoleEnum } from "../../users/enums/role.enum";
|
||||||
|
|
||||||
|
// @Injectable()
|
||||||
|
// export class RoleGuard implements CanActivate {
|
||||||
|
// constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
// canActivate(context: ExecutionContext): boolean {
|
||||||
|
// const requiredRole = this.reflector.getAllAndOverride<RoleEnum[]>(ROLES_KEY, [context.getHandler(), context.getClass()]);
|
||||||
|
// if (!requiredRole) return true;
|
||||||
|
|
||||||
|
// const req = context.switchToHttp().getRequest<FastifyRequest>();
|
||||||
|
// const user = req.user;
|
||||||
|
|
||||||
|
// if (!user) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||||
|
|
||||||
|
// const hasRequiredRole = requiredRole.some((role) => user.roles.includes(role));
|
||||||
|
// if (!hasRequiredRole) throw new ForbiddenException(AuthMessage.UNAUTHORIZED_ACCESS);
|
||||||
|
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
import { RoleEnum } from "../../users/enums/role.enum";
|
||||||
|
|
||||||
|
export interface ILocalTokenPayload {
|
||||||
|
id: string;
|
||||||
|
role: RoleEnum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IConsoleTokenPayload {
|
||||||
|
id: string;
|
||||||
|
permissions?: string[];
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITokenPayload extends ILocalTokenPayload, IConsoleTokenPayload {}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { EntityRepository } from "@mikro-orm/core";
|
||||||
|
import { InjectRepository } from "@mikro-orm/nestjs";
|
||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { TokensService } from "./tokens.service";
|
||||||
|
import { AuthMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
import { PasswordService } from "../../utils/services/password.service";
|
||||||
|
import { LoginDto } from "../DTO/login.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
private readonly userRepository: EntityRepository<User>,
|
||||||
|
private readonly tokensService: TokensService,
|
||||||
|
private readonly passwordService: PasswordService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
//==============================================
|
||||||
|
//==============================================
|
||||||
|
async login(loginDto: LoginDto) {
|
||||||
|
const { email, password } = loginDto;
|
||||||
|
|
||||||
|
const user = await this.checkUserLoginCredentialWithEmail(email, password);
|
||||||
|
|
||||||
|
const tokens = await this.tokensService.generateTokens(user);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: AuthMessage.PASSWORD_LOGIN_SUCCESS,
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
//==============================================
|
||||||
|
//==============================================
|
||||||
|
async refreshToken(oldRefreshToken: string) {
|
||||||
|
return this.tokensService.refreshToken(oldRefreshToken);
|
||||||
|
}
|
||||||
|
//==============================================
|
||||||
|
//==============================================
|
||||||
|
async logout(userId: string) {
|
||||||
|
await this.tokensService.invalidateRefreshToken(userId);
|
||||||
|
return {
|
||||||
|
message: AuthMessage.LOGOUT_SUCCESS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
//==============================================
|
||||||
|
|
||||||
|
private async checkUserLoginCredentialWithEmail(email: string, password: string) {
|
||||||
|
const user = await this.userRepository.findOne({ emailAddress: email, isActive: true });
|
||||||
|
if (!user) throw new BadRequestException(AuthMessage.INVALID_PASSWORD);
|
||||||
|
|
||||||
|
const passCompare = await this.passwordService.comparePasswords(password, user.password);
|
||||||
|
if (!passCompare) throw new BadRequestException(AuthMessage.INVALID_PASSWORD);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
|
import { BadRequestException, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { AuthMessage, UserMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { RefreshToken } from "../../users/entities/refresh-token.entity";
|
||||||
|
import { User } from "../../users/entities/user.entity";
|
||||||
|
import { ITokenPayload } from "../interfaces/IToken-payload";
|
||||||
|
|
||||||
|
@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 generateTokens(user: User, em?: EntityManager) {
|
||||||
|
return this.generateAccessAndRefreshToken(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
role: user.role.name,
|
||||||
|
},
|
||||||
|
em,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateAccessAndRefreshToken(payload: ITokenPayload, em?: EntityManager) {
|
||||||
|
const accessExpire = this.configService.getOrThrow<number>("ACCESS_TOKEN_EXPIRE");
|
||||||
|
const refreshExpire = this.configService.getOrThrow<number>("REFRESH_TOKEN_EXPIRE");
|
||||||
|
|
||||||
|
const accessToken = await this.jwtService.signAsync(payload, { expiresIn: `${accessExpire}m` });
|
||||||
|
const refreshToken = await this.generateRefreshToken();
|
||||||
|
|
||||||
|
await this.storeRefreshToken(payload.id, refreshToken, em);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: { token: accessToken, expire: dayjs().add(accessExpire, "minute").valueOf() },
|
||||||
|
refreshToken: { token: refreshToken, expire: dayjs().add(refreshExpire, "day").valueOf() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async storeRefreshToken(userId: string, refreshToken: string, em?: EntityManager) {
|
||||||
|
const entityManager = em || this.em.fork();
|
||||||
|
let transactionStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!entityManager.isInTransaction()) {
|
||||||
|
await entityManager.begin();
|
||||||
|
transactionStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshExpire = this.configService.getOrThrow<number>("REFRESH_TOKEN_EXPIRE");
|
||||||
|
const expiresAt = dayjs().add(refreshExpire, "day").toDate();
|
||||||
|
|
||||||
|
const user = await entityManager.findOne(User, { id: userId });
|
||||||
|
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
|
||||||
|
|
||||||
|
const token = entityManager.create(RefreshToken, {
|
||||||
|
token: refreshToken,
|
||||||
|
user,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await entityManager.persistAndFlush(token);
|
||||||
|
|
||||||
|
if (transactionStarted) await entityManager.commit();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
if (transactionStarted) await entityManager.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(oldRefreshToken: string) {
|
||||||
|
const entityManager = this.em.fork();
|
||||||
|
let transactionStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await entityManager.begin();
|
||||||
|
transactionStarted = true;
|
||||||
|
|
||||||
|
const token = await entityManager.findOne(
|
||||||
|
RefreshToken,
|
||||||
|
{ token: oldRefreshToken },
|
||||||
|
{
|
||||||
|
populate: ["user", "user.role"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!token) throw new UnauthorizedException(AuthMessage.INVALID_REFRESH_TOKEN);
|
||||||
|
|
||||||
|
if (dayjs(token.expiresAt).isBefore(dayjs())) {
|
||||||
|
await entityManager.removeAndFlush(token);
|
||||||
|
throw new UnauthorizedException(AuthMessage.REFRESH_TOKEN_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
await entityManager.removeAndFlush(token);
|
||||||
|
const tokens = await this.generateTokens(token.user, entityManager);
|
||||||
|
|
||||||
|
await entityManager.commit();
|
||||||
|
return tokens;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
if (transactionStarted) {
|
||||||
|
await entityManager.rollback();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateRefreshToken(userId: string) {
|
||||||
|
const entityManager = this.em.fork();
|
||||||
|
let transactionStarted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await entityManager.begin();
|
||||||
|
transactionStarted = true;
|
||||||
|
|
||||||
|
await entityManager.nativeDelete(RefreshToken, { user: { id: userId } });
|
||||||
|
this.logger.log(`Successfully deleted all refresh tokens for user ${userId}`);
|
||||||
|
|
||||||
|
await entityManager.commit();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(error);
|
||||||
|
if (transactionStarted) {
|
||||||
|
await entityManager.rollback();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateRefreshToken() {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
|
|
||||||
|
import { CONSOLE_JWT_STRATEGY_NAME } from "../../../common/constants";
|
||||||
|
import { IConsoleTokenPayload } from "../interfaces/IToken-payload";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConsoleJwtStrategy extends PassportStrategy(Strategy, CONSOLE_JWT_STRATEGY_NAME) {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: readFileSync(`${process.cwd()}/keys/public.pem`, "utf8"),
|
||||||
|
algorithms: ["RS256"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: IConsoleTokenPayload) {
|
||||||
|
return { id: payload.id, permissions: payload.permissions, isAdmin: payload.isAdmin };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
|
|
||||||
|
import { LOCAL_JWT_STRATEGY_NAME } from "../../../common/constants";
|
||||||
|
import { ITokenPayload } from "../interfaces/IToken-payload";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalJwtStrategy extends PassportStrategy(Strategy, LOCAL_JWT_STRATEGY_NAME) {
|
||||||
|
constructor(configService: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: configService.getOrThrow<string>("JWT_SECRET"),
|
||||||
|
issuer: configService.getOrThrow<string>("JWT_ISSUER"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: ITokenPayload) {
|
||||||
|
return { id: payload.id, role: payload.role };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsString } from "class-validator";
|
||||||
|
|
||||||
|
import { BusinessMessage } from "../../../common/enums/message.enum";
|
||||||
|
|
||||||
|
export class BusinessSlugParamDto {
|
||||||
|
@IsNotEmpty({ message: BusinessMessage.SLUG_REQUIRED })
|
||||||
|
@IsString({ message: BusinessMessage.SLUG_MUST_BE_STRING })
|
||||||
|
@ApiProperty({ description: "The slug of the business", example: "example-business" })
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator";
|
||||||
|
|
||||||
|
import { BusinessMessage } from "../../../common/enums/message.enum";
|
||||||
|
|
||||||
|
export class UpdateBusinessSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNotEmpty({ message: BusinessMessage.ZARINPAL_MERCHANT_ID_REQUIRED })
|
||||||
|
@IsString({ message: BusinessMessage.ZARINPAL_MERCHANT_ID_STRING })
|
||||||
|
@ApiProperty({ description: "Zarinpal merchant id", example: "1234567890" })
|
||||||
|
zarinpalMerchantId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNotEmpty({ message: BusinessMessage.LOGO_URL_REQUIRED })
|
||||||
|
@IsString({ message: BusinessMessage.LOGO_URL_STRING })
|
||||||
|
@IsUrl({ protocols: ["https"] }, { message: BusinessMessage.LOGO_URL_INVALID })
|
||||||
|
@ApiProperty({ description: "Logo url", example: "https://example.com/logo.png" })
|
||||||
|
logoUrl?: string;
|
||||||
|
|
||||||
|
// @IsOptional()
|
||||||
|
// @IsNotEmpty({ message: BusinessMessage.NAME_REQUIRED })
|
||||||
|
// @IsString({ message: BusinessMessage.NAME_STRING })
|
||||||
|
// @ApiProperty({ description: "Name", example: "Example" })
|
||||||
|
// name?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, UseInterceptors } from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto";
|
||||||
|
import { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto";
|
||||||
|
import { BusinessesService } from "./services/businesses.service";
|
||||||
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
|
import { BusinessDec } from "../../common/decorators/business.decorator";
|
||||||
|
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||||
|
|
||||||
|
@Controller("business")
|
||||||
|
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||||
|
export class BusinessesController {
|
||||||
|
constructor(private readonly businessesService: BusinessesService) {}
|
||||||
|
|
||||||
|
@Get("slug/:slug")
|
||||||
|
@ApiOperation({ summary: "Get business by slug" })
|
||||||
|
getBusinessBySlug(@Param() params: BusinessSlugParamDto) {
|
||||||
|
return this.businessesService.getBusinessBySlug(params.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("settings")
|
||||||
|
@UseInterceptors(BusinessInterceptor)
|
||||||
|
@ApiOperation({ summary: "Update business settings" })
|
||||||
|
@AuthGuards()
|
||||||
|
updateBusinessSettings(@BusinessDec("id") businessId: string, @Body() settingsDto: UpdateBusinessSettingsDto) {
|
||||||
|
return this.businessesService.updateBusinessSettings(businessId, settingsDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("settings")
|
||||||
|
@UseInterceptors(BusinessInterceptor)
|
||||||
|
@ApiOperation({ summary: "Get business settings" })
|
||||||
|
@AuthGuards()
|
||||||
|
getBusinessSettings(@BusinessDec("id") businessId: string) {
|
||||||
|
return this.businessesService.getBusinessSettings(businessId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
|
import { BullModule } from "@nestjs/bullmq";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { BusinessesController } from "./businesses.controller";
|
||||||
|
import { SUBSCRIPTIONS } from "./constant";
|
||||||
|
import { Business } from "./entities/business.entity";
|
||||||
|
import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor";
|
||||||
|
import { BusinessesService } from "./services/businesses.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
MikroOrmModule.forFeature([Business]),
|
||||||
|
BullModule.registerQueue({
|
||||||
|
name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME,
|
||||||
|
prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [BusinessesService, BusinessProvisioningProcessor],
|
||||||
|
exports: [BusinessesService],
|
||||||
|
controllers: [BusinessesController],
|
||||||
|
})
|
||||||
|
export class BusinessesModule {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export const SUBSCRIPTIONS = Object.freeze({
|
||||||
|
PROVISIONING_QUEUE_PREFIX: "provisioning",
|
||||||
|
PROVISIONING_QUEUE_NAME: "provisioning",
|
||||||
|
PROVISIONING_JOB_NAME: "business.created",
|
||||||
|
|
||||||
|
//
|
||||||
|
SERVICE_SLUG: "Dmail",
|
||||||
|
SERVICE_ID: "e51afdc3-ea0b-49cf-8f49-2a6f131b024e",
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
import { Business } from "./business.entity";
|
||||||
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
|
import { BusinessStaffRepository } from "../repositories/business-staff.repository";
|
||||||
|
|
||||||
|
@Entity({ repository: () => BusinessStaffRepository })
|
||||||
|
export class BusinessStaff extends BaseEntity {
|
||||||
|
@Property({ type: "uuid", nullable: false })
|
||||||
|
danakUserId: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: false })
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: true })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: false })
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Business, { deleteRule: "cascade", nullable: false })
|
||||||
|
business: Business;
|
||||||
|
|
||||||
|
[EntityRepositoryType]?: BusinessStaffRepository;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, OneToOne, Property, Unique } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
import { BusinessStaff } from "./business-staff.entity";
|
||||||
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
|
import { Domain } from "../../domains/entities/domain.entity";
|
||||||
|
import { BusinessRepository } from "../repositories/business.repository";
|
||||||
|
|
||||||
|
@Entity({ repository: () => BusinessRepository })
|
||||||
|
export class Business extends BaseEntity {
|
||||||
|
@Unique()
|
||||||
|
@Property({ type: "uuid", nullable: false })
|
||||||
|
danakSubscriptionId!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: false })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: false })
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: true })
|
||||||
|
logoUrl?: string;
|
||||||
|
|
||||||
|
//=========================
|
||||||
|
|
||||||
|
@OneToOne(() => Domain, (domain) => domain.business, { nullable: true, cascade: [Cascade.ALL] })
|
||||||
|
domain?: Domain;
|
||||||
|
|
||||||
|
//=========================
|
||||||
|
|
||||||
|
// @OneToMany(() => User, (user) => user.business)
|
||||||
|
// users = new Collection<User>(this);
|
||||||
|
|
||||||
|
@OneToMany(() => BusinessStaff, (businessStaff) => businessStaff.business)
|
||||||
|
businessStaffs = new Collection<BusinessStaff>(this);
|
||||||
|
|
||||||
|
[EntityRepositoryType]?: BusinessRepository;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface IBusinessProvisioningJob {
|
||||||
|
subscriptionId: string;
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
businessName: string;
|
||||||
|
userId: string;
|
||||||
|
slug: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// src/business/queue/business-provisioning.processor.ts
|
||||||
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
|
import { Processor } from "@nestjs/bullmq";
|
||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { Job } from "bullmq";
|
||||||
|
|
||||||
|
import { WorkerProcessor } from "../../../common/queues/worker.processor";
|
||||||
|
import { SUBSCRIPTIONS } from "../constant";
|
||||||
|
import { BusinessStaff } from "../entities/business-staff.entity";
|
||||||
|
import { Business } from "../entities/business.entity";
|
||||||
|
import { IBusinessProvisioningJob } from "../interfaces/IBusiness";
|
||||||
|
|
||||||
|
@Processor(SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME)
|
||||||
|
export class BusinessProvisioningProcessor extends WorkerProcessor {
|
||||||
|
protected readonly logger = new Logger(BusinessProvisioningProcessor.name);
|
||||||
|
|
||||||
|
constructor(private readonly em: EntityManager) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(job: Job) {
|
||||||
|
switch (job.name) {
|
||||||
|
case SUBSCRIPTIONS.PROVISIONING_JOB_NAME:
|
||||||
|
await this.handleBusinessCreated(job);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(`Unknown job name: ${job.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleBusinessCreated(job: Job<IBusinessProvisioningJob>) {
|
||||||
|
const em = this.em.fork();
|
||||||
|
const { subscriptionId, serviceId, serviceName, businessName, slug, userId, fullName, phone, email } = job.data;
|
||||||
|
|
||||||
|
// Only act if the event is for this service
|
||||||
|
if (serviceId !== SUBSCRIPTIONS.SERVICE_ID) {
|
||||||
|
this.logger.debug(`Skipping job for service: ${serviceName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
let business = await em.findOne(Business, { danakSubscriptionId: subscriptionId });
|
||||||
|
if (!business) {
|
||||||
|
business = em.create(Business, {
|
||||||
|
danakSubscriptionId: subscriptionId,
|
||||||
|
name: businessName,
|
||||||
|
slug: slug,
|
||||||
|
});
|
||||||
|
await em.persistAndFlush(business);
|
||||||
|
this.logger.log(`Created business for subscription ${subscriptionId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Business already exists for subscription ${subscriptionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await em.findOne(BusinessStaff, { danakUserId: userId });
|
||||||
|
if (!user) {
|
||||||
|
const user = em.create(BusinessStaff, {
|
||||||
|
danakUserId: userId,
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
business,
|
||||||
|
});
|
||||||
|
await em.persistAndFlush(user);
|
||||||
|
this.logger.log(`Created business staff for subscription ${subscriptionId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Business staff already exists for subscription ${subscriptionId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { EntityRepository } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
import { BusinessStaff } from "../entities/business-staff.entity";
|
||||||
|
|
||||||
|
export class BusinessStaffRepository extends EntityRepository<BusinessStaff> {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||||
|
|
||||||
|
import { Business } from "../entities/business.entity";
|
||||||
|
|
||||||
|
export class BusinessRepository extends EntityRepository<Business> {}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { BusinessMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { UpdateBusinessSettingsDto } from "../DTO/update-business-settings.dto";
|
||||||
|
import { BusinessStaff } from "../entities/business-staff.entity";
|
||||||
|
import { BusinessRepository } from "../repositories/business.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BusinessesService {
|
||||||
|
constructor(
|
||||||
|
private readonly businessRepository: BusinessRepository,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) {
|
||||||
|
const business = await this.businessRepository.findOne({ danakSubscriptionId, deletedAt: null });
|
||||||
|
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
|
||||||
|
return business;
|
||||||
|
}
|
||||||
|
//************************************************** */
|
||||||
|
|
||||||
|
async getBusinessBySlug(slug: string) {
|
||||||
|
const business = await this.businessRepository.findOne({ slug, deletedAt: null });
|
||||||
|
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
|
||||||
|
return { business };
|
||||||
|
}
|
||||||
|
//************************************************** */
|
||||||
|
async updateBusinessSettings(businessId: string, settingsDto: UpdateBusinessSettingsDto) {
|
||||||
|
const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null });
|
||||||
|
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
|
||||||
|
this.businessRepository.assign(business, settingsDto);
|
||||||
|
await this.em.flush();
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: BusinessMessage.BUSINESS_SETTINGS_UPDATED,
|
||||||
|
business,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
//************************************************** */
|
||||||
|
|
||||||
|
async getBusinessSettings(businessId: string) {
|
||||||
|
const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null });
|
||||||
|
console.log("business", business);
|
||||||
|
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
|
||||||
|
return { business };
|
||||||
|
}
|
||||||
|
//************************************************** */
|
||||||
|
|
||||||
|
async getBusinessById(id: string) {
|
||||||
|
const business = await this.businessRepository.findOne({ id, deletedAt: null });
|
||||||
|
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
|
||||||
|
return business;
|
||||||
|
}
|
||||||
|
|
||||||
|
//************************************************** */
|
||||||
|
|
||||||
|
async findAdminWithLeastTickets() {
|
||||||
|
// First query to get the ID of the business staff with the least tickets
|
||||||
|
const result = await this.em.getConnection().execute(
|
||||||
|
`
|
||||||
|
SELECT bs.id
|
||||||
|
FROM business_staff bs
|
||||||
|
WHERE bs.deleted_at IS NULL
|
||||||
|
ORDER BY (
|
||||||
|
SELECT COUNT(*) FROM ticket t WHERE t.assigned_to_id = bs.id
|
||||||
|
) ASC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length === 0) return null;
|
||||||
|
|
||||||
|
return this.em.findOne(BusinessStaff, { id: result[0].id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************/
|
||||||
|
async findOneByIdWithEntityManager(staffId: string, em: EntityManager) {
|
||||||
|
const staff = await em.findOne(BusinessStaff, { id: staffId });
|
||||||
|
if (!staff) throw new BadRequestException(BusinessMessage.STAFF_NOT_FOUND);
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
|
||||||
|
|
||||||
|
import { DNSRecordType } from "../enums/domain-status.enum";
|
||||||
|
|
||||||
|
export class CreateDnsRecordDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@ApiProperty({ description: "The name of the DNS record", example: "mail" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEnum(DNSRecordType)
|
||||||
|
@ApiProperty({ description: "The type of the DNS record", example: "MX" })
|
||||||
|
type!: DNSRecordType;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ description: "The value of the DNS record", example: "10 mail.example.com" })
|
||||||
|
value!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Min(60)
|
||||||
|
@Max(86400)
|
||||||
|
@ApiProperty({ description: "The TTL of the DNS record", example: 300 })
|
||||||
|
ttl?: number = 300;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
@Min(0)
|
||||||
|
@Max(65535)
|
||||||
|
@ApiProperty({ description: "The priority of the DNS record", example: 10 })
|
||||||
|
priority?: number;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@ApiProperty({ description: "The ID of the domain", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||||
|
domainId!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiProperty({ description: "Whether the DNS record is required", example: true })
|
||||||
|
isRequired?: boolean = true;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(500)
|
||||||
|
@ApiProperty({ description: "The description of the DNS record", example: "This is a test DNS record" })
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
import { VerificationType } from "../enums/domain-status.enum";
|
||||||
|
|
||||||
|
export class CreateDomainVerificationDto {
|
||||||
|
@IsEnum(VerificationType)
|
||||||
|
@ApiProperty({ description: "The type of the domain verification", example: "DNS_TXT" })
|
||||||
|
type!: VerificationType;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@ApiProperty({ description: "The ID of the domain", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||||
|
domainId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(255)
|
||||||
|
@ApiProperty({ description: "The name of the record", example: "mail" })
|
||||||
|
recordName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@ApiProperty({ description: "The value of the record", example: "10 mail.example.com" })
|
||||||
|
recordValue?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(1000)
|
||||||
|
@ApiProperty({ description: "The instructions for the domain verification", example: "This is a test domain verification" })
|
||||||
|
instructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VerifyDomainDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ description: "The token for the domain verification", example: "123e4567-e89b-12d3-a456-426614174000" })
|
||||||
|
token!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateDomainDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+$/, { message: "Domain name must be a valid domain format" })
|
||||||
|
@ApiProperty({ description: "The name of the domain", example: "example.com" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MaxLength(1000)
|
||||||
|
@ApiProperty({ description: "The notes of the domain", example: "This is a test domain" })
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CreateDomainDto } from "./create-domain.dto";
|
||||||
|
|
||||||
|
export class UpdateDomainDto extends PartialType(CreateDomainDto) {}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseInterceptors } from "@nestjs/common";
|
||||||
|
import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { CreateDomainDto } from "./DTO/create-domain.dto";
|
||||||
|
import { UpdateDomainDto } from "./DTO/update-domain.dto";
|
||||||
|
import { DomainsService } from "./services/domains.service";
|
||||||
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
|
import { BusinessDec } from "../../common/decorators/business.decorator";
|
||||||
|
import { PaginationDto } from "../../common/DTO/pagination.dto";
|
||||||
|
import { ParamDto } from "../../common/DTO/param.dto";
|
||||||
|
import { BusinessInterceptor } from "../../core/interceptors/business.interceptor";
|
||||||
|
|
||||||
|
@Controller("domains")
|
||||||
|
@ApiHeader({ name: "x-business-id", description: "Business ID" })
|
||||||
|
@UseInterceptors(BusinessInterceptor)
|
||||||
|
@AuthGuards()
|
||||||
|
export class DomainsController {
|
||||||
|
constructor(private readonly domainsService: DomainsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: "Create a new domain and get setup instructions" })
|
||||||
|
@ApiResponse({ status: 201, description: "Domain created with setup instructions" })
|
||||||
|
createDomain(@Body() createDomainDto: CreateDomainDto, @BusinessDec("id") businessId: string) {
|
||||||
|
return this.domainsService.createDomain(createDomainDto, businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: "Get domain for business" })
|
||||||
|
@ApiResponse({ status: 200, description: "Domains retrieved successfully" })
|
||||||
|
getBusinessDomain(@BusinessDec("id") businessId: string, @Query() _query: PaginationDto) {
|
||||||
|
return this.domainsService.getBusinessDomain(businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@ApiOperation({ summary: "Update domain" })
|
||||||
|
@ApiResponse({ status: 200, description: "Domain updated successfully" })
|
||||||
|
updateDomain(@Param() paramDto: ParamDto, @Body() updateDomainDto: UpdateDomainDto) {
|
||||||
|
return this.domainsService.updateDomain(paramDto.id, updateDomainDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@ApiOperation({ summary: "Delete domain" })
|
||||||
|
@ApiResponse({ status: 200, description: "Domain deleted successfully" })
|
||||||
|
deleteDomain(@Param() paramDto: ParamDto) {
|
||||||
|
return this.domainsService.deleteDomain(paramDto.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/setup")
|
||||||
|
@ApiOperation({ summary: "Get domain setup instructions" })
|
||||||
|
@ApiResponse({ status: 200, description: "Domain setup instructions retrieved" })
|
||||||
|
getDomainSetupInstructions(@Param() paramDto: ParamDto) {
|
||||||
|
return this.domainsService.getDomainSetupInstructions(paramDto.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/dns-records")
|
||||||
|
@ApiOperation({ summary: "Get DNS records for domain" })
|
||||||
|
@ApiResponse({ status: 200, description: "DNS records retrieved successfully" })
|
||||||
|
getDomainDnsRecords(@Param() paramDto: ParamDto) {
|
||||||
|
return this.domainsService.getDomainDnsRecords(paramDto.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/verification-status")
|
||||||
|
@ApiOperation({ summary: "Check domain verification status" })
|
||||||
|
@ApiResponse({ status: 200, description: "Domain verification status checked" })
|
||||||
|
checkDomainVerificationStatus(@Param() paramDto: ParamDto) {
|
||||||
|
return this.domainsService.checkDomainVerificationStatus(paramDto.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/check-dns")
|
||||||
|
@ApiOperation({ summary: "Check DNS records and update verification status" })
|
||||||
|
@ApiResponse({ status: 200, description: "DNS records checked and updated" })
|
||||||
|
async checkDnsRecords(@Param() paramDto: ParamDto) {
|
||||||
|
// Trigger DNS verification
|
||||||
|
await this.domainsService.verifyDomain(paramDto.id);
|
||||||
|
// Return updated status
|
||||||
|
return this.domainsService.checkDomainVerificationStatus(paramDto.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { MikroOrmModule } from "@mikro-orm/nestjs";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { DomainsController } from "./domains.controller";
|
||||||
|
import { MailServerModule } from "../mail-server/mail-server.module";
|
||||||
|
import { DnsRecord } from "./entities/dns-record.entity";
|
||||||
|
import { Domain } from "./entities/domain.entity";
|
||||||
|
import { DnsService } from "./services/dns.service";
|
||||||
|
import { DomainsService } from "./services/domains.service";
|
||||||
|
import { BusinessesModule } from "../businesses/businesses.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MikroOrmModule.forFeature([Domain, DnsRecord]), MailServerModule, BusinessesModule],
|
||||||
|
controllers: [DomainsController],
|
||||||
|
providers: [DomainsService, DnsService],
|
||||||
|
exports: [DomainsService, DnsService],
|
||||||
|
})
|
||||||
|
export class DomainsModule {}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Entity, EntityRepositoryType, Enum, ManyToOne, Opt, Property } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
import { Domain } from "./domain.entity";
|
||||||
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
|
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
|
||||||
|
import { DnsRecordRepository } from "../repositories/dns-record.repository";
|
||||||
|
|
||||||
|
@Entity({ repository: () => DnsRecordRepository })
|
||||||
|
export class DnsRecord extends BaseEntity {
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: true })
|
||||||
|
wildduckId?: string;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: false })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Enum({ items: () => DNSRecordType, nativeEnumName: "dns_record_type_enum" })
|
||||||
|
type!: DNSRecordType;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: false })
|
||||||
|
value!: string;
|
||||||
|
|
||||||
|
@Property({ type: "integer", default: 300 })
|
||||||
|
ttl: number & Opt = 300;
|
||||||
|
|
||||||
|
@Property({ type: "integer", nullable: true })
|
||||||
|
priority?: number;
|
||||||
|
|
||||||
|
@Enum({ items: () => VerificationStatus, nativeEnumName: "verification_status_enum", default: VerificationStatus.PENDING })
|
||||||
|
status: VerificationStatus & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "boolean", default: true })
|
||||||
|
isRequired: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "boolean", default: true })
|
||||||
|
isActive: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "datetime", nullable: true })
|
||||||
|
lastVerifiedAt?: Date;
|
||||||
|
|
||||||
|
@Property({ type: "datetime", nullable: true })
|
||||||
|
lastCheckedAt?: Date;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: true })
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
@Property({ type: "integer", default: 0 })
|
||||||
|
verificationAttempts: number & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "datetime", nullable: true })
|
||||||
|
nextVerificationAt?: Date;
|
||||||
|
|
||||||
|
//==================================
|
||||||
|
@ManyToOne(() => Domain, { nullable: false })
|
||||||
|
domain!: Domain;
|
||||||
|
|
||||||
|
[EntityRepositoryType]?: DnsRecordRepository;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Collection, Entity, EntityRepositoryType, Enum, OneToMany, OneToOne, Opt, Property, Unique } from "@mikro-orm/core";
|
||||||
|
|
||||||
|
import { DnsRecord } from "./dns-record.entity";
|
||||||
|
import { BaseEntity } from "../../../common/entities/base.entity";
|
||||||
|
import { Business } from "../../businesses/entities/business.entity";
|
||||||
|
import { DomainStatus } from "../enums/domain-status.enum";
|
||||||
|
import { DomainRepository } from "../repositories/domain.repository";
|
||||||
|
|
||||||
|
@Entity({ repository: () => DomainRepository })
|
||||||
|
export class Domain extends BaseEntity {
|
||||||
|
@Unique()
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: false })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Enum({ items: () => DomainStatus, nativeEnumName: "domain_status_enum", default: DomainStatus.PENDING })
|
||||||
|
status: DomainStatus & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "boolean", default: false })
|
||||||
|
isVerified: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "boolean", default: true })
|
||||||
|
isActive: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "datetime", nullable: true })
|
||||||
|
verifiedAt?: Date;
|
||||||
|
|
||||||
|
@Property({ type: "datetime", nullable: true })
|
||||||
|
lastCheckedAt?: Date;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: true })
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
// DKIM Configuration
|
||||||
|
@Property({ type: "boolean", default: false })
|
||||||
|
dkimEnabled: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "varchar", length: 255, nullable: true })
|
||||||
|
dkimSelector?: string;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: true })
|
||||||
|
dkimPrivateKey?: string;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: true })
|
||||||
|
dkimPublicKey?: string;
|
||||||
|
|
||||||
|
// SPF Configuration
|
||||||
|
@Property({ type: "boolean", default: false })
|
||||||
|
spfEnabled: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: true })
|
||||||
|
spfRecord?: string;
|
||||||
|
|
||||||
|
// DMARC Configuration
|
||||||
|
@Property({ type: "boolean", default: false })
|
||||||
|
dmarcEnabled: boolean & Opt;
|
||||||
|
|
||||||
|
@Property({ type: "text", nullable: true })
|
||||||
|
dmarcPolicy?: string;
|
||||||
|
|
||||||
|
//==================================
|
||||||
|
@OneToOne(() => Business, (business) => business.domain, { owner: true, orphanRemoval: true })
|
||||||
|
business!: Business;
|
||||||
|
|
||||||
|
//==================================
|
||||||
|
|
||||||
|
@OneToMany(() => DnsRecord, (dnsRecord) => dnsRecord.domain)
|
||||||
|
dnsRecords = new Collection<DnsRecord>(this);
|
||||||
|
|
||||||
|
[EntityRepositoryType]?: DomainRepository;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export enum DomainStatus {
|
||||||
|
PENDING = "PENDING",
|
||||||
|
VERIFIED = "VERIFIED",
|
||||||
|
FAILED = "FAILED",
|
||||||
|
SUSPENDED = "SUSPENDED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DNSRecordType {
|
||||||
|
A = "A",
|
||||||
|
AAAA = "AAAA",
|
||||||
|
CNAME = "CNAME",
|
||||||
|
MX = "MX",
|
||||||
|
TXT = "TXT",
|
||||||
|
SPF = "SPF",
|
||||||
|
DKIM = "DKIM",
|
||||||
|
DMARC = "DMARC",
|
||||||
|
SRV = "SRV",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum VerificationStatus {
|
||||||
|
PENDING = "PENDING",
|
||||||
|
VERIFIED = "VERIFIED",
|
||||||
|
FAILED = "FAILED",
|
||||||
|
EXPIRED = "EXPIRED",
|
||||||
|
}
|
||||||
|
export enum VerificationType {
|
||||||
|
DNS_TXT = "DNS_TXT",
|
||||||
|
DNS_CNAME = "DNS_CNAME",
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||||
|
|
||||||
|
import { DnsRecord } from "../entities/dns-record.entity";
|
||||||
|
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
|
||||||
|
|
||||||
|
export class DnsRecordRepository extends EntityRepository<DnsRecord> {
|
||||||
|
/**
|
||||||
|
* Find DNS records by domain ID
|
||||||
|
*/
|
||||||
|
async findByDomainId(domainId: string): Promise<DnsRecord[]> {
|
||||||
|
return this.find({ domain: domainId, deletedAt: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find DNS records by type
|
||||||
|
*/
|
||||||
|
async findByType(domainId: string, type: DNSRecordType): Promise<DnsRecord[]> {
|
||||||
|
return this.find({ domain: domainId, type });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find required DNS records that are not verified
|
||||||
|
*/
|
||||||
|
async findUnverifiedRequired(domainId: string): Promise<DnsRecord[]> {
|
||||||
|
return this.find({
|
||||||
|
domain: domainId,
|
||||||
|
isRequired: true,
|
||||||
|
status: { $ne: VerificationStatus.VERIFIED },
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find DNS records that need verification
|
||||||
|
*/
|
||||||
|
async findNeedingVerification(): Promise<DnsRecord[]> {
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return this.find(
|
||||||
|
{
|
||||||
|
$and: [
|
||||||
|
{
|
||||||
|
$or: [{ lastCheckedAt: null }, { lastCheckedAt: { $lt: oneHourAgo } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$or: [{ status: VerificationStatus.PENDING }, { status: VerificationStatus.FAILED }],
|
||||||
|
},
|
||||||
|
{ isActive: true },
|
||||||
|
{ verificationAttempts: { $lt: 5 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ populate: ["domain"] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS record statistics for a domain
|
||||||
|
*/
|
||||||
|
async getDomainDnsStats(domainId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
verified: number;
|
||||||
|
pending: number;
|
||||||
|
failed: number;
|
||||||
|
required: number;
|
||||||
|
}> {
|
||||||
|
const records = await this.find({ domain: domainId, isActive: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: records.length,
|
||||||
|
verified: records.filter((r) => r.status === VerificationStatus.VERIFIED).length,
|
||||||
|
pending: records.filter((r) => r.status === VerificationStatus.PENDING).length,
|
||||||
|
failed: records.filter((r) => r.status === VerificationStatus.FAILED).length,
|
||||||
|
required: records.filter((r) => r.isRequired).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find MX records for a domain
|
||||||
|
*/
|
||||||
|
async findMXRecords(domainId: string): Promise<DnsRecord[]> {
|
||||||
|
return this.find(
|
||||||
|
{
|
||||||
|
domain: domainId,
|
||||||
|
type: DNSRecordType.MX,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
{ orderBy: { priority: "ASC" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find SPF record for a domain
|
||||||
|
*/
|
||||||
|
async findSPFRecord(domainId: string): Promise<DnsRecord | null> {
|
||||||
|
return this.findOne({
|
||||||
|
domain: domainId,
|
||||||
|
type: DNSRecordType.SPF,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find DKIM records for a domain
|
||||||
|
*/
|
||||||
|
async findDKIMRecords(domainId: string): Promise<DnsRecord[]> {
|
||||||
|
return this.find({
|
||||||
|
domain: domainId,
|
||||||
|
type: DNSRecordType.DKIM,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||||
|
|
||||||
|
import { Domain } from "../entities/domain.entity";
|
||||||
|
import { DomainStatus } from "../enums/domain-status.enum";
|
||||||
|
|
||||||
|
export class DomainRepository extends EntityRepository<Domain> {
|
||||||
|
/**
|
||||||
|
* Find domain by name
|
||||||
|
*/
|
||||||
|
async findByName(name: string): Promise<Domain | null> {
|
||||||
|
return this.findOne({ name, deletedAt: null }, { populate: ["business", "dnsRecords"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find domains by business ID
|
||||||
|
*/
|
||||||
|
async findByBusinessId(businessId: string): Promise<Domain | null> {
|
||||||
|
return this.findOne({ business: businessId, deletedAt: null }, { populate: ["dnsRecords"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find verified domains
|
||||||
|
*/
|
||||||
|
async findVerified(): Promise<Domain | null> {
|
||||||
|
return this.findOne({ status: DomainStatus.VERIFIED, isActive: true, deletedAt: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find domains pending verification
|
||||||
|
*/
|
||||||
|
async findPendingVerification(): Promise<Domain | null> {
|
||||||
|
return this.findOne({ status: DomainStatus.PENDING, isActive: true, deletedAt: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find domains that need checking
|
||||||
|
*/
|
||||||
|
async findNeedingCheck(): Promise<Domain | null> {
|
||||||
|
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
return this.findOne({
|
||||||
|
deletedAt: null,
|
||||||
|
$or: [{ lastCheckedAt: null }, { lastCheckedAt: { $lt: oneHourAgo } }],
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain statistics for a business
|
||||||
|
*/
|
||||||
|
async getBusinessDomainStats(businessId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
verified: number;
|
||||||
|
pending: number;
|
||||||
|
failed: number;
|
||||||
|
}> {
|
||||||
|
const domains = await this.find({ business: businessId, deletedAt: null });
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: domains.length,
|
||||||
|
verified: domains.filter((d) => d.status === DomainStatus.VERIFIED).length,
|
||||||
|
pending: domains.filter((d) => d.status === DomainStatus.PENDING).length,
|
||||||
|
failed: domains.filter((d) => d.status === DomainStatus.FAILED).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import * as crypto from "crypto";
|
||||||
|
import * as dns from "dns";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
import { EntityManager } from "@mikro-orm/core";
|
||||||
|
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { DnsRecordMessage, DomainMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
|
import { CreateDnsRecordDto } from "../DTO/create-dns-record.dto";
|
||||||
|
import { DnsRecord } from "../entities/dns-record.entity";
|
||||||
|
import { Domain } from "../entities/domain.entity";
|
||||||
|
import { DNSRecordType, VerificationStatus } from "../enums/domain-status.enum";
|
||||||
|
import { DnsRecordRepository } from "../repositories/dns-record.repository";
|
||||||
|
import { DomainRepository } from "../repositories/domain.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DnsService {
|
||||||
|
private readonly logger = new Logger(DnsService.name);
|
||||||
|
|
||||||
|
private readonly mailServerDomain: string = "mail.danakcorp.com";
|
||||||
|
private readonly spfRecord: string = `v=spf1 include:${this.mailServerDomain} ~all`;
|
||||||
|
private readonly dmarcRecord: string = `v=DMARC1; p=quarantine; rua=mailto:dmarc@${this.mailServerDomain}; ruf=mailto:dmarc@${this.mailServerDomain}`;
|
||||||
|
|
||||||
|
private readonly resolveTxt = promisify(dns.resolveTxt);
|
||||||
|
private readonly resolveCname = promisify(dns.resolveCname);
|
||||||
|
private readonly resolveMx = promisify(dns.resolveMx);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dnsRecordRepository: DnsRecordRepository,
|
||||||
|
private readonly domainRepository: DomainRepository,
|
||||||
|
private readonly mailServerService: MailServerService,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/*******************************/
|
||||||
|
async createDnsRecord(createDnsRecordDto: CreateDnsRecordDto) {
|
||||||
|
const domain = await this.domainRepository.findOne({ id: createDnsRecordDto.domainId });
|
||||||
|
if (!domain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
|
||||||
|
|
||||||
|
const dnsRecord = this.dnsRecordRepository.create({
|
||||||
|
...createDnsRecordDto,
|
||||||
|
domain,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(dnsRecord);
|
||||||
|
this.logger.log(`DNS record created: ${dnsRecord.name} ${dnsRecord.type}`);
|
||||||
|
|
||||||
|
return { dnsRecord };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS records for domain
|
||||||
|
*/
|
||||||
|
async getDomainDnsRecords(domainId: string) {
|
||||||
|
const dnsRecords = await this.dnsRecordRepository.findByDomainId(domainId);
|
||||||
|
return { dnsRecords };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update DNS record
|
||||||
|
*/
|
||||||
|
async updateDnsRecord(id: string, updates: Partial<DnsRecord>) {
|
||||||
|
const dnsRecord = await this.dnsRecordRepository.findOne({ id });
|
||||||
|
if (!dnsRecord) throw new BadRequestException(DnsRecordMessage.DNS_RECORD_NOT_FOUND);
|
||||||
|
|
||||||
|
this.em.assign(dnsRecord, updates);
|
||||||
|
await this.em.persistAndFlush(dnsRecord);
|
||||||
|
|
||||||
|
return { dnsRecord };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************/
|
||||||
|
async deleteDnsRecord(id: string) {
|
||||||
|
const dnsRecord = await this.dnsRecordRepository.findOne({ id });
|
||||||
|
if (!dnsRecord) throw new BadRequestException(DnsRecordMessage.DNS_RECORD_NOT_FOUND);
|
||||||
|
|
||||||
|
dnsRecord.isActive = false;
|
||||||
|
dnsRecord.deletedAt = new Date();
|
||||||
|
await this.em.persistAndFlush(dnsRecord);
|
||||||
|
|
||||||
|
return { message: DnsRecordMessage.DNS_RECORD_DELETED };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************/
|
||||||
|
async generateRequiredDnsRecords(domain: Domain) {
|
||||||
|
const requiredRecords: Partial<DnsRecord>[] = [];
|
||||||
|
|
||||||
|
// MX Record
|
||||||
|
requiredRecords.push({
|
||||||
|
name: domain.name,
|
||||||
|
type: DNSRecordType.MX,
|
||||||
|
value: this.mailServerDomain,
|
||||||
|
priority: 10,
|
||||||
|
isRequired: true,
|
||||||
|
description: "Mail exchange record for email delivery",
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPF Record
|
||||||
|
requiredRecords.push({
|
||||||
|
name: domain.name,
|
||||||
|
type: DNSRecordType.SPF,
|
||||||
|
value: this.spfRecord,
|
||||||
|
isRequired: true,
|
||||||
|
description: "Sender Policy Framework record",
|
||||||
|
});
|
||||||
|
|
||||||
|
// DMARC Record
|
||||||
|
requiredRecords.push({
|
||||||
|
name: `_dmarc.${domain.name}`,
|
||||||
|
type: DNSRecordType.DMARC,
|
||||||
|
value: this.dmarcRecord,
|
||||||
|
isRequired: true,
|
||||||
|
description: "DMARC policy record",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dnsRecords: DnsRecord[] = [];
|
||||||
|
|
||||||
|
for (const recordData of requiredRecords) {
|
||||||
|
const dnsRecord = this.dnsRecordRepository.create({
|
||||||
|
name: recordData.name!,
|
||||||
|
type: recordData.type!,
|
||||||
|
value: recordData.value!,
|
||||||
|
domain,
|
||||||
|
isRequired: recordData.isRequired ?? true,
|
||||||
|
description: recordData.description,
|
||||||
|
priority: recordData.priority,
|
||||||
|
ttl: recordData.ttl,
|
||||||
|
});
|
||||||
|
dnsRecords.push(dnsRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(dnsRecords);
|
||||||
|
this.logger.log(`Generated ${dnsRecords.length} required DNS records for ${domain.name}`);
|
||||||
|
|
||||||
|
return { dnsRecords };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create DKIM record
|
||||||
|
*/
|
||||||
|
async createDKIMRecord(domain: Domain, selector: string, dkimName: string, dkimValue: string, wildduckId: string) {
|
||||||
|
const dkimRecord = this.dnsRecordRepository.create({
|
||||||
|
name: dkimName,
|
||||||
|
type: DNSRecordType.DKIM,
|
||||||
|
value: dkimValue,
|
||||||
|
domain,
|
||||||
|
isRequired: true,
|
||||||
|
description: `DKIM public key for selector ${selector}`,
|
||||||
|
wildduckId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(dkimRecord);
|
||||||
|
this.logger.log(`DKIM record created for ${domain.name} with selector ${selector}`);
|
||||||
|
|
||||||
|
return { dkimRecord };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate DKIM keys
|
||||||
|
*/
|
||||||
|
async generateDKIMKeysByMailServer(domainName: string, selector: string) {
|
||||||
|
const data = await firstValueFrom(this.mailServerService.dkim.createDKIMKey({ domain: domainName, selector }));
|
||||||
|
return { publicKey: data.publicKey, privateKey: data.dnsTxt.value, dnsTxt: data.dnsTxt, wildduckId: data.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate DKIM keys native
|
||||||
|
*/
|
||||||
|
async generateDKIMKeysByNative() {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicKeyEncoding: {
|
||||||
|
type: "spki",
|
||||||
|
format: "pem",
|
||||||
|
},
|
||||||
|
privateKeyEncoding: {
|
||||||
|
type: "pkcs8",
|
||||||
|
format: "pem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract the public key without headers and newlines for DNS
|
||||||
|
const publicKeyForDNS = publicKey
|
||||||
|
.replace(/-----BEGIN PUBLIC KEY-----/g, "")
|
||||||
|
.replace(/-----END PUBLIC KEY-----/g, "")
|
||||||
|
.replace(/\n/g, "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey,
|
||||||
|
publicKey: publicKeyForDNS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify domain DNS configuration
|
||||||
|
*/
|
||||||
|
async verifyDomainDNS(domain: Domain) {
|
||||||
|
const dnsRecords = await this.dnsRecordRepository.findByDomainId(domain.id);
|
||||||
|
const requiredRecords = dnsRecords.filter((record) => record.isRequired && record.isActive);
|
||||||
|
|
||||||
|
let allVerified = true;
|
||||||
|
|
||||||
|
for (const record of requiredRecords) {
|
||||||
|
const isVerified = await this.verifyDnsRecord(record);
|
||||||
|
if (!isVerified) {
|
||||||
|
allVerified = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allVerified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify individual DNS record
|
||||||
|
*/
|
||||||
|
async verifyDnsRecord(dnsRecord: DnsRecord) {
|
||||||
|
try {
|
||||||
|
dnsRecord.verificationAttempts += 1;
|
||||||
|
dnsRecord.lastCheckedAt = new Date();
|
||||||
|
|
||||||
|
let isVerified = false;
|
||||||
|
|
||||||
|
switch (dnsRecord.type) {
|
||||||
|
case DNSRecordType.TXT:
|
||||||
|
case DNSRecordType.SPF:
|
||||||
|
case DNSRecordType.DMARC:
|
||||||
|
case DNSRecordType.DKIM:
|
||||||
|
isVerified = await this.verifyTxtRecord(dnsRecord);
|
||||||
|
break;
|
||||||
|
case DNSRecordType.MX:
|
||||||
|
isVerified = await this.verifyMxRecord(dnsRecord);
|
||||||
|
break;
|
||||||
|
case DNSRecordType.CNAME:
|
||||||
|
isVerified = await this.verifyCnameRecord(dnsRecord);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(`DNS record type ${dnsRecord.type} verification not implemented`);
|
||||||
|
isVerified = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVerified) {
|
||||||
|
dnsRecord.status = VerificationStatus.VERIFIED;
|
||||||
|
dnsRecord.lastVerifiedAt = new Date();
|
||||||
|
dnsRecord.errorMessage = undefined;
|
||||||
|
} else {
|
||||||
|
dnsRecord.status = VerificationStatus.FAILED;
|
||||||
|
dnsRecord.errorMessage = "DNS record not found or value mismatch";
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(dnsRecord);
|
||||||
|
return isVerified;
|
||||||
|
} catch (error) {
|
||||||
|
dnsRecord.status = VerificationStatus.FAILED;
|
||||||
|
dnsRecord.errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
await this.em.persistAndFlush(dnsRecord);
|
||||||
|
|
||||||
|
this.logger.error(`DNS verification failed for ${dnsRecord.name}:`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify TXT record
|
||||||
|
*/
|
||||||
|
private async verifyTxtRecord(dnsRecord: DnsRecord) {
|
||||||
|
try {
|
||||||
|
const txtRecords = await this.resolveTxt(dnsRecord.name);
|
||||||
|
const flatRecords = txtRecords.flat();
|
||||||
|
|
||||||
|
return flatRecords.some((record) => record.includes(dnsRecord.value) || record === dnsRecord.value);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`TXT record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify MX record
|
||||||
|
*/
|
||||||
|
private async verifyMxRecord(dnsRecord: DnsRecord) {
|
||||||
|
try {
|
||||||
|
const mxRecords = await this.resolveMx(dnsRecord.name);
|
||||||
|
|
||||||
|
return mxRecords.some((mx) => mx.exchange === dnsRecord.value && mx.priority === (dnsRecord.priority || 10));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`MX record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify CNAME record
|
||||||
|
*/
|
||||||
|
private async verifyCnameRecord(dnsRecord: DnsRecord) {
|
||||||
|
try {
|
||||||
|
const cnameRecords = await this.resolveCname(dnsRecord.name);
|
||||||
|
|
||||||
|
return cnameRecords.includes(dnsRecord.value);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug(`CNAME record verification failed for ${dnsRecord.name}:`, error instanceof Error ? error.message : "Unknown error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS record statistics
|
||||||
|
*/
|
||||||
|
async getDnsRecordStats(domainId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
verified: number;
|
||||||
|
pending: number;
|
||||||
|
failed: number;
|
||||||
|
required: number;
|
||||||
|
}> {
|
||||||
|
return this.dnsRecordRepository.getDomainDnsStats(domainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify all DNS records that need checking
|
||||||
|
*/
|
||||||
|
async verifyPendingDnsRecords(): Promise<void> {
|
||||||
|
const records = await this.dnsRecordRepository.findNeedingVerification();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
try {
|
||||||
|
await this.verifyDnsRecord(record);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to verify DNS record ${record.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { EntityManager } from "@mikro-orm/core";
|
||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
|
|
||||||
|
import { DnsService } from "./dns.service";
|
||||||
|
import { BusinessMessage, DomainMessage, MailServerMessage } from "../../../common/enums/message.enum";
|
||||||
|
import { Business } from "../../businesses/entities/business.entity";
|
||||||
|
import { MailServerService } from "../../mail-server/services/mail-server.service";
|
||||||
|
import { formatDomainMessage } from "../../utils/services/message.utils";
|
||||||
|
import { CreateDomainDto } from "../DTO/create-domain.dto";
|
||||||
|
import { UpdateDomainDto } from "../DTO/update-domain.dto";
|
||||||
|
import { DnsRecord } from "../entities/dns-record.entity";
|
||||||
|
import { Domain } from "../entities/domain.entity";
|
||||||
|
import { DomainStatus, VerificationStatus } from "../enums/domain-status.enum";
|
||||||
|
import { DomainRepository } from "../repositories/domain.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DomainsService {
|
||||||
|
private readonly logger = new Logger(DomainsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly domainRepository: DomainRepository,
|
||||||
|
private readonly em: EntityManager,
|
||||||
|
private readonly dnsService: DnsService,
|
||||||
|
private readonly mailServerService: MailServerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new domain
|
||||||
|
*/
|
||||||
|
async createDomain(createDomainDto: CreateDomainDto, businessId: string) {
|
||||||
|
const { name, notes } = createDomainDto;
|
||||||
|
|
||||||
|
const existBusinessDomain = await this.domainRepository.findOne({ business: { id: businessId }, deletedAt: null });
|
||||||
|
if (existBusinessDomain) throw new BadRequestException(DomainMessage.BUSINESS_ALREADY_HAS_DOMAIN);
|
||||||
|
|
||||||
|
// Check if domain already exists
|
||||||
|
const existingDomain = await this.domainRepository.findByName(name);
|
||||||
|
if (existingDomain) throw new BadRequestException(formatDomainMessage(DomainMessage.DOMAIN_ALREADY_EXISTS, { name }));
|
||||||
|
|
||||||
|
// Verify business exists
|
||||||
|
const business = await this.em.findOne(Business, { id: businessId });
|
||||||
|
if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND);
|
||||||
|
|
||||||
|
const domain = this.domainRepository.create({
|
||||||
|
name,
|
||||||
|
business,
|
||||||
|
notes,
|
||||||
|
dkimEnabled: true,
|
||||||
|
dkimSelector: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(domain);
|
||||||
|
|
||||||
|
// Generate required DNS records (MX, SPF, DMARC)
|
||||||
|
await this.dnsService.generateRequiredDnsRecords(domain);
|
||||||
|
|
||||||
|
// Generate and create DKIM record
|
||||||
|
try {
|
||||||
|
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, "default");
|
||||||
|
|
||||||
|
// Update domain with DKIM keys
|
||||||
|
domain.dkimPrivateKey = dkimKeys.privateKey;
|
||||||
|
domain.dkimPublicKey = dkimKeys.publicKey;
|
||||||
|
|
||||||
|
// Create DKIM DNS record
|
||||||
|
await this.dnsService.createDKIMRecord(domain, "default", dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(domain);
|
||||||
|
|
||||||
|
this.logger.log(`DKIM automatically enabled for domain ${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to create DKIM for domain ${name}:`, error);
|
||||||
|
// Continue with domain creation even if DKIM fails
|
||||||
|
domain.dkimEnabled = false;
|
||||||
|
await this.em.persistAndFlush(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial verification
|
||||||
|
|
||||||
|
this.logger.log(`Domain ${name} created for business ${businessId} with all required records`);
|
||||||
|
|
||||||
|
// Get updated DNS records including DKIM
|
||||||
|
const { dnsRecords: allDnsRecords } = await this.dnsService.getDomainDnsRecords(domain.id);
|
||||||
|
|
||||||
|
// Return domain setup response with complete instructions
|
||||||
|
return this.formatDomainSetupResponse(domain, allDnsRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain by ID
|
||||||
|
*/
|
||||||
|
async getDomainById(id: string): Promise<Domain> {
|
||||||
|
const domain = await this.domainRepository.findOne({ id, deletedAt: null }, { populate: ["business", "dnsRecords"] });
|
||||||
|
|
||||||
|
if (!domain) throw new NotFoundException(DomainMessage.DOMAIN_NOT_FOUND);
|
||||||
|
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain by name
|
||||||
|
*/
|
||||||
|
async getDomainByName(name: string) {
|
||||||
|
const domain = await this.domainRepository.findByName(name);
|
||||||
|
if (!domain) throw new BadRequestException(DomainMessage.DOMAIN_NOT_FOUND);
|
||||||
|
|
||||||
|
return { domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domains for a business
|
||||||
|
*/
|
||||||
|
async getBusinessDomain(businessId: string) {
|
||||||
|
const domain = await this.domainRepository.findByBusinessId(businessId);
|
||||||
|
return { domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update domain
|
||||||
|
*/
|
||||||
|
async updateDomain(id: string, updateDomainDto: UpdateDomainDto) {
|
||||||
|
const domain = await this.getDomainById(id);
|
||||||
|
|
||||||
|
this.em.assign(domain, updateDomainDto);
|
||||||
|
|
||||||
|
await this.em.flush();
|
||||||
|
|
||||||
|
this.logger.log(`Domain ${domain.name} updated`);
|
||||||
|
return { domain, message: DomainMessage.DOMAIN_UPDATED_SUCCESSFULLY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete domain
|
||||||
|
*/
|
||||||
|
async deleteDomain(id: string) {
|
||||||
|
const domain = await this.getDomainById(id);
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
domain.deletedAt = new Date();
|
||||||
|
domain.isActive = false;
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(domain);
|
||||||
|
this.logger.log(`Domain ${domain.name} deleted`);
|
||||||
|
return { message: DomainMessage.DOMAIN_DELETED_SUCCESSFULLY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify domain ownership
|
||||||
|
*/
|
||||||
|
async verifyDomain(id: string) {
|
||||||
|
const domain = await this.getDomainById(id);
|
||||||
|
|
||||||
|
// Check DNS records for verification
|
||||||
|
const isVerified = await this.dnsService.verifyDomainDNS(domain);
|
||||||
|
|
||||||
|
if (isVerified) {
|
||||||
|
domain.status = DomainStatus.VERIFIED;
|
||||||
|
domain.isVerified = true;
|
||||||
|
domain.verifiedAt = new Date();
|
||||||
|
|
||||||
|
// Setup domain in mail server
|
||||||
|
await this.setupDomainInMailServer(domain);
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(domain);
|
||||||
|
this.logger.log(`Domain ${domain.name} verified successfully`);
|
||||||
|
} else {
|
||||||
|
domain.status = DomainStatus.FAILED;
|
||||||
|
await this.em.persistAndFlush(domain);
|
||||||
|
this.logger.warn(`Domain ${domain.name} verification failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup domain in mail server
|
||||||
|
*/
|
||||||
|
private async setupDomainInMailServer(domain: Domain) {
|
||||||
|
try {
|
||||||
|
// Create DKIM keys if enabled
|
||||||
|
if (domain.dkimEnabled && domain.dkimSelector) {
|
||||||
|
const dkimKey = await firstValueFrom(
|
||||||
|
this.mailServerService.dkim.createDKIMKey({
|
||||||
|
domain: domain.name,
|
||||||
|
selector: domain.dkimSelector,
|
||||||
|
description: `DKIM for ${domain.name}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!dkimKey.success) throw new BadRequestException(MailServerMessage.FAILED_TO_CREATE_DKIM_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Mail server setup completed for domain ${domain.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to setup domain ${domain.name} in mail server:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable DKIM for domain
|
||||||
|
*/
|
||||||
|
async enableDKIM(id: string, selector?: string) {
|
||||||
|
const domain = await this.getDomainById(id);
|
||||||
|
|
||||||
|
const dkimSelector = selector || "default";
|
||||||
|
|
||||||
|
// Generate DKIM keys
|
||||||
|
const dkimKeys = await this.dnsService.generateDKIMKeysByMailServer(domain.name, dkimSelector);
|
||||||
|
|
||||||
|
domain.dkimEnabled = true;
|
||||||
|
domain.dkimSelector = dkimSelector;
|
||||||
|
domain.dkimPrivateKey = dkimKeys.privateKey;
|
||||||
|
domain.dkimPublicKey = dkimKeys.publicKey;
|
||||||
|
|
||||||
|
// Create DKIM DNS record
|
||||||
|
await this.dnsService.createDKIMRecord(domain, dkimSelector, dkimKeys.dnsTxt.name, dkimKeys.dnsTxt.value, dkimKeys.wildduckId);
|
||||||
|
|
||||||
|
await this.em.persistAndFlush(domain);
|
||||||
|
this.logger.log(`DKIM enabled for domain ${domain.name}`);
|
||||||
|
|
||||||
|
return { domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain statistics
|
||||||
|
*/
|
||||||
|
async getDomainStats(businessId: string) {
|
||||||
|
return this.domainRepository.getBusinessDomainStats(businessId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check all domains that need verification
|
||||||
|
*/
|
||||||
|
async checkDomainsNeedingVerification() {
|
||||||
|
const domain = await this.domainRepository.findNeedingCheck();
|
||||||
|
|
||||||
|
if (!domain) return { message: DomainMessage.NO_DOMAIN_FOUND };
|
||||||
|
await this.verifyDomain(domain.id);
|
||||||
|
|
||||||
|
return { message: DomainMessage.DOMAIN_CHECKED, domain: domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain setup instructions
|
||||||
|
*/
|
||||||
|
async getDomainSetupInstructions(domainId: string) {
|
||||||
|
const domain = await this.getDomainById(domainId);
|
||||||
|
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId);
|
||||||
|
|
||||||
|
return this.formatDomainSetupResponse(domain, dnsRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS records for domain
|
||||||
|
*/
|
||||||
|
async getDomainDnsRecords(domainId: string) {
|
||||||
|
// Verify domain exists
|
||||||
|
await this.getDomainById(domainId);
|
||||||
|
return this.dnsService.getDomainDnsRecords(domainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check domain verification status
|
||||||
|
*/
|
||||||
|
async checkDomainVerificationStatus(domainId: string) {
|
||||||
|
await this.getDomainById(domainId);
|
||||||
|
const { dnsRecords } = await this.dnsService.getDomainDnsRecords(domainId);
|
||||||
|
|
||||||
|
const verificationStatuses = [];
|
||||||
|
let verified = 0;
|
||||||
|
let pending = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
|
for (const record of dnsRecords) {
|
||||||
|
// Verify each DNS record
|
||||||
|
await this.dnsService.verifyDnsRecord(record);
|
||||||
|
|
||||||
|
const status = {
|
||||||
|
recordId: record.id,
|
||||||
|
name: record.name,
|
||||||
|
type: record.type,
|
||||||
|
expectedValue: record.value,
|
||||||
|
currentValue: undefined,
|
||||||
|
status: record.status,
|
||||||
|
lastChecked: record.lastCheckedAt || new Date(),
|
||||||
|
errorMessage: record.errorMessage,
|
||||||
|
isCorrect: record.status === VerificationStatus.VERIFIED,
|
||||||
|
};
|
||||||
|
|
||||||
|
verificationStatuses.push(status);
|
||||||
|
|
||||||
|
if (record.status === VerificationStatus.VERIFIED) {
|
||||||
|
verified++;
|
||||||
|
} else if (record.status === VerificationStatus.FAILED) {
|
||||||
|
failed++;
|
||||||
|
if (record.errorMessage) {
|
||||||
|
recommendations.push(
|
||||||
|
formatDomainMessage(DomainMessage.RECORD_NEEDS_FIXING, {
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
error: record.errorMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pending++;
|
||||||
|
recommendations.push(
|
||||||
|
formatDomainMessage(DomainMessage.RECORD_PENDING_VERIFICATION_DETAIL, {
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVerified = failed === 0 && pending === 0 && verified > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dnsRecords: verificationStatuses,
|
||||||
|
overallStatus: {
|
||||||
|
isVerified,
|
||||||
|
verified,
|
||||||
|
pending,
|
||||||
|
failed,
|
||||||
|
total: dnsRecords.length,
|
||||||
|
lastChecked: new Date(),
|
||||||
|
},
|
||||||
|
recommendations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format domain setup response with instructions
|
||||||
|
*/
|
||||||
|
private formatDomainSetupResponse(domain: Domain, dnsRecords: DnsRecord[]) {
|
||||||
|
const dnsInstructions = dnsRecords.map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
type: record.type,
|
||||||
|
value: record.value,
|
||||||
|
ttl: record.ttl,
|
||||||
|
priority: record.priority,
|
||||||
|
description:
|
||||||
|
record.description ||
|
||||||
|
formatDomainMessage(DomainMessage.RECORD_DESCRIPTION_EMAIL_FUNCTIONALITY, {
|
||||||
|
type: record.type,
|
||||||
|
}),
|
||||||
|
isRequired: record.isRequired,
|
||||||
|
status: record.status,
|
||||||
|
instructions: this.generateRecordInstructions(record),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const verified = dnsRecords.filter((r) => r.status === VerificationStatus.VERIFIED).length;
|
||||||
|
const pending = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING).length;
|
||||||
|
const failed = dnsRecords.filter((r) => r.status === VerificationStatus.FAILED).length;
|
||||||
|
|
||||||
|
const nextSteps = this.generateNextSteps(domain, dnsRecords);
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
dnsRecords: dnsInstructions,
|
||||||
|
setupStatus: {
|
||||||
|
isComplete: failed === 0 && pending === 0 && verified > 0,
|
||||||
|
verified,
|
||||||
|
pending,
|
||||||
|
failed,
|
||||||
|
total: dnsRecords.length,
|
||||||
|
},
|
||||||
|
nextSteps,
|
||||||
|
estimatedPropagationTime: DomainMessage.ESTIMATED_PROPAGATION_TIME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate instructions for a specific DNS record
|
||||||
|
*/
|
||||||
|
private generateRecordInstructions(record: DnsRecord): string {
|
||||||
|
switch (record.type) {
|
||||||
|
case "MX":
|
||||||
|
return formatDomainMessage(DomainMessage.ADD_MX_RECORD, {
|
||||||
|
name: record.name,
|
||||||
|
value: record.value,
|
||||||
|
priority: record.priority,
|
||||||
|
});
|
||||||
|
case "TXT":
|
||||||
|
case "SPF":
|
||||||
|
return formatDomainMessage(DomainMessage.ADD_TXT_RECORD, {
|
||||||
|
name: record.name,
|
||||||
|
value: record.value,
|
||||||
|
});
|
||||||
|
case "DKIM":
|
||||||
|
return formatDomainMessage(DomainMessage.ADD_DKIM_RECORD, {
|
||||||
|
name: record.name,
|
||||||
|
value: record.value,
|
||||||
|
});
|
||||||
|
case "DMARC":
|
||||||
|
return formatDomainMessage(DomainMessage.ADD_DMARC_RECORD, {
|
||||||
|
name: record.name,
|
||||||
|
value: record.value,
|
||||||
|
});
|
||||||
|
case "CNAME":
|
||||||
|
return formatDomainMessage(DomainMessage.ADD_CNAME_RECORD, {
|
||||||
|
name: record.name,
|
||||||
|
value: record.value,
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return formatDomainMessage(DomainMessage.ADD_GENERIC_RECORD, {
|
||||||
|
type: record.type,
|
||||||
|
name: record.name,
|
||||||
|
value: record.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate next steps for domain setup
|
||||||
|
*/
|
||||||
|
private generateNextSteps(domain: Domain, dnsRecords: DnsRecord[]): string[] {
|
||||||
|
const steps: string[] = [];
|
||||||
|
|
||||||
|
if (domain.status === DomainStatus.PENDING) {
|
||||||
|
steps.push(`1. ${DomainMessage.STEP_LOGIN_DNS_PANEL}`);
|
||||||
|
steps.push(`2. ${DomainMessage.STEP_ADD_ALL_RECORDS}`);
|
||||||
|
steps.push(`3. ${DomainMessage.STEP_INCLUDE_ALL_TYPES}`);
|
||||||
|
steps.push(`4. ${DomainMessage.STEP_WAIT_PROPAGATION}`);
|
||||||
|
steps.push(`5. ${DomainMessage.STEP_USE_CHECK_ENDPOINT}`);
|
||||||
|
steps.push(`6. ${DomainMessage.STEP_DOMAIN_READY}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRecords = dnsRecords.filter((r) => r.status === VerificationStatus.PENDING);
|
||||||
|
if (pendingRecords.length > 0) {
|
||||||
|
steps.push(`📋 ${formatDomainMessage(DomainMessage.RECORDS_PENDING_VERIFICATION, { count: pendingRecords.length })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredRecords = dnsRecords.filter((r) => r.isRequired);
|
||||||
|
if (requiredRecords.length > 0) {
|
||||||
|
steps.push(`⚠️ ${formatDomainMessage(DomainMessage.ALL_REQUIRED_RECORDS_MUST_BE_CONFIGURED, { count: requiredRecords.length })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain.status === DomainStatus.VERIFIED) {
|
||||||
|
steps.push(`✅ ${DomainMessage.DOMAIN_FULLY_VERIFIED}`);
|
||||||
|
steps.push(`🚀 ${DomainMessage.EMAIL_ACCOUNTS_READY}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsBoolean, IsEmail, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateAddressDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Email address to use as an alias",
|
||||||
|
example: "alias@example.com",
|
||||||
|
})
|
||||||
|
@IsEmail()
|
||||||
|
address: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Whether this is the default address for the user",
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
main?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateAddressDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Set as the default address for the user",
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
main: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsEnum, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export enum AuthScope {
|
||||||
|
MASTER = "master",
|
||||||
|
IMAP = "imap",
|
||||||
|
POP3 = "pop3",
|
||||||
|
SMTP = "smtp",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthenticateDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Username or email address of the user",
|
||||||
|
example: "testuser",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: "Password for the user (master or application specific)",
|
||||||
|
example: "secretpassword123",
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Scope to request for",
|
||||||
|
enum: AuthScope,
|
||||||
|
default: AuthScope.MASTER,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AuthScope)
|
||||||
|
scope?: AuthScope;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Application type this authentication is made from",
|
||||||
|
example: "API",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
protocol?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Session identifier for logging",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sess?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "IP address the request was made from",
|
||||||
|
example: "192.168.1.1",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ip?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsArray, IsBoolean, IsEmail, IsNumber, IsOptional, IsString, IsUrl } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateUserDto {
|
||||||
|
@ApiProperty({ description: "Username for the user (letters and numbers only)", example: "testuser" })
|
||||||
|
@IsString()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "Password for the user", example: "secretpassword123" })
|
||||||
|
@IsString()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Main email address for the user", example: "testuser@example.com" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "If true, do not set up an address for the user", default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
emptyAddress?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Display name for the user", example: "Test User" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: "Array of email addresses to forward all messages to",
|
||||||
|
example: ["forward1@example.com", "forward2@example.com"],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsEmail({}, { each: true })
|
||||||
|
forward?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "URL to upload all messages to", example: "https://example.com/webhook" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl()
|
||||||
|
targetUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Maximum storage in bytes allowed for this user", example: 1073741824 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
quota?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Default retention time in milliseconds for mailboxes", example: 2592000000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
retention?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Language code for the user", example: "en" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
language?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Maximum number of recipients allowed to send mail to in 24h", example: 2000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
recipients?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Maximum number of forwarded emails in 24h", example: 2000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
forwards?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Array of tags to associate with the user", example: ["vip", "customer"] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "PGP public key for encryption" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
pubKey?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Whether to encrypt stored messages", default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
encryptMessages?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Whether to encrypt forwarded messages", default: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
encryptForwarded?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sess?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "IP address the request was made from", example: "192.168.1.1" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ip?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateDKIMDto {
|
||||||
|
@ApiProperty({ description: "Domain name", example: "example.com" })
|
||||||
|
@IsString()
|
||||||
|
domain: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: "DKIM selector", example: "default" })
|
||||||
|
@IsString()
|
||||||
|
selector: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Description for the DKIM key" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Session identifier for logging" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sess?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "IP address" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ip?: string;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user