Init
This commit is contained in:
commit
87224f0816
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
||||
# Docker
|
||||
PROJECT_NAME=mybot
|
||||
|
||||
# Database
|
||||
DB_PORT=5432
|
||||
DB_NAME=mybot
|
||||
DB_USERNAME=mybot
|
||||
DB_PASSWORD=Strong_Password_123456
|
||||
|
||||
# Back
|
||||
BACK_PORT=4000
|
||||
JWT_SECRET=SECRET_STRING # How to generate: require('crypto').randomBytes(32).toString('hex')
|
||||
TELEGRAM_BOT_TOKEN=...
|
||||
|
||||
# Front
|
||||
FRONT_PORT=3000
|
||||
API_URL=http://127.0.0.1:${BACK_PORT}
|
||||
|
||||
# Backup (optional)
|
||||
BACKUP_DIR=/home/${PROJECT_NAME}/backups
|
||||
BACKUP_TELEGRAM_BOT_TOKEN=1234567890:AAbbCCddEeffGG5hhj2kOpqqRRSssttuvv
|
||||
BACKUP_TELEGRAM_CHAT_ID=-1234567898765
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
92
README.md
Normal file
92
README.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Telegram Bot with Admin Panel — Starter
|
||||
|
||||
A starter template for building a Telegram bot with a web-based admin panel.
|
||||
|
||||
**Stack:**
|
||||
- **Backend:** NestJS, TypeORM, PostgreSQL, Grammy (Telegram Bot)
|
||||
- **Frontend:** React, Vite, Mantine UI
|
||||
- **Infrastructure:** Docker Compose
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Set up environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your values (database credentials, Telegram bot token, etc.)
|
||||
```
|
||||
|
||||
### 2. Run with Docker (development)
|
||||
|
||||
```bash
|
||||
docker compose -f compose.dev.yml up -d
|
||||
```
|
||||
|
||||
### 3. Create super admin
|
||||
|
||||
```bash
|
||||
docker exec -it ${PROJECT_NAME}_back sh
|
||||
pnpm console admin create {username} {email}
|
||||
```
|
||||
|
||||
### 4. Run frontend (development)
|
||||
|
||||
```bash
|
||||
cd front
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 5. Run migrations
|
||||
|
||||
Migrations are run automatically on container start. To run manually:
|
||||
|
||||
```bash
|
||||
cd back
|
||||
pnpm migration:run
|
||||
```
|
||||
|
||||
### Create a new migration
|
||||
|
||||
```bash
|
||||
cd back
|
||||
pnpm migration:generate --name=my-migration-name
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── back/ # NestJS backend
|
||||
│ └── src/
|
||||
│ ├── admin-console/ # CLI commands (create admin)
|
||||
│ ├── admins/ # Admin user management
|
||||
│ ├── auth/ # JWT authentication
|
||||
│ ├── bot/ # Telegram bot service
|
||||
│ ├── common/ # Shared utilities, decorators, entities
|
||||
│ ├── config/ # Environment validation
|
||||
│ └── database/ # TypeORM setup & migrations
|
||||
├── front/ # React admin panel
|
||||
│ └── src/
|
||||
│ ├── api/ # API client (axios, react-query)
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── guards/ # Auth & guest route guards
|
||||
│ ├── hooks/ # Custom hooks (auth, API)
|
||||
│ ├── layouts/ # Auth & dashboard layouts
|
||||
│ ├── pages/ # Page components
|
||||
│ ├── providers/ # Context providers
|
||||
│ ├── routes/ # Routing configuration
|
||||
│ └── theme/ # Mantine theme customization
|
||||
├── compose.yml # Docker Compose (production)
|
||||
├── compose.dev.yml # Docker Compose (development)
|
||||
└── backup.sh # Database & uploads backup script
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- JWT-based admin authentication
|
||||
- Admin CRUD with role management (superadmin / admin)
|
||||
- Telegram bot skeleton with Grammy
|
||||
- Data table with pagination, sorting, filtering
|
||||
- Dark/light theme
|
||||
- Docker-based development & production setup
|
||||
- Database backup with Telegram notification
|
||||
6
back/.dockerignore
Normal file
6
back/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
*.log
|
||||
25
back/.eslintrc.js
Normal file
25
back/.eslintrc.js
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
||||
58
back/.gitignore
vendored
Normal file
58
back/.gitignore
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
uploads
|
||||
274
back/.prettierignore
Normal file
274
back/.prettierignore
Normal file
@ -0,0 +1,274 @@
|
||||
### VisualStudioCode template
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### JetBrains template
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/
|
||||
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
### Linux template
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### Node template
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-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
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
.pnp.*
|
||||
|
||||
### Vim template
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
# Persistent undo
|
||||
[._]*.un~
|
||||
|
||||
### macOS template
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### SublimeText template
|
||||
# Cache files for Sublime Text
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
|
||||
# Workspace files are user-specific
|
||||
*.sublime-workspace
|
||||
|
||||
# Project files should be checked into the repository, unless a significant
|
||||
# proportion of contributors will probably not be using Sublime Text
|
||||
# *.sublime-project
|
||||
|
||||
# SFTP configuration file
|
||||
sftp-config.json
|
||||
sftp-config-alt*.json
|
||||
|
||||
# Package control specific files
|
||||
Package Control.last-run
|
||||
Package Control.ca-list
|
||||
Package Control.ca-bundle
|
||||
Package Control.system-ca-bundle
|
||||
Package Control.cache/
|
||||
Package Control.ca-certs/
|
||||
Package Control.merged-ca-bundle
|
||||
Package Control.user-ca-bundle
|
||||
oscrypto-ca-bundle.crt
|
||||
bh_unicode_properties.cache
|
||||
|
||||
# Sublime-github package stores a github token in this file
|
||||
# https://packagecontrol.io/packages/sublime-github
|
||||
GitHub.sublime-settings
|
||||
|
||||
yarn.lock
|
||||
*.module.s[ac]ss.d.ts
|
||||
|
||||
src/shared/metronic
|
||||
packages/metronic
|
||||
src/shared/providers/i18n/locales-gen/compiled
|
||||
8
back/.prettierrc
Normal file
8
back/.prettierrc
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"quoteProps": "consistent",
|
||||
"semi": true,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
}
|
||||
23
back/Dockerfile
Normal file
23
back/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
FROM node:22.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir /app/node_modules
|
||||
RUN chown -R node:node /app/node_modules
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN npm install -g pnpm@10.6.0
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
COPY tsconfig.json .
|
||||
|
||||
RUN \
|
||||
if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \
|
||||
else echo "pnpm-lock.yaml not found." && exit 1; \
|
||||
fi
|
||||
|
||||
RUN pnpm add -g @nestjs/cli
|
||||
|
||||
COPY . .
|
||||
12
back/eslint.config.mjs
Normal file
12
back/eslint.config.mjs
Normal file
@ -0,0 +1,12 @@
|
||||
import globals from "globals";
|
||||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
{files: ["**/*.{js,mjs,cjs,ts}"]},
|
||||
{languageOptions: { globals: globals.browser }},
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
];
|
||||
8
back/nest-cli.json
Normal file
8
back/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
97
back/package.json
Normal file
97
back/package.json
Normal file
@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "back",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm": "ts-node -r tsconfig-paths/register node_modules/typeorm/cli.js",
|
||||
"migration:create": "npm run typeorm migration:create src/database/migrations/$npm_config_name",
|
||||
"migration:generate": "npm run typeorm migration:generate -- -p -d src/data-source.ts src/database/migrations/$npm_config_name",
|
||||
"migration:run": "npm run typeorm -- migration:run -d src/data-source.ts",
|
||||
"migration:revert": "npm run typeorm -- migration:revert -d src/data-source.ts",
|
||||
"console:dev": "ts-node -r tsconfig-paths/register src/console.ts",
|
||||
"console": "node dist/console.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/mapped-types": "^2.0.5",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"grammy": "^1.36.1",
|
||||
"helmet": "^7.1.0",
|
||||
"multer": "1.4.5-lts.2",
|
||||
"nestjs-console": "^9.0.0",
|
||||
"pg": "^8.13.1",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"globals": "^15.14.0",
|
||||
"jest": "^29.5.0",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
"ts-jest": "^29.1.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "5.5.4",
|
||||
"typescript-eslint": "^8.18.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"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
||||
}
|
||||
7721
back/pnpm-lock.yaml
Normal file
7721
back/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
30
back/src/admin-console/admin-console.module.ts
Normal file
30
back/src/admin-console/admin-console.module.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConsoleModule } from 'nestjs-console';
|
||||
import { AdminConsoleService } from './admin-console.service';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Password } from 'src/admins/entities/password.entity';
|
||||
import { AuthService } from 'src/auth/auth.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DbTransactionFactory } from 'src/database/transaction-factory';
|
||||
import { AdminsService } from 'src/admins/admins.service';
|
||||
import { Admin } from 'src/admins/entities/admin.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Admin,
|
||||
Password,
|
||||
]),
|
||||
ConsoleModule,
|
||||
],
|
||||
providers: [
|
||||
AdminConsoleService,
|
||||
AdminsService,
|
||||
AuthService,
|
||||
JwtService,
|
||||
ConfigService,
|
||||
DbTransactionFactory,
|
||||
]
|
||||
})
|
||||
export class AdminConsoleModule {}
|
||||
110
back/src/admin-console/admin-console.service.ts
Normal file
110
back/src/admin-console/admin-console.service.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConsoleService } from 'nestjs-console';
|
||||
import { AdminsService } from 'src/admins/admins.service';
|
||||
import { Roles } from 'src/admins/entities/admin.entity';
|
||||
import { AuthService } from 'src/auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminConsoleService {
|
||||
private readonly _logger = new Logger(AdminConsoleService.name);
|
||||
|
||||
constructor(
|
||||
private readonly consoleService: ConsoleService,
|
||||
private readonly adminsService: AdminsService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const cli = this.consoleService.getCli();
|
||||
|
||||
const bossGroupCommand = this.consoleService.createGroupCommand(
|
||||
{
|
||||
command: 'admin',
|
||||
description: 'A command for admin commands'
|
||||
},
|
||||
cli
|
||||
);
|
||||
|
||||
this.consoleService.createCommand(
|
||||
{
|
||||
command: 'create <username> <email>',
|
||||
description: 'Create super admin by username and email'
|
||||
},
|
||||
this.createBoss,
|
||||
bossGroupCommand
|
||||
);
|
||||
}
|
||||
|
||||
createBoss = async (username: string, email: string): Promise<void> => {
|
||||
const user = await this.adminsService.findOneByEmail(email);
|
||||
this._logger.log(`Creating super admin ${username} with email ${email}`);
|
||||
|
||||
if (user) {
|
||||
this._logger.error(`User with email ${email} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const password = this.generateRandomPassword(8, 8, 4);
|
||||
|
||||
await this.authService.register(
|
||||
null,
|
||||
{
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
role: Roles.SuperAdmin,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`╔═══════════════════════════════════════════════════╗`);
|
||||
console.log(`║ The SUPER ADMIN credentials: ║`);
|
||||
console.log(`║ ➲ Login: ${email}${this.setSpaces(41, email.length)}║`);
|
||||
console.log(`║ ➲ Password: ${password}${this.setSpaces(38, password.length)}║`);
|
||||
console.log(`╚═══════════════════════════════════════════════════╝`);
|
||||
} catch (err) {
|
||||
this._logger.error(`Something went wrong: ${JSON.stringify(err)}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// utils
|
||||
private setSpaces = (def: number, counts: number) => {
|
||||
let spaces = '';
|
||||
for (let i = 0; i < def - counts; i++) spaces += ' ';
|
||||
return spaces;
|
||||
};
|
||||
|
||||
private generateRandomPassword = (letters: number, numbers: number, either: number) => {
|
||||
const chars = [
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
||||
"0123456789",
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
];
|
||||
|
||||
return this.shuffle([letters, numbers, either].map(
|
||||
(len, i) => Array(len).fill(chars[i]).map((x: string) => this.randomCharFrom(x)).join('')
|
||||
).concat().join('').split('')).join('')
|
||||
}
|
||||
|
||||
private randInt = (thisMax: number) => {
|
||||
let umax = Math.pow(2, 32);
|
||||
let max = umax - (umax % thisMax);
|
||||
let r = new Uint32Array(1);
|
||||
do {
|
||||
crypto.getRandomValues(r);
|
||||
} while (r[0] > max);
|
||||
return r[0] % thisMax;
|
||||
}
|
||||
|
||||
private randomCharFrom(chars: string) {
|
||||
return chars[this.randInt(chars.length)];
|
||||
}
|
||||
|
||||
// https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
|
||||
private shuffle = (arr: string[]) => {
|
||||
for (let i = 0, n = arr.length; i < n - 2; i++) {
|
||||
let j = this.randInt(n - i);
|
||||
[arr[j], arr[i]] = [arr[i], arr[j]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
55
back/src/admins/admins.controller.ts
Normal file
55
back/src/admins/admins.controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Controller, Get, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
|
||||
import { AdminsService } from './admins.service';
|
||||
import { UpdateAdminDto } from './dto/update-admin.dto';
|
||||
import { AuthGuard } from 'src/auth/auth.guard';
|
||||
import { CurrentAdmin } from 'src/common/decorators/current-admin.decorator';
|
||||
import { IPagination, PaginationParams } from 'src/common/decorators/pagination-params.decorator';
|
||||
import { ISorting, SortingParams } from 'src/common/decorators/sorting-params.decorator';
|
||||
import { FilteringParams, IFiltering } from 'src/common/decorators/filtering-params.decorator';
|
||||
import { Admin } from './entities/admin.entity';
|
||||
|
||||
@Controller('admins')
|
||||
export class AdminsController {
|
||||
constructor(private readonly adminsService: AdminsService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get()
|
||||
findChunk(
|
||||
@PaginationParams() paginationParams: IPagination,
|
||||
@SortingParams(['id', 'created_at', 'updated_at']) sorting: ISorting,
|
||||
@FilteringParams(['username', 'role']) filtering: IFiltering,
|
||||
) {
|
||||
return this.adminsService.findChunk(paginationParams, sorting, filtering);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('one/:id')
|
||||
findOne(@Param('id') id: number) {
|
||||
return this.adminsService.findOne(id);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('one/:email')
|
||||
findOneByEmail(@Param('email') email: string) {
|
||||
return this.adminsService.findOneByEmail(email);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Patch('update/:id')
|
||||
update(
|
||||
@CurrentAdmin() user: Partial<Admin>,
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateAdminDto,
|
||||
) {
|
||||
return this.adminsService.update(user, +id, updateUserDto);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete(':id')
|
||||
remove(
|
||||
@CurrentAdmin() user: Admin,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.adminsService.remove(user, +id);
|
||||
}
|
||||
}
|
||||
39
back/src/admins/admins.module.ts
Normal file
39
back/src/admins/admins.module.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { AdminsService } from './admins.service';
|
||||
import { AdminsController } from './admins.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Admin } from './entities/admin.entity';
|
||||
import { Password } from './entities/password.entity';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { DbTransactionFactory } from 'src/database/transaction-factory';
|
||||
import { AuthModule } from 'src/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Admin,
|
||||
Password,
|
||||
]),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
global: true,
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '30d' },
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
forwardRef(() => AuthModule),
|
||||
],
|
||||
controllers: [AdminsController],
|
||||
providers: [
|
||||
AdminsService,
|
||||
ConfigService,
|
||||
DbTransactionFactory,
|
||||
],
|
||||
exports: [AdminsService]
|
||||
})
|
||||
export class AdminsModule { }
|
||||
190
back/src/admins/admins.service.ts
Normal file
190
back/src/admins/admins.service.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { CreateAdminDto } from './dto/create-admin.dto';
|
||||
import { UpdateAdminDto } from './dto/update-admin.dto';
|
||||
import { Admin } from './entities/admin.entity';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, IsNull } from 'typeorm';
|
||||
import { Password } from './entities/password.entity';
|
||||
import { PaginatedResource, IPagination } from 'src/common/decorators/pagination-params.decorator';
|
||||
import { ISorting } from 'src/common/decorators/sorting-params.decorator';
|
||||
import { IFiltering } from 'src/common/decorators/filtering-params.decorator';
|
||||
import { ErrorCode } from 'src/common/enums/error-code.enum';
|
||||
import { DbTransactionFactory, deleteWithTransactions, saveWithTransactions } from 'src/database/transaction-factory';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class AdminsService {
|
||||
private readonly _logger = new Logger(AdminsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Admin) private adminRepository: Repository<Admin>,
|
||||
@InjectRepository(Password) private passwordRepository: Repository<Password>,
|
||||
private transactionRunner: DbTransactionFactory,
|
||||
) { }
|
||||
|
||||
async create(
|
||||
admin: Admin | null,
|
||||
{
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
password,
|
||||
}: CreateAdminDto
|
||||
): Promise<Admin> {
|
||||
const transactionalRunner = await this.transactionRunner.createTransaction();
|
||||
|
||||
try {
|
||||
await transactionalRunner.startTransaction();
|
||||
|
||||
const existingAdmin = await this.findOneByEmail(email);
|
||||
|
||||
if (existingAdmin) {
|
||||
throw new ConflictException(ErrorCode.AdminAlreadyExists);
|
||||
}
|
||||
|
||||
const newPassword = this.passwordRepository.create({ value: password });
|
||||
|
||||
const newAdmin = await saveWithTransactions.call(
|
||||
this.adminRepository,
|
||||
{
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
password: newPassword,
|
||||
},
|
||||
transactionalRunner.transactionManager,
|
||||
);
|
||||
|
||||
delete newAdmin.password;
|
||||
|
||||
await transactionalRunner.commitTransaction();
|
||||
return newAdmin;
|
||||
} catch (error) {
|
||||
await transactionalRunner.rollbackTransaction();
|
||||
this._logger.error(error.message, error.stack);
|
||||
throw error;
|
||||
} finally {
|
||||
await transactionalRunner.releaseTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
async findChunk(
|
||||
{ limit, offset }: IPagination,
|
||||
sorting?: ISorting,
|
||||
filtering?: IFiltering,
|
||||
): Promise<PaginatedResource<Admin>> {
|
||||
const [items, totalCount] = await this.adminRepository.findAndCount({
|
||||
take: limit,
|
||||
skip: offset,
|
||||
order: sorting,
|
||||
where: filtering,
|
||||
});
|
||||
|
||||
return { totalCount, items };
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<Admin> {
|
||||
const admin = await this.adminRepository.findOne({
|
||||
where: { id: id ? id : IsNull() },
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundException(ErrorCode.AdminNotFound);
|
||||
}
|
||||
|
||||
return admin;
|
||||
}
|
||||
|
||||
async findOneByEmail(email: string): Promise<Admin> {
|
||||
const admin = await this.adminRepository.findOne({ where: { email } });
|
||||
return admin;
|
||||
}
|
||||
|
||||
async findOneByEmailWithPassword(email: string): Promise<Admin> {
|
||||
const admin = await this.adminRepository.findOne({ where: { email }, relations: ['password'] });
|
||||
|
||||
if (!admin) {
|
||||
throw new NotFoundException(ErrorCode.AdminWithEmailNotFound)
|
||||
}
|
||||
|
||||
return admin;
|
||||
}
|
||||
|
||||
async update(
|
||||
admin: Partial<Admin>,
|
||||
id: number,
|
||||
{
|
||||
email,
|
||||
username,
|
||||
role,
|
||||
password,
|
||||
is_active,
|
||||
}: UpdateAdminDto
|
||||
) {
|
||||
const transactionalRunner = await this.transactionRunner.createTransaction();
|
||||
|
||||
try {
|
||||
await transactionalRunner.startTransaction();
|
||||
|
||||
const existingAdmin = await this.adminRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['password'],
|
||||
});
|
||||
|
||||
if (!existingAdmin) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const salt = await bcrypt.genSalt();
|
||||
const hashedPassword = await bcrypt.hash(password, salt);
|
||||
existingAdmin.password.value = hashedPassword;
|
||||
}
|
||||
|
||||
existingAdmin.email = email;
|
||||
existingAdmin.username = username;
|
||||
existingAdmin.is_active = is_active;
|
||||
existingAdmin.role = role;
|
||||
|
||||
const newAdmin = await saveWithTransactions.call(
|
||||
this.adminRepository,
|
||||
existingAdmin,
|
||||
transactionalRunner.transactionManager,
|
||||
);
|
||||
|
||||
delete newAdmin.password;
|
||||
|
||||
await transactionalRunner.commitTransaction();
|
||||
return newAdmin;
|
||||
} catch (error) {
|
||||
await transactionalRunner.rollbackTransaction();
|
||||
this._logger.error(error.message, error.stack);
|
||||
throw error;
|
||||
} finally {
|
||||
await transactionalRunner.releaseTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
async remove(admin: Admin, id: number) {
|
||||
const transactionalRunner = await this.transactionRunner.createTransaction();
|
||||
|
||||
try {
|
||||
await transactionalRunner.startTransaction();
|
||||
|
||||
const deleteResult = await deleteWithTransactions.call(
|
||||
this.adminRepository,
|
||||
id,
|
||||
transactionalRunner.transactionManager,
|
||||
);
|
||||
|
||||
await transactionalRunner.commitTransaction();
|
||||
return deleteResult;
|
||||
} catch (error) {
|
||||
await transactionalRunner.rollbackTransaction();
|
||||
this._logger.error(error.message, error.stack);
|
||||
throw error;
|
||||
} finally {
|
||||
await transactionalRunner.releaseTransaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
back/src/admins/dto/create-admin.dto.ts
Normal file
24
back/src/admins/dto/create-admin.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength, IsOptional, IsEnum, IsBoolean } from 'class-validator';
|
||||
import { Roles } from '../entities/admin.entity';
|
||||
|
||||
export class CreateAdminDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
username: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(Roles)
|
||||
role: Roles;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_active?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
4
back/src/admins/dto/update-admin.dto.ts
Normal file
4
back/src/admins/dto/update-admin.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateAdminDto } from './create-admin.dto';
|
||||
|
||||
export class UpdateAdminDto extends PartialType(CreateAdminDto) {}
|
||||
34
back/src/admins/entities/admin.entity.ts
Normal file
34
back/src/admins/entities/admin.entity.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { AbstractEntity } from "src/common/entities/abstract.entity";
|
||||
import { Column, Entity, OneToOne } from "typeorm";
|
||||
import { Password } from "./password.entity";
|
||||
|
||||
export enum Roles {
|
||||
SuperAdmin = 'superadmin',
|
||||
Admin = 'admin',
|
||||
};
|
||||
|
||||
@Entity('admins')
|
||||
export class Admin extends AbstractEntity {
|
||||
@Column({ length: 255 })
|
||||
username: string;
|
||||
|
||||
@Column({ length: 255 })
|
||||
email: string;
|
||||
|
||||
@Column({ nullable: true, default: null })
|
||||
image: string;
|
||||
|
||||
@OneToOne(() => Password, (password) => password.admin, { cascade: true })
|
||||
password: Password;
|
||||
|
||||
@Column({ default: true })
|
||||
is_active: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: Roles,
|
||||
nullable: false,
|
||||
default: Roles.Admin,
|
||||
})
|
||||
role: Roles;
|
||||
}
|
||||
20
back/src/admins/entities/password.entity.ts
Normal file
20
back/src/admins/entities/password.entity.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { AbstractEntity } from "src/common/entities/abstract.entity";
|
||||
import { Column, Entity, OneToOne, JoinColumn } from "typeorm";
|
||||
import { Admin } from "./admin.entity";
|
||||
|
||||
@Entity('passwords')
|
||||
export class Password extends AbstractEntity {
|
||||
@Column({ length: 255 })
|
||||
value: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
admin_id: number;
|
||||
|
||||
@OneToOne(
|
||||
() => Admin,
|
||||
(admin) => admin.password,
|
||||
{ onDelete: 'CASCADE' },
|
||||
)
|
||||
@JoinColumn({ name: 'admin_id', referencedColumnName: 'id' })
|
||||
admin: Admin;
|
||||
}
|
||||
12
back/src/app.controller.ts
Normal file
12
back/src/app.controller.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
32
back/src/app.module.ts
Normal file
32
back/src/app.module.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
import { validate } from './config/env/validate';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AdminsModule } from './admins/admins.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AdminConsoleModule } from './admin-console/admin-console.module';
|
||||
import { BotModule } from './bot/bot.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ validate }),
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, '..', 'uploads'),
|
||||
serveRoot: '/uploads',
|
||||
}),
|
||||
CommonModule,
|
||||
DatabaseModule,
|
||||
AdminsModule,
|
||||
AuthModule,
|
||||
AdminConsoleModule,
|
||||
BotModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
back/src/app.service.ts
Normal file
8
back/src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
32
back/src/auth/auth.controller.ts
Normal file
32
back/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { CurrentAdmin } from 'src/common/decorators/current-admin.decorator';
|
||||
import { Admin } from 'src/admins/entities/admin.entity';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('register')
|
||||
register(
|
||||
@CurrentAdmin() admin: Admin,
|
||||
@Body() registerDto: RegisterDto,
|
||||
) {
|
||||
return this.authService.register(admin, registerDto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() { email, password }: LoginDto) {
|
||||
return this.authService.login(email, password);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('verify-token')
|
||||
verifyToken(@CurrentAdmin() admin: Admin) {
|
||||
return admin;
|
||||
}
|
||||
}
|
||||
40
back/src/auth/auth.guard.ts
Normal file
40
back/src/auth/auth.guard.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminsService } from 'src/admins/admins.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private authSerivce: AuthService,
|
||||
private adminsSerivce: AdminsService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
const authData = await this.authSerivce.verifyToken(token);
|
||||
request.user = await this.adminsSerivce.findOne(authData.id)
|
||||
|
||||
if (!request.user.is_active) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
34
back/src/auth/auth.module.ts
Normal file
34
back/src/auth/auth.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { DbTransactionFactory } from 'src/database/transaction-factory';
|
||||
import { AdminsModule } from 'src/admins/admins.module';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => AdminsModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
global: true,
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '30d' },
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
ConfigService,
|
||||
DbTransactionFactory,
|
||||
AuthGuard,
|
||||
],
|
||||
exports: [AuthService, AuthGuard]
|
||||
})
|
||||
export class AuthModule {}
|
||||
69
back/src/auth/auth.service.ts
Normal file
69
back/src/auth/auth.service.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { Admin } from 'src/admins/entities/admin.entity';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ErrorCode } from 'src/common/enums/error-code.enum';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AdminsService } from 'src/admins/admins.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly adminsService: AdminsService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) { }
|
||||
|
||||
async login(email: string, password: string): Promise<{ accessToken: string }> {
|
||||
const admin = await this.adminsService.findOneByEmailWithPassword(email);
|
||||
const isMatch = await bcrypt.compare(password, admin.password.value);
|
||||
|
||||
if (!isMatch) {
|
||||
throw new UnauthorizedException(ErrorCode.WrongPassword)
|
||||
}
|
||||
|
||||
if (!admin.is_active) {
|
||||
throw new UnauthorizedException(ErrorCode.BlockedAdmin)
|
||||
}
|
||||
|
||||
const payload = { id: admin.id, username: admin.username };
|
||||
return { accessToken: this.jwtService.sign(payload) };
|
||||
}
|
||||
|
||||
async register(
|
||||
admin: Admin | null,
|
||||
{
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
password,
|
||||
}: RegisterDto,
|
||||
): Promise<Admin> {
|
||||
const salt = await bcrypt.genSalt();
|
||||
const hashedPassword = await bcrypt.hash(password, salt);
|
||||
|
||||
return this.adminsService.create(
|
||||
admin,
|
||||
{
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
password: hashedPassword,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async verifyToken(token: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(
|
||||
token,
|
||||
{ secret: this.configService.get<string>('JWT_SECRET') }
|
||||
);
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
10
back/src/auth/dto/login.dto.ts
Normal file
10
back/src/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
25
back/src/auth/dto/register.dto.ts
Normal file
25
back/src/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, IsStrongPassword, IsEnum } from 'class-validator';
|
||||
import { Roles } from 'src/admins/entities/admin.entity';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(Roles)
|
||||
role: Roles;
|
||||
|
||||
@IsString()
|
||||
@IsStrongPassword({
|
||||
minLength: 8,
|
||||
minSymbols: 1,
|
||||
minNumbers: 1,
|
||||
minLowercase: 1,
|
||||
minUppercase: 1,
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
14
back/src/bot/bot.module.ts
Normal file
14
back/src/bot/bot.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BotService } from './bot.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
providers: [BotService],
|
||||
exports: [BotService],
|
||||
})
|
||||
export class BotModule {}
|
||||
58
back/src/bot/bot.service.ts
Normal file
58
back/src/bot/bot.service.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Bot, Context, Keyboard } from 'grammy';
|
||||
import { ForceReply, InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove } from 'grammy/types';
|
||||
|
||||
@Injectable()
|
||||
export class BotService {
|
||||
private readonly bot: Bot;
|
||||
private readonly _logger = new Logger(BotService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
const telegramBotToken = this.configService.get<string>('TELEGRAM_BOT_TOKEN');
|
||||
this.bot = new Bot(telegramBotToken);
|
||||
}
|
||||
|
||||
public onModuleInit() {
|
||||
this.bot.command('start', this.onStart.bind(this));
|
||||
|
||||
this.bot.start().catch((error) => console.error(':: BOT ERROR:', error));
|
||||
this._logger.log('BOT STARTED!');
|
||||
}
|
||||
|
||||
private async onStart(ctx: Context) {
|
||||
try {
|
||||
const keyboard = new Keyboard()
|
||||
.text('Menu')
|
||||
.row()
|
||||
.resized();
|
||||
|
||||
await ctx.reply('Welcome! This bot is under development.', {
|
||||
reply_markup: keyboard,
|
||||
});
|
||||
} catch (error) {
|
||||
this._logger.error(error.message, error.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a specific Telegram user.
|
||||
*/
|
||||
public async sendMessage(
|
||||
telegramId: string,
|
||||
message: string,
|
||||
reply_markup?: InlineKeyboardMarkup | ReplyKeyboardMarkup | ReplyKeyboardRemove | ForceReply,
|
||||
) {
|
||||
try {
|
||||
await this.bot.api.sendMessage(telegramId, message, {
|
||||
parse_mode: 'Markdown',
|
||||
reply_markup,
|
||||
link_preview_options: { is_disabled: true },
|
||||
});
|
||||
} catch (error) {
|
||||
this._logger.error(`Failed to send message to ${telegramId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
back/src/common/common.module.ts
Normal file
4
back/src/common/common.module.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class CommonModule {}
|
||||
9
back/src/common/decorators/current-admin.decorator.ts
Normal file
9
back/src/common/decorators/current-admin.decorator.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { ExecutionContext, createParamDecorator } from "@nestjs/common";
|
||||
import { Admin } from "typeorm";
|
||||
|
||||
export const CurrentAdmin = createParamDecorator((data: keyof Admin, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
const user = req.user;
|
||||
|
||||
return data ? user[data] : user;
|
||||
});
|
||||
82
back/src/common/decorators/filtering-params.decorator.ts
Normal file
82
back/src/common/decorators/filtering-params.decorator.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { IsNull, Not, LessThan, LessThanOrEqual, MoreThan, MoreThanOrEqual, ILike, In, Between } from "typeorm";
|
||||
import { Request } from 'express';
|
||||
import { ErrorCode } from '../enums/error-code.enum';
|
||||
|
||||
export type EntityFields<T> = (keyof T)[];
|
||||
|
||||
export interface IFiltering {
|
||||
[field: string]: string;
|
||||
}
|
||||
|
||||
interface IFilteringParams {
|
||||
field: string;
|
||||
rule: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// valid filter rules
|
||||
export enum FilterRule {
|
||||
EQUALS = 'eq',
|
||||
NOT_EQUALS = 'neq',
|
||||
GREATER_THAN = 'gt',
|
||||
GREATER_THAN_OR_EQUALS = 'gte',
|
||||
LESS_THAN = 'lt',
|
||||
LESS_THAN_OR_EQUALS = 'lte',
|
||||
LIKE = 'like',
|
||||
NOT_LIKE = 'nlike',
|
||||
IN = 'in',
|
||||
NOT_IN = 'nin',
|
||||
IS_NULL = 'isnull',
|
||||
IS_NOT_NULL = 'isnotnull',
|
||||
BETWEEN = 'between',
|
||||
}
|
||||
|
||||
export const getWhere = (filter: IFilteringParams) => {
|
||||
if (!filter) return {};
|
||||
|
||||
if (filter.rule == FilterRule.IS_NULL) return IsNull();
|
||||
if (filter.rule == FilterRule.IS_NOT_NULL) return Not(IsNull());
|
||||
if (filter.rule == FilterRule.EQUALS) return filter.value;
|
||||
if (filter.rule == FilterRule.NOT_EQUALS) return Not(filter.value);
|
||||
if (filter.rule == FilterRule.GREATER_THAN) return MoreThan(filter.value);
|
||||
if (filter.rule == FilterRule.GREATER_THAN_OR_EQUALS) return MoreThanOrEqual(filter.value);
|
||||
if (filter.rule == FilterRule.LESS_THAN) return LessThan(filter.value);
|
||||
if (filter.rule == FilterRule.LESS_THAN_OR_EQUALS) return LessThanOrEqual(filter.value);
|
||||
if (filter.rule == FilterRule.LIKE) return ILike(`%${filter.value}%`);
|
||||
if (filter.rule == FilterRule.NOT_LIKE) return Not(ILike(`%${filter.value}%`));
|
||||
if (filter.rule == FilterRule.IN) return In(filter.value.split(','));
|
||||
if (filter.rule == FilterRule.NOT_IN) return Not(In(filter.value.split(',')));
|
||||
if (filter.rule == FilterRule.BETWEEN) return Between(...(filter.value.split(',') as [string, string]));
|
||||
}
|
||||
|
||||
export const FilteringParams = createParamDecorator((data, ctx: ExecutionContext): IFiltering => {
|
||||
const req: Request = ctx.switchToHttp().getRequest();
|
||||
const queryFilters = req.query.filters as string[];
|
||||
|
||||
if (!queryFilters || !Array.isArray(queryFilters)) return null;
|
||||
|
||||
if (typeof data !== 'object') throw new BadRequestException(ErrorCode.InvalidFilterParams);
|
||||
|
||||
let filters: { [field: string]: any } = {};
|
||||
|
||||
for (const filter of queryFilters) {
|
||||
const [fieldPath, rule, value] = filter.split(':');
|
||||
|
||||
const fieldParts = fieldPath.split('.');
|
||||
const field = fieldParts.pop();
|
||||
let nestedFilters = filters;
|
||||
for (const part of fieldParts) {
|
||||
nestedFilters[part] = nestedFilters[part] || {};
|
||||
nestedFilters = nestedFilters[part];
|
||||
}
|
||||
|
||||
if (!data.includes(fieldPath)) throw new BadRequestException(`${ErrorCode.FilterFieldNotAllowed}:${field}`);
|
||||
if (!Object.values(FilterRule).includes(rule as FilterRule)) throw new BadRequestException(`${ErrorCode.InvalidFilterParams}:${rule}`);
|
||||
|
||||
const whereClause = getWhere({ field, rule, value });
|
||||
nestedFilters[field] = whereClause;
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
34
back/src/common/decorators/pagination-params.decorator.ts
Normal file
34
back/src/common/decorators/pagination-params.decorator.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { ErrorCode } from '../enums/error-code.enum';
|
||||
|
||||
export interface IPagination {
|
||||
limit: number;
|
||||
offset: number;
|
||||
page?: number; // for infinite scroll on client side
|
||||
}
|
||||
|
||||
export type PaginatedResource<T> = {
|
||||
totalCount: number;
|
||||
items: T[];
|
||||
page?: number; // for infinite scroll on client side
|
||||
};
|
||||
|
||||
export const PaginationParams = createParamDecorator((data, ctx: ExecutionContext): IPagination => {
|
||||
const req: Request = ctx.switchToHttp().getRequest();
|
||||
const page = parseInt(req.query.page as string);
|
||||
const size = parseInt(req.query.size as string);
|
||||
|
||||
// check if page and size are valid
|
||||
if (isNaN(page) || page < 0 || isNaN(size) || size < 0) {
|
||||
throw new BadRequestException(ErrorCode.InvalidPaginationParams);
|
||||
}
|
||||
// do not allow to fetch large slices of the dataset
|
||||
if (size > 250) {
|
||||
throw new BadRequestException(ErrorCode.MaximumChunkSizeExceeded);
|
||||
}
|
||||
|
||||
const limit = size;
|
||||
const offset = (page - 1) * limit;
|
||||
return { limit, offset, page };
|
||||
});
|
||||
3
back/src/common/decorators/response.decorator.ts
Normal file
3
back/src/common/decorators/response.decorator.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ResponseMessage = (message: string) => SetMetadata('response_message', message);
|
||||
30
back/src/common/decorators/sorting-params.decorator.ts
Normal file
30
back/src/common/decorators/sorting-params.decorator.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { ErrorCode } from '../enums/error-code.enum';
|
||||
|
||||
type Direction = 'ASC' | 'DESC';
|
||||
|
||||
export interface ISorting {
|
||||
[field: string]: Direction;
|
||||
}
|
||||
|
||||
export const SortingParams = createParamDecorator((validParams, ctx: ExecutionContext): ISorting => {
|
||||
const req: Request = ctx.switchToHttp().getRequest();
|
||||
const sort = req.query.sort as string;
|
||||
if (!sort) return null;
|
||||
|
||||
// check if the valid params sent is an array
|
||||
if (typeof validParams !== 'object') throw new BadRequestException(ErrorCode.InvalidSortParams);
|
||||
|
||||
// check the format of the sort query param
|
||||
const sortPattern = /^([a-zA-Z0-9_]+):(ASC|DESC|asc|desc)$/;
|
||||
if (!sort.match(sortPattern)) throw new BadRequestException(ErrorCode.InvalidSortParams);
|
||||
|
||||
// extract the field name and direction and check if they are valid
|
||||
const [field, direction] = sort.split(':');
|
||||
if (!validParams.includes(field)) throw new BadRequestException(`${ErrorCode.InvalidSortParams}:${field}`);
|
||||
|
||||
return {
|
||||
[field]: direction.toUpperCase() as Direction,
|
||||
};
|
||||
});
|
||||
16
back/src/common/entities/abstract.entity.ts
Normal file
16
back/src/common/entities/abstract.entity.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import {
|
||||
CreateDateColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export abstract class AbstractEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
public created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
public updated_at: Date;
|
||||
}
|
||||
13
back/src/common/enums/error-code.enum.ts
Normal file
13
back/src/common/enums/error-code.enum.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export enum ErrorCode {
|
||||
WrongPassword = 'wrong-password',
|
||||
BlockedAdmin = 'blocked-admin',
|
||||
AdminWithEmailNotFound = 'admin-with-email-not-found',
|
||||
AdminAlreadyExists = 'admin-already-exists',
|
||||
AdminNotFound = 'admin-not-found',
|
||||
InvalidPaginationParams = 'invalid-pagination-params',
|
||||
MaximumChunkSizeExceeded = 'maximum-chunk-size-100-exceeded',
|
||||
InvalidSortParams = 'invalid-sort-params',
|
||||
InvalidFilterParams = 'invalid-filter-params',
|
||||
FilterFieldNotAllowed = 'filter-field-not-allowed',
|
||||
FilterInvalidRule = 'filter-invalid-rule',
|
||||
}
|
||||
48
back/src/common/interceptors/response.interceptor.ts
Normal file
48
back/src/common/interceptors/response.interceptor.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
export interface IResponse<T> {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<T, IResponse<T>> {
|
||||
constructor(private reflector: Reflector) { }
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<IResponse<T>> {
|
||||
const httpResponse = context.switchToHttp().getResponse();
|
||||
const customMessage = this.reflector.get<string>('response_message', context.getHandler());
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
return {
|
||||
statusCode: data?.statusCode || httpResponse.statusCode || 200,
|
||||
message: data?.message || customMessage || 'success',
|
||||
data: this.clearResponseData(data),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// NOTE: If we want to set custom statusCode and message in response it will set values from data to response then remove
|
||||
private clearResponseData(data: any) {
|
||||
if (data?.statusCode) {
|
||||
data.statusCode = undefined;
|
||||
}
|
||||
|
||||
if (data?.message) {
|
||||
data.message = undefined;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
export class ColumnDecimalTransformer {
|
||||
to(data: number): number {
|
||||
return data;
|
||||
}
|
||||
|
||||
from(data: string): number {
|
||||
return parseFloat(data);
|
||||
}
|
||||
}
|
||||
3
back/src/common/utils/enum.utils.ts
Normal file
3
back/src/common/utils/enum.utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function enumToArray<T extends Record<string, string>>(e: T): string[] {
|
||||
return Object.values(e);
|
||||
}
|
||||
53
back/src/config/env/env.validation.ts
vendored
Normal file
53
back/src/config/env/env.validation.ts
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
import { IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsStrongPassword } from 'class-validator';
|
||||
|
||||
enum NodeEnvironment {
|
||||
Development = 'development',
|
||||
Production = 'production',
|
||||
Test = 'test',
|
||||
}
|
||||
|
||||
export class EnvironmentVariables {
|
||||
// App config
|
||||
@IsEnum(NodeEnvironment)
|
||||
NODE_ENV: NodeEnvironment;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
JWT_SECRET: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
PORT: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
TELEGRAM_BOT_TOKEN: string;
|
||||
|
||||
// Database config
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DB_HOST: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
DB_PORT: number;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DB_NAME: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
DB_USERNAME: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsStrongPassword({
|
||||
minLength: 16,
|
||||
minSymbols: 0,
|
||||
minNumbers: 3,
|
||||
minLowercase: 4,
|
||||
minUppercase: 4,
|
||||
})
|
||||
DB_PASSWORD: string;
|
||||
}
|
||||
19
back/src/config/env/validate.ts
vendored
Normal file
19
back/src/config/env/validate.ts
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { EnvironmentVariables } from './env.validation';
|
||||
|
||||
export function validate(config: Record<string, unknown>) {
|
||||
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
||||
enableImplicitConversion: true,
|
||||
});
|
||||
|
||||
const errors = validateSync(validatedConfig, {
|
||||
skipMissingProperties: false,
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.toString());
|
||||
}
|
||||
|
||||
return validatedConfig;
|
||||
}
|
||||
24
back/src/console.ts
Normal file
24
back/src/console.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { BootstrapConsole } from 'nestjs-console';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const bootstrap = new BootstrapConsole({
|
||||
module: AppModule,
|
||||
useDecorators: true,
|
||||
contextOptions: {
|
||||
logger: [
|
||||
'verbose',
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
bootstrap.init().then(async (app) => {
|
||||
try {
|
||||
await app.init();
|
||||
await bootstrap.boot();
|
||||
await app.close();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await app.close();
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
18
back/src/data-source.ts
Normal file
18
back/src/data-source.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/*
|
||||
Migration classes are separate from the Nest application source code. Their lifecycle is maintained by the TypeORM CLI.
|
||||
Therefore, you are not able to leverage dependency injection and other Nest specific features with migrations.
|
||||
*/
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export default new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
synchronize: false,
|
||||
entities: ['src/**/entities/*.entity.ts'],
|
||||
migrations: ['src/database/migrations/*.ts'],
|
||||
subscribers: [],
|
||||
});
|
||||
30
back/src/database/database.module.ts
Normal file
30
back/src/database/database.module.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Password } from 'src/admins/entities/password.entity';
|
||||
import { Admin } from 'src/admins/entities/admin.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('DB_HOST'),
|
||||
port: configService.get('DB_PORT'),
|
||||
username: configService.get('DB_USERNAME'),
|
||||
password: configService.get('DB_PASSWORD'),
|
||||
database: configService.get('DB_NAME'),
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
subscribers: [],
|
||||
entities: [
|
||||
Admin,
|
||||
Password,
|
||||
]
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
104
back/src/database/transaction-factory.ts
Normal file
104
back/src/database/transaction-factory.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, QueryRunner, EntityManager, Repository, DeepPartial, EntityTarget, ObjectLiteral, FindOptionsWhere, DeleteResult, ObjectId, UpdateResult } from 'typeorm';
|
||||
import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel';
|
||||
|
||||
const DEFAULT_ISOLATION_LEVEL: IsolationLevel = 'READ COMMITTED';
|
||||
|
||||
|
||||
// TODO: Make as member of custom generic TransactionalRepository
|
||||
// For now this function should be used only with binded 'this' context (bind, call or apply)
|
||||
export function saveWithTransactions<Entity>(data: DeepPartial<Entity>, transactionManager: EntityManager): Promise<DeepPartial<Entity>> {
|
||||
if (transactionManager) return transactionManager.save(this.target, data);
|
||||
return this.save(data);
|
||||
}
|
||||
|
||||
export function updateWithTransactions<Entity>(
|
||||
criteria: string | string[] | number | number[] | Date | Date[] | ObjectId | ObjectId[] | FindOptionsWhere<Entity>,
|
||||
data: DeepPartial<Entity>,
|
||||
transactionManager: EntityManager,
|
||||
): Promise<DeepPartial<UpdateResult>> {
|
||||
if (transactionManager) return transactionManager.update(this.target, criteria, data);
|
||||
return this.update(criteria, data);
|
||||
}
|
||||
|
||||
export function deleteWithTransactions<Entity>(
|
||||
criteria: string | string[] | number | number[] | Date | Date[] | FindOptionsWhere<Entity>,
|
||||
transactionManager: EntityManager,
|
||||
): Promise<DeleteResult> {
|
||||
if (transactionManager) return transactionManager.delete(this.target, criteria);
|
||||
return this.delete(criteria);
|
||||
}
|
||||
|
||||
export function softDeleteWithTransactions<Entity>(
|
||||
criteria: string | string[] | number | number[] | Date | Date[] | FindOptionsWhere<Entity>,
|
||||
transactionManager: EntityManager,
|
||||
): Promise<DeleteResult> {
|
||||
if (transactionManager) return transactionManager.softDelete(this.target, criteria);
|
||||
return this.softDelete(criteria);
|
||||
}
|
||||
|
||||
export function restoreWithTransactions<Entity>(
|
||||
criteria: string | string[] | number | number[] | Date | Date[] | FindOptionsWhere<Entity>,
|
||||
transactionManager: EntityManager,
|
||||
): Promise<DeleteResult> {
|
||||
if (transactionManager) return transactionManager.restore(this.target, criteria);
|
||||
return this.restore(criteria);
|
||||
}
|
||||
|
||||
// export class TransactionalRepository<Entity extends ObjectLiteral> extends Repository<Entity> {
|
||||
// constructor(target: EntityTarget<Entity>, dataSource: DataSource) {
|
||||
// super(target, dataSource.createEntityManager());
|
||||
// }
|
||||
//
|
||||
// saveWithTransactions(data: DeepPartial<Entity>, transactionManager: EntityManager): Promise<DeepPartial<Entity>> {
|
||||
// if (transactionManager) return transactionManager.save(this.target, data);
|
||||
// return this.save(data);
|
||||
// }
|
||||
// }
|
||||
|
||||
export abstract class ITransactionRunner {
|
||||
abstract startTransaction(): Promise<void>;
|
||||
abstract commitTransaction(): Promise<void>;
|
||||
abstract rollbackTransaction(): Promise<void>;
|
||||
abstract releaseTransaction(): Promise<void>;
|
||||
}
|
||||
|
||||
class TransactionRunner implements ITransactionRunner {
|
||||
private hasTransactionDestroyed = false;
|
||||
constructor(private readonly queryRunner: QueryRunner) { }
|
||||
|
||||
async startTransaction(isolationLevel: IsolationLevel = DEFAULT_ISOLATION_LEVEL): Promise<void> {
|
||||
if (this.queryRunner.isTransactionActive) return;
|
||||
return this.queryRunner.startTransaction(isolationLevel);
|
||||
}
|
||||
|
||||
async commitTransaction(): Promise<void> {
|
||||
if (this.hasTransactionDestroyed) return;
|
||||
return this.queryRunner.commitTransaction();
|
||||
}
|
||||
|
||||
async rollbackTransaction(): Promise<void> {
|
||||
if (this.hasTransactionDestroyed) return;
|
||||
return this.queryRunner.rollbackTransaction();
|
||||
}
|
||||
|
||||
async releaseTransaction(): Promise<void> {
|
||||
this.hasTransactionDestroyed = true;
|
||||
return this.queryRunner.release();
|
||||
}
|
||||
|
||||
get transactionManager(): EntityManager {
|
||||
return this.queryRunner.manager;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DbTransactionFactory {
|
||||
constructor(private readonly dataSource: DataSource) { }
|
||||
|
||||
async createTransaction(): Promise<TransactionRunner> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
return new TransactionRunner(queryRunner);
|
||||
}
|
||||
}
|
||||
53
back/src/main.ts
Normal file
53
back/src/main.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory, Reflector } from '@nestjs/core';
|
||||
import helmet from 'helmet';
|
||||
import { AppModule } from './app.module';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
// logger: process.env.NODE_ENV === 'production'
|
||||
// ? ['error', 'warn', 'log']
|
||||
// : ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const reflector = app.get(Reflector);
|
||||
const configService = app.get(ConfigService);
|
||||
const PORT = configService.get<number>('PORT');
|
||||
const isDevelop = configService.get<string>('NODE_ENV');
|
||||
|
||||
const helmetConfig = helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"'unsafe-eval'",
|
||||
// "", "", "" // TODO: domains here
|
||||
],
|
||||
imgSrc: [
|
||||
"'self'",
|
||||
"https: data: blob:"
|
||||
],
|
||||
mediaSrc: [
|
||||
"'self'",
|
||||
"https: data: blob:"
|
||||
],
|
||||
},
|
||||
},
|
||||
crossOriginResourcePolicy: {
|
||||
policy: isDevelop ? 'cross-origin' : 'same-site'
|
||||
}
|
||||
});
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true }));
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(reflector));
|
||||
app.useGlobalInterceptors(new ResponseInterceptor(reflector))
|
||||
app.enableCors();
|
||||
app.use(helmetConfig);
|
||||
|
||||
await app.listen(PORT);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
4
back/src/types/json.d.ts
vendored
Normal file
4
back/src/types/json.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "*.json" {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
4
back/tsconfig.build.json
Normal file
4
back/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
23
back/tsconfig.json
Normal file
23
back/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
54
backup.sh
Normal file
54
backup.sh
Normal file
@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
|
||||
DB_CONTAINER_NAME="${PROJECT_NAME}_database"
|
||||
UPLOADS_DIR="$PWD/back/uploads"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
DB_BACKUP_FILE="$BACKUP_DIR/db_backup_$TIMESTAMP.sql"
|
||||
UPLOADS_BACKUP_FILE="$BACKUP_DIR/uploads_backup_$TIMESTAMP.tar.gz"
|
||||
|
||||
backup_postgres() {
|
||||
echo "Creating Postgres backup..."
|
||||
docker exec "$DB_CONTAINER_NAME" pg_dump -U "$DB_USERNAME" -Fc "$DB_NAME" > "$DB_BACKUP_FILE"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Database backup saved to $DB_BACKUP_FILE"
|
||||
else
|
||||
echo "Failed to create database backup"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_uploads() {
|
||||
echo "Creating uploads backup..."
|
||||
tar -czf "$UPLOADS_BACKUP_FILE" -C "$UPLOADS_DIR" .
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Uploads backup saved to $UPLOADS_BACKUP_FILE"
|
||||
else
|
||||
echo "Failed to create uploads backup"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
send_backup_to_telegram() {
|
||||
local FILE_PATH="$1"
|
||||
echo "Sending $FILE_PATH to Telegram..."
|
||||
curl -s -F "chat_id=$BACKUP_TELEGRAM_CHAT_ID" -F "document=@$FILE_PATH" "https://api.telegram.org/bot$BACKUP_TELEGRAM_BOT_TOKEN/sendDocument" > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Backup $FILE_PATH successfully sent to Telegram"
|
||||
else
|
||||
echo "Failed to send backup to Telegram"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_postgres
|
||||
send_backup_to_telegram "$DB_BACKUP_FILE"
|
||||
|
||||
backup_uploads
|
||||
send_backup_to_telegram "$UPLOADS_BACKUP_FILE"
|
||||
74
compose.dev.yml
Normal file
74
compose.dev.yml
Normal file
@ -0,0 +1,74 @@
|
||||
services:
|
||||
database:
|
||||
container_name: ${PROJECT_NAME}_database
|
||||
hostname: postgres
|
||||
image: postgres:17.4
|
||||
ports:
|
||||
- ${DB_PORT}:5432
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/pgdata
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
PGDATA: /var/lib/postgresql/pgdata
|
||||
networks:
|
||||
- main_network
|
||||
restart: unless-stopped
|
||||
|
||||
back:
|
||||
container_name: ${PROJECT_NAME}_back
|
||||
build: ./back
|
||||
volumes:
|
||||
- ./back:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
PORT: ${BACK_PORT}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
NODE_ENV: development
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
|
||||
DB_HOST: database
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: ${DB_USERNAME}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME}
|
||||
networks:
|
||||
- main_network
|
||||
ports:
|
||||
- ${BACK_PORT}:${BACK_PORT}
|
||||
depends_on:
|
||||
- database
|
||||
restart: unless-stopped
|
||||
command: sh -c "pnpm migration:run && pnpm start:dev"
|
||||
|
||||
front:
|
||||
container_name: ${PROJECT_NAME}_front
|
||||
build:
|
||||
context: ./front
|
||||
args:
|
||||
- VITE_API_URL=${API_URL}
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
PORT: ${FRONT_PORT}
|
||||
HOST: 0.0.0.0
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./front:/app
|
||||
- /app/node_modules
|
||||
depends_on:
|
||||
- back
|
||||
networks:
|
||||
- main_network
|
||||
ports:
|
||||
- ${FRONT_PORT}:5173
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
|
||||
networks:
|
||||
main_network:
|
||||
name: ${PROJECT_NAME}_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
name: ${PROJECT_NAME}_volume
|
||||
64
compose.yml
Normal file
64
compose.yml
Normal file
@ -0,0 +1,64 @@
|
||||
services:
|
||||
database:
|
||||
container_name: ${PROJECT_NAME}_database
|
||||
hostname: postgres
|
||||
image: postgres:17.4
|
||||
ports:
|
||||
- ${DB_PORT}:5432
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/pgdata
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
PGDATA: /var/lib/postgresql/pgdata
|
||||
networks:
|
||||
- main_network
|
||||
restart: always
|
||||
|
||||
back:
|
||||
container_name: ${PROJECT_NAME}_back
|
||||
image: your-registry/back:latest
|
||||
environment:
|
||||
PORT: ${BACK_PORT}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
NODE_ENV: production
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
|
||||
DB_HOST: database
|
||||
DB_PORT: 5432
|
||||
DB_USERNAME: ${DB_USERNAME}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: ${DB_NAME}
|
||||
networks:
|
||||
- main_network
|
||||
ports:
|
||||
- ${BACK_PORT}:${BACK_PORT}
|
||||
depends_on:
|
||||
- database
|
||||
restart: always
|
||||
command: sh -c "pnpm migration:run && pnpm start"
|
||||
|
||||
front:
|
||||
container_name: ${PROJECT_NAME}_front
|
||||
image: your-registry/front:latest
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: ${FRONT_PORT}
|
||||
restart: always
|
||||
depends_on:
|
||||
- back
|
||||
networks:
|
||||
- main_network
|
||||
ports:
|
||||
- ${FRONT_PORT}:3000
|
||||
command: npm start
|
||||
|
||||
networks:
|
||||
main_network:
|
||||
name: ${PROJECT_NAME}_network
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
name: ${PROJECT_NAME}_volume
|
||||
11
front/.dockerignore
Normal file
11
front/.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.md
|
||||
.vscode
|
||||
.idea
|
||||
dist
|
||||
coverage
|
||||
133
front/.gitignore
vendored
Normal file
133
front/.gitignore
vendored
Normal file
@ -0,0 +1,133 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-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
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# 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
|
||||
|
||||
# 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.*
|
||||
|
||||
.DS_Store
|
||||
.vercel
|
||||
1
front/.npmrc
Normal file
1
front/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
35
front/.prettierrc.mjs
Normal file
35
front/.prettierrc.mjs
Normal file
@ -0,0 +1,35 @@
|
||||
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
|
||||
const config = {
|
||||
printWidth: 100,
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
plugins: ['@ianvs/prettier-plugin-sort-imports'],
|
||||
importOrder: [
|
||||
'.*styles.css$',
|
||||
'',
|
||||
'dayjs',
|
||||
'^react$',
|
||||
'^next$',
|
||||
'^next/.*$',
|
||||
'<BUILTIN_MODULES>',
|
||||
'<THIRD_PARTY_MODULES>',
|
||||
'^@mantine/(.*)$',
|
||||
'^@mantinex/(.*)$',
|
||||
'^@mantine-tests/(.*)$',
|
||||
'^@docs/(.*)$',
|
||||
'^@/.*$',
|
||||
'^../(?!.*.css$).*$',
|
||||
'^./(?!.*.css$).*$',
|
||||
'\\.css$',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: '*.mdx',
|
||||
options: {
|
||||
printWidth: 70,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
12
front/.storybook/main.ts
Normal file
12
front/.storybook/main.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.story.@(js|jsx|ts|tsx)'],
|
||||
addons: ['@storybook/addon-essentials', 'storybook-dark-mode'],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
25
front/.storybook/preview.tsx
Normal file
25
front/.storybook/preview.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import '@mantine/core/styles.css';
|
||||
import React, { useEffect } from 'react';
|
||||
import { addons } from '@storybook/preview-api';
|
||||
import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';
|
||||
import { MantineProvider, useMantineColorScheme } from '@mantine/core';
|
||||
import { theme } from '../src/theme';
|
||||
|
||||
const channel = addons.getChannel();
|
||||
|
||||
function ColorSchemeWrapper({ children }: { children: React.ReactNode }) {
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const handleColorScheme = (value: boolean) => setColorScheme(value ? 'dark' : 'light');
|
||||
|
||||
useEffect(() => {
|
||||
channel.on(DARK_MODE_EVENT_NAME, handleColorScheme);
|
||||
return () => channel.off(DARK_MODE_EVENT_NAME, handleColorScheme);
|
||||
}, [channel]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export const decorators = [
|
||||
(renderStory: any) => <ColorSchemeWrapper>{renderStory()}</ColorSchemeWrapper>,
|
||||
(renderStory: any) => <MantineProvider theme={theme}>{renderStory()}</MantineProvider>,
|
||||
];
|
||||
1
front/.stylelintignore
Normal file
1
front/.stylelintignore
Normal file
@ -0,0 +1 @@
|
||||
dist
|
||||
28
front/.stylelintrc.json
Normal file
28
front/.stylelintrc.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard-scss"],
|
||||
"rules": {
|
||||
"custom-property-pattern": null,
|
||||
"selector-class-pattern": null,
|
||||
"scss/no-duplicate-mixins": null,
|
||||
"declaration-empty-line-before": null,
|
||||
"declaration-block-no-redundant-longhand-properties": null,
|
||||
"alpha-value-notation": null,
|
||||
"custom-property-empty-line-before": null,
|
||||
"property-no-vendor-prefix": null,
|
||||
"color-function-notation": null,
|
||||
"length-zero-no-unit": null,
|
||||
"selector-not-notation": null,
|
||||
"no-descending-specificity": null,
|
||||
"comment-empty-line-before": null,
|
||||
"scss/at-mixin-pattern": null,
|
||||
"scss/at-rule-no-unknown": null,
|
||||
"value-keyword-case": null,
|
||||
"media-feature-range-notation": null,
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3
front/.yarnrc.yml
Normal file
3
front/.yarnrc.yml
Normal file
@ -0,0 +1,3 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.1.1.cjs
|
||||
20
front/Dockerfile
Normal file
20
front/Dockerfile
Normal file
@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm i --legacy-peer-deps
|
||||
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY src ./src
|
||||
|
||||
RUN npm i --legacy-peer-deps
|
||||
|
||||
2
front/README.md
Normal file
2
front/README.md
Normal file
@ -0,0 +1,2 @@
|
||||
# CRM
|
||||
|
||||
7
front/eslint.config.js
Normal file
7
front/eslint.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import mantine from 'eslint-config-mantine';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
...mantine,
|
||||
{ ignores: ['**/*.{mjs,cjs,js,d.ts,d.mts}', './.storybook/main.ts'] },
|
||||
);
|
||||
34
front/index.html
Normal file
34
front/index.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
|
||||
/>
|
||||
<meta property="og:title" content="USDT Ban Notifications" />
|
||||
<meta property="og:site_name" content="USDT Ban Notifications" />
|
||||
<meta property="og:url" content="mantine-dashboard-eight.vercel.app" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="USDT Ban Notifications"
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://raw.githubusercontent.com/nedois/mantine-dashboard/main/screenshoots/screen-1.jpeg"
|
||||
/>
|
||||
<title>USDT Ban Notifications</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18908
front/package-lock.json
generated
Normal file
18908
front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
106
front/package.json
Normal file
106
front/package.json
Normal file
@ -0,0 +1,106 @@
|
||||
{
|
||||
"name": "admin-panel",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"start": "node src/server.js",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "npm run lint:eslint && npm run lint:stylelint",
|
||||
"lint:eslint": "eslint . --ext .ts,.tsx --cache",
|
||||
"lint:stylelint": "stylelint '**/*.css' --cache",
|
||||
"prettier": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"prettier:write": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"vitest": "vitest run",
|
||||
"vitest:watch": "vitest",
|
||||
"test": "npm run typecheck && npm run prettier && npm run lint && npm run vitest && npm run build",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@mantine/carousel": "^7.12.2",
|
||||
"@mantine/charts": "^7.12.2",
|
||||
"@mantine/code-highlight": "^7.12.2",
|
||||
"@mantine/core": "^7.17.8",
|
||||
"@mantine/dates": "^7.12.2",
|
||||
"@mantine/dropzone": "^7.12.2",
|
||||
"@mantine/form": "^7.12.2",
|
||||
"@mantine/hooks": "^7.17.8",
|
||||
"@mantine/modals": "^7.12.2",
|
||||
"@mantine/notifications": "^7.12.2",
|
||||
"@mantine/nprogress": "^7.12.2",
|
||||
"@mantine/spotlight": "^7.12.2",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"@tanstack/react-query": "^5.54.1",
|
||||
"@tanstack/react-query-devtools": "^5.54.1",
|
||||
"axios": "^1.7.7",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-react": "^8.2.1",
|
||||
"express": "^4.21.2",
|
||||
"framer-motion": "^11.5.2",
|
||||
"mantine-datatable": "^7.12.4",
|
||||
"nanoid": "^5.0.7",
|
||||
"rambda": "^9.4.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"recharts": "^2.12.7",
|
||||
"serve-static": "^1.16.2",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.3.1",
|
||||
"@storybook/addon-essentials": "^8.2.9",
|
||||
"@storybook/addon-interactions": "^8.2.9",
|
||||
"@storybook/addon-links": "^8.2.9",
|
||||
"@storybook/blocks": "^8.2.9",
|
||||
"@storybook/react": "^8.2.9",
|
||||
"@storybook/react-vite": "^8.2.9",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.4.0",
|
||||
"@typescript-eslint/parser": "^8.4.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-config-airbnb": "19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-mantine": "4.0.2",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"eslint-plugin-react": "^7.35.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.4.45",
|
||||
"postcss-preset-mantine": "1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"prettier": "^3.3.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"sass-embedded": "^1.85.1",
|
||||
"storybook": "^8.2.9",
|
||||
"storybook-dark-mode": "^4.0.2",
|
||||
"stylelint": "^16.9.0",
|
||||
"stylelint-config-standard-scss": "^13.1.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.3",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
19
front/postcss.config.cjs
Normal file
19
front/postcss.config.cjs
Normal file
@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {
|
||||
autoRem: true,
|
||||
},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '30em',
|
||||
'mantine-breakpoint-sm': '40em',
|
||||
'mantine-breakpoint-md': '48em',
|
||||
'mantine-breakpoint-lg': '64em',
|
||||
'mantine-breakpoint-xl': '80em',
|
||||
'mantine-breakpoint-2xl': '96em',
|
||||
'mantine-breakpoint-3xl': '120em',
|
||||
'mantine-breakpoint-4xl': '160em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
25
front/src/api/axios.ts
Normal file
25
front/src/api/axios.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import axios from 'axios';
|
||||
import { app } from '@/config';
|
||||
|
||||
export const client = axios.create({
|
||||
baseURL: app.apiBaseUrl,
|
||||
headers: {
|
||||
'Content-type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export function setClientAccessToken(token: string) {
|
||||
localStorage.setItem(app.accessTokenStoreKey, token);
|
||||
client.defaults.headers.common.authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
export function removeClientAccessToken() {
|
||||
localStorage.removeItem(app.accessTokenStoreKey);
|
||||
delete client.defaults.headers.common.authorization;
|
||||
}
|
||||
|
||||
export function loadAccessToken() {
|
||||
const token = localStorage.getItem(app.accessTokenStoreKey);
|
||||
setClientAccessToken(token ?? '');
|
||||
}
|
||||
10
front/src/api/dtos/auth.ts
Normal file
10
front/src/api/dtos/auth.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const LoginRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
});
|
||||
|
||||
export const LoginResponseSchema = z.object({
|
||||
accessToken: z.string(),
|
||||
});
|
||||
1
front/src/api/dtos/index.ts
Normal file
1
front/src/api/dtos/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
10
front/src/api/entities/abstract.ts
Normal file
10
front/src/api/entities/abstract.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AbstractEntity = z.object({
|
||||
id: z.number(),
|
||||
// created_at: z.date(),
|
||||
// updated_at: z.date(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
|
||||
30
front/src/api/entities/admin.ts
Normal file
30
front/src/api/entities/admin.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
import { AbstractEntity } from './abstract';
|
||||
import { booleanSchema } from '@/utilities/boolean';
|
||||
|
||||
export enum Roles {
|
||||
SuperAdmin = 'superadmin',
|
||||
Admin = 'admin',
|
||||
};
|
||||
|
||||
export const Admin = AbstractEntity.extend({
|
||||
username: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
image: z.string().url().nullable().nullish(),
|
||||
is_active: booleanSchema,
|
||||
role: z.nativeEnum(Roles),
|
||||
});
|
||||
|
||||
export const AdminDto = Admin.extend({
|
||||
// password: PasswordSchema,
|
||||
password: z.string().min(1),
|
||||
}).omit({
|
||||
id: true,
|
||||
image: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
}).partial();
|
||||
|
||||
export type Admin = z.infer<typeof Admin>;
|
||||
export type AdminDto = z.infer<typeof AdminDto>;
|
||||
|
||||
1
front/src/api/entities/index.ts
Normal file
1
front/src/api/entities/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './admin';
|
||||
572
front/src/api/helpers.ts
Normal file
572
front/src/api/helpers.ts
Normal file
@ -0,0 +1,572 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
QueryClient,
|
||||
UndefinedInitialDataOptions,
|
||||
useMutation,
|
||||
UseMutationOptions,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
UseQueryOptions,
|
||||
} from '@tanstack/react-query';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { client } from './axios';
|
||||
import { filter } from 'rambda';
|
||||
|
||||
interface EnhancedMutationParams<
|
||||
TData = unknown,
|
||||
TError = Error,
|
||||
TVariables = void,
|
||||
TContext = unknown,
|
||||
> extends Omit<
|
||||
UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||
'mutationFn' | 'onSuccess' | 'onError' | 'onSettled'
|
||||
> {
|
||||
onSuccess?: (
|
||||
data: TData,
|
||||
variables: TVariables,
|
||||
context: TContext,
|
||||
queryClient: QueryClient
|
||||
) => unknown;
|
||||
onError?: (
|
||||
error: TError,
|
||||
variables: TVariables,
|
||||
context: TContext | undefined,
|
||||
queryClient: QueryClient
|
||||
) => unknown;
|
||||
onSettled?: (
|
||||
data: TData | undefined,
|
||||
error: TError | null,
|
||||
variables: TVariables,
|
||||
context: TContext | undefined,
|
||||
queryClient: QueryClient
|
||||
) => unknown;
|
||||
}
|
||||
|
||||
export enum FilterRule {
|
||||
EQUALS = 'eq',
|
||||
NOT_EQUALS = 'neq',
|
||||
GREATER_THAN = 'gt',
|
||||
GREATER_THAN_OR_EQUALS = 'gte',
|
||||
LESS_THAN = 'lt',
|
||||
LESS_THAN_OR_EQUALS = 'lte',
|
||||
LIKE = 'like',
|
||||
NOT_LIKE = 'nlike',
|
||||
IN = 'in',
|
||||
NOT_IN = 'nin',
|
||||
IS_NULL = 'isnull',
|
||||
IS_NOT_NULL = 'isnotnull',
|
||||
BETWEEN = 'between',
|
||||
}
|
||||
|
||||
export type FilteringType = {
|
||||
field: string;
|
||||
rule: FilterRule,
|
||||
value: number | string | number[] | string[] | Date;
|
||||
label?: string | undefined;
|
||||
}
|
||||
|
||||
export function buildFilteringParam(
|
||||
filters: FilteringType[],
|
||||
searchParams: URLSearchParams,
|
||||
) {
|
||||
for (let [index, { field, rule, value }] of filters.entries()) {
|
||||
if (Array.isArray(value) && value.length) {
|
||||
value = value.join(',');
|
||||
} else if (typeof value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
searchParams.set(`filters[${index}]`, `${field}:${rule}:${value}`);
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URL with query parameters and route parameters
|
||||
*
|
||||
* @param base - The base URL with route parameters
|
||||
* @param queryParams - The query parameters
|
||||
* @param routeParams - The route parameters
|
||||
* @param filters - The filters
|
||||
* @returns The URL with query parameters
|
||||
* @example
|
||||
* createUrl('/api/users/:id', { page: 1 }, { id: 1 });
|
||||
* // => '/api/users/1?page=1'
|
||||
*/
|
||||
function createUrl(
|
||||
base: string,
|
||||
queryParams?: Record<string, string | number | undefined>,
|
||||
routeParams?: Record<string, string | number | undefined>,
|
||||
filters?: FilteringType[] | undefined,
|
||||
) {
|
||||
const url = Object.entries(routeParams ?? {}).reduce(
|
||||
(acc, [key, value]) => acc.replaceAll(`:${key}`, String(value)),
|
||||
base
|
||||
);
|
||||
|
||||
const query = new URLSearchParams();
|
||||
|
||||
if (filters?.length) {
|
||||
buildFilteringParam(filters, query);
|
||||
}
|
||||
|
||||
if (queryParams) {
|
||||
Object.entries(queryParams).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
query.append(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
return `${url}?${query.toString()}`;
|
||||
}
|
||||
|
||||
type QueryKey = [string] | [string, Record<string, string | number | undefined>];
|
||||
|
||||
function getQueryKey(
|
||||
queryKey: QueryKey,
|
||||
route: Record<string, string | number | undefined> = {},
|
||||
query: Record<string, string | number | undefined> = {},
|
||||
filters: FilteringType[] = [],
|
||||
) {
|
||||
const [mainKey, otherKeys = {}] = queryKey;
|
||||
|
||||
return [mainKey, { ...otherKeys, ...route, ...query, filters }];
|
||||
}
|
||||
|
||||
/** Handle request errors */
|
||||
function handleRequestError(error: unknown): never {
|
||||
if (isAxiosError(error)) {
|
||||
throw error.response?.data;
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
console.error(error.format());
|
||||
}
|
||||
|
||||
console.log(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
/* ----------------------------------- GET ---------------------------------- */
|
||||
|
||||
interface CreateGetQueryHookArgs<ResponseSchema extends z.ZodType> {
|
||||
/** The endpoint for the GET request */
|
||||
endpoint: string;
|
||||
/** The Zod schema for the response data */
|
||||
responseSchema: ResponseSchema;
|
||||
/** The query parameters for the react-query hook */
|
||||
rQueryParams: Omit<UseQueryOptions<{ statusCode: number; message: string; data: z.infer<ResponseSchema> }, Error>, 'queryFn'> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom hook for performing GET requests with react-query and Zod validation
|
||||
*
|
||||
* @example
|
||||
* const useGetUser = createGetQueryHook<typeof userSchema, { id: string }>({
|
||||
* endpoint: '/api/users/:id',
|
||||
* responseSchema: userSchema,
|
||||
* rQueryParams: { queryKey: ['getUser'] },
|
||||
* });
|
||||
*
|
||||
* const { data, error } = useGetUser({ route: { id: 1 } });
|
||||
*/
|
||||
export function createGetQueryHook<
|
||||
ResponseSchema extends z.ZodType,
|
||||
RouteParams extends Record<string, string | number | undefined> = {},
|
||||
QueryParams extends Record<string, string | number | undefined> = {},
|
||||
>({ endpoint, responseSchema, rQueryParams }: CreateGetQueryHookArgs<ResponseSchema>) {
|
||||
const queryFn = async (params?: { query?: QueryParams; route?: RouteParams; filters?: FilteringType[]; }): Promise<{
|
||||
statusCode: number;
|
||||
message: string;
|
||||
data: z.infer<ResponseSchema>;
|
||||
}> => {
|
||||
const url = createUrl(endpoint, params?.query, params?.route, params?.filters);
|
||||
|
||||
const responseWrapperSchema = z.object({
|
||||
statusCode: z.number(),
|
||||
message: z.string(),
|
||||
data: responseSchema,
|
||||
});
|
||||
|
||||
return client
|
||||
.get(url)
|
||||
.then((response) => {
|
||||
const parsed = responseWrapperSchema.parse(response.data);
|
||||
return {
|
||||
statusCode: parsed.statusCode,
|
||||
message: parsed.message,
|
||||
data: parsed.data,
|
||||
};
|
||||
})
|
||||
.catch(handleRequestError);
|
||||
};
|
||||
|
||||
return (params?: { query?: QueryParams; route?: RouteParams, filters?: FilteringType[] | undefined }) =>
|
||||
useQuery({
|
||||
...rQueryParams,
|
||||
queryKey: getQueryKey(rQueryParams.queryKey, params?.route, params?.query, params?.filters),
|
||||
queryFn: () => queryFn(params),
|
||||
}) as UseQueryResult<{
|
||||
statusCode: number;
|
||||
message: string;
|
||||
data: z.infer<ResponseSchema>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/* ---------------------------------- POST ---------------------------------- */
|
||||
|
||||
interface CreatePostMutationHookArgs<
|
||||
BodySchema extends z.ZodType,
|
||||
ResponseSchema extends z.ZodType,
|
||||
> {
|
||||
/** The endpoint for the POST request */
|
||||
endpoint: string;
|
||||
/** The Zod schema for the request body */
|
||||
bodySchema: BodySchema;
|
||||
/** The Zod schema for the response data */
|
||||
responseSchema: ResponseSchema;
|
||||
/** The mutation parameters for the react-query hook */
|
||||
rMutationParams?: EnhancedMutationParams<z.infer<ResponseSchema>, Error, z.infer<BodySchema>>;
|
||||
options?: { isMultipart?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom hook for performing POST requests with react-query and Zod validation
|
||||
*
|
||||
* @example
|
||||
* const useCreateUser = createPostMutationHook({
|
||||
* endpoint: '/api/users',
|
||||
* bodySchema: createUserSchema,
|
||||
* responseSchema: userSchema,
|
||||
* rMutationParams: { onSuccess: () => queryClient.invalidateQueries('getUsers') },
|
||||
* });
|
||||
*/
|
||||
export function createPostMutationHook<
|
||||
BodySchema extends z.ZodType,
|
||||
ResponseSchema extends z.ZodType,
|
||||
RouteParams extends Record<string, string | number | undefined> = {},
|
||||
QueryParams extends Record<string, string | number | undefined> = {},
|
||||
>({
|
||||
endpoint,
|
||||
bodySchema,
|
||||
responseSchema,
|
||||
rMutationParams,
|
||||
options,
|
||||
}: CreatePostMutationHookArgs<BodySchema, ResponseSchema>) {
|
||||
return (params?: { query?: QueryParams; route?: RouteParams }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const baseUrl = createUrl(endpoint, params?.query, params?.route);
|
||||
|
||||
const mutationFn = async ({
|
||||
variables,
|
||||
route,
|
||||
query,
|
||||
}: {
|
||||
variables: z.infer<BodySchema>;
|
||||
query?: QueryParams;
|
||||
route?: RouteParams;
|
||||
}) => {
|
||||
const url = createUrl(baseUrl, query, route);
|
||||
|
||||
const config = options?.isMultipart
|
||||
? { headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
: undefined;
|
||||
|
||||
const responseWrapperSchema = z.object({
|
||||
statusCode: z.number(),
|
||||
message: z.string(),
|
||||
data: responseSchema,
|
||||
});
|
||||
|
||||
return client
|
||||
.post(url, bodySchema.parse(variables), config)
|
||||
.then((response) => responseWrapperSchema.parse(response.data))
|
||||
.catch(handleRequestError);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
...rMutationParams,
|
||||
mutationFn,
|
||||
onSuccess: (data, variables, context) =>
|
||||
rMutationParams?.onSuccess?.(data, variables, context, queryClient),
|
||||
onError: (error, variables, context) =>
|
||||
rMutationParams?.onError?.(error, variables, context, queryClient),
|
||||
onSettled: (data, error, variables, context) =>
|
||||
rMutationParams?.onSettled?.(data, error, variables, context, queryClient),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/* ----------------------------------- PATCH ---------------------------------- */
|
||||
|
||||
interface CreatePatchMutationHookArgs<
|
||||
BodySchema extends z.ZodType,
|
||||
ResponseSchema extends z.ZodType,
|
||||
> {
|
||||
/** The endpoint for the PATCH request */
|
||||
endpoint: string;
|
||||
/** The Zod schema for the request body */
|
||||
bodySchema: BodySchema;
|
||||
/** The Zod schema for the response data */
|
||||
responseSchema: ResponseSchema;
|
||||
/** The mutation parameters for the react-query hook */
|
||||
rMutationParams?: EnhancedMutationParams<z.infer<ResponseSchema>, Error, z.infer<BodySchema>>;
|
||||
options?: { isMultipart?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom hook for performing PATCH requests with react-query and Zod validation
|
||||
*
|
||||
* @example
|
||||
* const useUpdateUser = createPatchMutationHook<typeof updateUserSchema, typeof userSchema, { id: string }>({
|
||||
* endpoint: '/api/users/:id',
|
||||
* bodySchema: updateUserSchema,
|
||||
* responseSchema: userSchema,
|
||||
* rMutationParams: { onSuccess: () => queryClient.invalidateQueries('getUsers') },
|
||||
* });
|
||||
*/
|
||||
export function createPatchMutationHook<
|
||||
BodySchema extends z.ZodType,
|
||||
ResponseSchema extends z.ZodType,
|
||||
RouteParams extends Record<string, string | number | undefined> = {},
|
||||
QueryParams extends Record<string, string | number | undefined> = {},
|
||||
>({
|
||||
endpoint,
|
||||
bodySchema,
|
||||
responseSchema,
|
||||
rMutationParams,
|
||||
options,
|
||||
}: CreatePatchMutationHookArgs<BodySchema, ResponseSchema>) {
|
||||
return (params?: { query?: QueryParams; route?: RouteParams }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const baseUrl = createUrl(endpoint, params?.query, params?.route);
|
||||
const mutationFn = async ({
|
||||
variables,
|
||||
route,
|
||||
query,
|
||||
}: {
|
||||
variables: z.infer<BodySchema>;
|
||||
query?: QueryParams;
|
||||
route?: RouteParams;
|
||||
}) => {
|
||||
const url = createUrl(baseUrl, query, route);
|
||||
|
||||
const config = options?.isMultipart
|
||||
? { headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
: undefined;
|
||||
|
||||
const responseWrapperSchema = z.object({
|
||||
statusCode: z.number(),
|
||||
message: z.string(),
|
||||
data: responseSchema,
|
||||
});
|
||||
|
||||
return client
|
||||
.patch(url, bodySchema.parse(variables), config)
|
||||
.then((response) => responseWrapperSchema.parse(response.data))
|
||||
.catch(handleRequestError);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
...rMutationParams,
|
||||
mutationFn,
|
||||
onSuccess: (data, variables, context) =>
|
||||
rMutationParams?.onSuccess?.(data, variables, context, queryClient),
|
||||
onError: (error, variables, context) =>
|
||||
rMutationParams?.onError?.(error, variables, context, queryClient),
|
||||
onSettled: (data, error, variables, context) =>
|
||||
rMutationParams?.onSettled?.(data, error, variables, context, queryClient),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------------- DELETE --------------------------------- */
|
||||
|
||||
interface CreateDeleteMutationHookArgs<
|
||||
TData = unknown,
|
||||
TError = Error,
|
||||
TVariables = void,
|
||||
TContext = unknown,
|
||||
> {
|
||||
/** The endpoint for the DELETE request */
|
||||
endpoint: string;
|
||||
/** The mutation parameters for the react-query hook */
|
||||
rMutationParams?: EnhancedMutationParams<TData, TError, TVariables, TContext>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom hook for performing DELETE requests with react-query
|
||||
*
|
||||
* @example
|
||||
* const useDeleteUser = createDeleteMutationHook<typeof userSchema, { id: string }>({
|
||||
* endpoint: '/api/users/:id',
|
||||
* rMutationParams: { onSuccess: () => queryClient.invalidateQueries('getUsers') },
|
||||
* });
|
||||
*/
|
||||
export function createDeleteMutationHook<
|
||||
ModelSchema extends z.ZodType,
|
||||
RouteParams extends Record<string, string | number | undefined> = {},
|
||||
QueryParams extends Record<string, string | number | undefined> = {},
|
||||
>({
|
||||
endpoint,
|
||||
rMutationParams,
|
||||
}: CreateDeleteMutationHookArgs<z.infer<ModelSchema>, Error, z.infer<ModelSchema>>) {
|
||||
return (params?: { query?: QueryParams; route?: RouteParams }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const baseUrl = createUrl(endpoint, params?.query, params?.route);
|
||||
|
||||
const mutationFn = async ({
|
||||
model,
|
||||
route,
|
||||
query,
|
||||
}: {
|
||||
model: z.infer<ModelSchema>;
|
||||
query?: QueryParams;
|
||||
route?: RouteParams;
|
||||
}) => {
|
||||
const url = createUrl(baseUrl, query, route);
|
||||
return client
|
||||
.delete(url)
|
||||
.then(() => model)
|
||||
.catch(handleRequestError);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
...rMutationParams,
|
||||
mutationFn,
|
||||
onSuccess: (data, variables, context) =>
|
||||
rMutationParams?.onSuccess?.(data, variables, context, queryClient),
|
||||
onError: (error, variables, context) =>
|
||||
rMutationParams?.onError?.(error, variables, context, queryClient),
|
||||
onSettled: (data, error, variables, context) =>
|
||||
rMutationParams?.onSettled?.(data, error, variables, context, queryClient),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------- PAGINATION ------------------------------- */
|
||||
|
||||
export type PaginationParams = {
|
||||
page?: number;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function usePagination(params?: PaginationParams) {
|
||||
const [page, setPage] = useState(params?.page ?? 1);
|
||||
const [size, setSize] = useState(params?.size ?? 15);
|
||||
|
||||
const onChangeLimit = (value: number) => {
|
||||
setSize(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return { page, size, setPage, setSize: onChangeLimit };
|
||||
}
|
||||
|
||||
export const PaginationMetaSchema = z.object({
|
||||
total: z.number().int().min(0),
|
||||
perPage: z.number().int().positive(),
|
||||
currentPage: z.number().int().positive().nullable(),
|
||||
lastPage: z.number().int().positive(),
|
||||
firstPage: z.number().int().positive(),
|
||||
firstPageUrl: z.string(),
|
||||
lastPageUrl: z.string(),
|
||||
nextPageUrl: z.string().nullable(),
|
||||
previousPageUrl: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type PaginationMeta = z.infer<typeof PaginationMetaSchema>;
|
||||
|
||||
interface CreatePaginationQueryHookArgs<DataSchema extends z.ZodType> {
|
||||
/** The endpoint for the GET request */
|
||||
endpoint: string;
|
||||
/** The Zod schema for the data attribute in response */
|
||||
dataSchema: DataSchema;
|
||||
/** The query parameters for the react-query hook */
|
||||
rQueryParams: Omit<UndefinedInitialDataOptions, 'queryFn' | 'queryKey'> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
}
|
||||
|
||||
export type SortableQueryParams = {
|
||||
sort?: `${string}:${'asc' | 'desc'}`;
|
||||
shuffle?: 'true' | 'false';
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a custom hook for performing paginated GET requests with react-query and Zod validation
|
||||
*
|
||||
* @example
|
||||
* const useGetUsers = createPaginatedQueryHook<typeof userSchema>({
|
||||
* endpoint: '/api/users',
|
||||
* dataSchema: userSchema,
|
||||
* queryParams: { queryKey: 'getUsers' },
|
||||
* });
|
||||
*/
|
||||
export function createPaginationQueryHook<
|
||||
DataSchema extends z.ZodType,
|
||||
QueryParams extends Record<string, string | number | undefined> = SortableQueryParams,
|
||||
RouteParams extends Record<string, string | number | undefined> = {},
|
||||
>({ endpoint, dataSchema, rQueryParams }: CreatePaginationQueryHookArgs<DataSchema>) {
|
||||
const queryFn = async (params: {
|
||||
query?: QueryParams & PaginationParams;
|
||||
route?: RouteParams;
|
||||
filters?: FilteringType[];
|
||||
}) => {
|
||||
const url = createUrl(endpoint, params?.query, params?.route, params?.filters);
|
||||
|
||||
const schema = z.object({
|
||||
statusCode: z.number(),
|
||||
message: z.string(),
|
||||
data: z.object({
|
||||
totalCount: z.number().positive(),
|
||||
items: dataSchema.array(),
|
||||
}),
|
||||
});
|
||||
|
||||
return client
|
||||
.get(url)
|
||||
.then((response) => schema.parse(response.data))
|
||||
.catch(handleRequestError);
|
||||
};
|
||||
|
||||
return (params?: { query: QueryParams & PaginationParams; route?: RouteParams, filters?: FilteringType[] | undefined }) => {
|
||||
const query = { page: 1, size: 25, ...params?.query } as unknown as QueryParams;
|
||||
const route = params?.route ?? ({} as RouteParams);
|
||||
const filters = params?.filters;
|
||||
|
||||
return useQuery({
|
||||
...rQueryParams,
|
||||
queryKey: getQueryKey(rQueryParams.queryKey, route, query, filters),
|
||||
queryFn: () => queryFn({ query, route, filters }),
|
||||
}) as UseQueryResult<{
|
||||
statusCode: number;
|
||||
message: string;
|
||||
data: {
|
||||
totalCount: number;
|
||||
items: z.infer<DataSchema>[];
|
||||
}
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export function useShuffle(initialValue = false) {
|
||||
const [shuffle, setShuffle] = useState(initialValue);
|
||||
|
||||
const toggleShuffle = () => {
|
||||
setShuffle(!shuffle);
|
||||
};
|
||||
|
||||
return {
|
||||
shuffle: shuffle ? 'true' : 'false' as 'true' | 'false' | undefined,
|
||||
isShuffled: shuffle,
|
||||
toggleShuffle,
|
||||
setShuffle: (value: boolean) => setShuffle(value),
|
||||
};
|
||||
}
|
||||
13
front/src/api/query-client.ts
Normal file
13
front/src/api/query-client.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
1
front/src/api/resources/index.ts
Normal file
1
front/src/api/resources/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './verify-token';
|
||||
6
front/src/api/resources/verify-token.ts
Normal file
6
front/src/api/resources/verify-token.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { client } from '../axios';
|
||||
|
||||
export async function verifyToken() {
|
||||
const response = await client.get('auth/verify-token');
|
||||
return response.data;
|
||||
}
|
||||
42
front/src/app.tsx
Normal file
42
front/src/app.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import '@mantine/carousel/styles.layer.css';
|
||||
import '@mantine/charts/styles.layer.css';
|
||||
import '@mantine/code-highlight/styles.layer.css';
|
||||
import '@mantine/core/styles.layer.css';
|
||||
import '@mantine/dates/styles.layer.css';
|
||||
import '@mantine/dropzone/styles.layer.css';
|
||||
import '@mantine/notifications/styles.layer.css';
|
||||
import '@mantine/nprogress/styles.layer.css';
|
||||
import '@mantine/spotlight/styles.layer.css';
|
||||
import 'mantine-datatable/styles.layer.css';
|
||||
import './global.css';
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { HelmetProvider } from 'react-helmet-async';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
import { NavigationProgress } from '@mantine/nprogress';
|
||||
import { queryClient } from '@/api/query-client';
|
||||
import { AuthProvider } from '@/providers/auth-provider';
|
||||
import { Router } from '@/routes/router';
|
||||
import { theme } from '@/theme';
|
||||
import { CustomModalsProvider } from './providers/modals-provider';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<AuthProvider>
|
||||
<MantineProvider theme={theme}>
|
||||
<Notifications position="bottom-center" />
|
||||
<NavigationProgress />
|
||||
<CustomModalsProvider>
|
||||
<Router />
|
||||
</CustomModalsProvider>
|
||||
</MantineProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
BIN
front/src/assets/app-demo.webp
Normal file
BIN
front/src/assets/app-demo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
9
front/src/assets/favicon.svg
Normal file
9
front/src/assets/favicon.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 163 163">
|
||||
<path fill="#339AF0"
|
||||
d="M162.162 81.5c0-45.011-36.301-81.5-81.08-81.5C36.301 0 0 36.489 0 81.5 0 126.51 36.301 163 81.081 163s81.081-36.49 81.081-81.5z" />
|
||||
<path fill="#fff"
|
||||
d="M65.983 43.049a6.234 6.234 0 00-.336 6.884 6.14 6.14 0 001.618 1.786c9.444 7.036 14.866 17.794 14.866 29.52 0 11.726-5.422 22.484-14.866 29.52a6.145 6.145 0 00-1.616 1.786 6.21 6.21 0 00-.694 4.693 6.21 6.21 0 001.028 2.186 6.151 6.151 0 006.457 2.319 6.154 6.154 0 002.177-1.035 50.083 50.083 0 007.947-7.39h17.493c3.406 0 6.174-2.772 6.174-6.194s-2.762-6.194-6.174-6.194h-9.655a49.165 49.165 0 004.071-19.69 49.167 49.167 0 00-4.07-19.692h9.66c3.406 0 6.173-2.771 6.173-6.194 0-3.422-2.762-6.193-6.173-6.193H82.574a50.112 50.112 0 00-7.952-7.397 6.15 6.15 0 00-4.578-1.153 6.189 6.189 0 00-4.055 2.438h-.006z" />
|
||||
<path fill="#fff" fill-rule="evenodd"
|
||||
d="M56.236 79.391a9.342 9.342 0 01.632-3.608 9.262 9.262 0 011.967-3.077 9.143 9.143 0 012.994-2.063 9.06 9.06 0 017.103 0 9.145 9.145 0 012.995 2.063 9.262 9.262 0 011.967 3.077 9.339 9.339 0 01-2.125 10.003 9.094 9.094 0 01-6.388 2.63 9.094 9.094 0 01-6.39-2.63 9.3 9.3 0 01-2.755-6.395z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
11
front/src/components/add-button.tsx
Normal file
11
front/src/components/add-button.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button, type ButtonProps, createPolymorphicComponent } from '@mantine/core';
|
||||
import { PiPlus as AddIcon } from 'react-icons/pi';
|
||||
|
||||
export type AddButtonProps = Omit<ButtonProps, 'leftSection'>;
|
||||
|
||||
export const AddButton = createPolymorphicComponent<'button', AddButtonProps>(
|
||||
forwardRef<HTMLButtonElement, AddButtonProps>((props, ref) => (
|
||||
<Button ref={ref} leftSection={<AddIcon size="1rem" />} {...props} />
|
||||
))
|
||||
);
|
||||
27
front/src/components/admin-info.tsx
Normal file
27
front/src/components/admin-info.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Admin } from "@/api/entities";
|
||||
import { firstLetters } from "@/utilities/text";
|
||||
import { Avatar, Box, Group, rem, Text } from "@mantine/core";
|
||||
import { FC } from "react";
|
||||
|
||||
interface Props {
|
||||
admin?: Admin | undefined | null;
|
||||
}
|
||||
|
||||
export const AdminInfo: FC<Props> = ({ admin }) => {
|
||||
return (
|
||||
admin ?
|
||||
<Group wrap="nowrap">
|
||||
<Avatar src={admin.image} alt={admin.username}>
|
||||
{firstLetters(admin.username)}
|
||||
</Avatar>
|
||||
<Box w="16rem">
|
||||
<Text truncate="end" fz={rem(14)}>{admin.username}</Text>
|
||||
<Text size="sm" c="dimmed" truncate="end" fz={rem(14)}>
|
||||
{admin.email}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group> :
|
||||
<>N/A</>
|
||||
)
|
||||
};
|
||||
|
||||
33
front/src/components/card-title.tsx
Normal file
33
front/src/components/card-title.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { ReactNode, forwardRef } from 'react';
|
||||
import { Text, CardSection, CardSectionProps, Group, Title } from '@mantine/core';
|
||||
|
||||
export interface CardTitleProps extends Omit<CardSectionProps, 'size' | 'c' | 'fw' | 'tt'> {
|
||||
title: ReactNode;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const CardTitle = forwardRef<HTMLDivElement, CardTitleProps>(
|
||||
({ title, description, style, actions, withBorder = true, ...props }, ref) => (
|
||||
<CardSection
|
||||
ref={ref}
|
||||
py="md"
|
||||
withBorder={withBorder}
|
||||
inheritPadding
|
||||
style={{ ...style, borderTop: 'none' }}
|
||||
{...props}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={5}>{title}</Title>
|
||||
{description && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{actions}
|
||||
</Group>
|
||||
</CardSection>
|
||||
)
|
||||
);
|
||||
47
front/src/components/color-scheme-toggler.tsx
Normal file
47
front/src/components/color-scheme-toggler.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
PiMoonDuotone as DarkIcon,
|
||||
PiSunDimDuotone as LightIcon,
|
||||
PiDesktop as SystemIcon,
|
||||
} from 'react-icons/pi';
|
||||
import {
|
||||
ActionIcon,
|
||||
ActionIconProps,
|
||||
ElementProps,
|
||||
MantineColorScheme,
|
||||
Tooltip,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import { match } from '@/utilities/match';
|
||||
|
||||
type ColorSchemeTogglerProps = Omit<ActionIconProps, 'children' | 'c' | 'onClick' | 'size'> &
|
||||
ElementProps<'button', keyof ActionIconProps>;
|
||||
|
||||
export function ColorSchemeToggler(props: ColorSchemeTogglerProps) {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
|
||||
const { label, icon: Icon } = match(
|
||||
[colorScheme === 'auto', { label: 'System', icon: SystemIcon }],
|
||||
[colorScheme === 'dark', { label: 'Dark', icon: DarkIcon }],
|
||||
[colorScheme === 'light', { label: 'Light', icon: LightIcon }],
|
||||
[true, { label: 'Dark', icon: DarkIcon }]
|
||||
);
|
||||
|
||||
const handleSchemeChange = () => {
|
||||
const nextColorScheme = match<MantineColorScheme>(
|
||||
[colorScheme === 'auto', 'dark'],
|
||||
[colorScheme === 'dark', 'light'],
|
||||
[colorScheme === 'light', 'auto'],
|
||||
[true, 'dark']
|
||||
);
|
||||
|
||||
setColorScheme(nextColorScheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon variant="transparent" c="inherit" onClick={handleSchemeChange} {...props}>
|
||||
<Icon size="100%" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
25
front/src/components/copy-icon-button.tsx
Normal file
25
front/src/components/copy-icon-button.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { CopyButton, ActionIcon, Tooltip, rem } from '@mantine/core';
|
||||
import { IconCopy, IconCheck } from '@tabler/icons-react';
|
||||
import { FC } from 'react';
|
||||
|
||||
interface IProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const CopyIconButton: FC<IProps> = ({ value }) => {
|
||||
return (
|
||||
<CopyButton value={value} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? 'Copied!' : 'Copy'} position="bottom" withArrow>
|
||||
<ActionIcon color={copied ? 'teal' : 'gray'} variant="subtle" onClick={copy}>
|
||||
{copied ? (
|
||||
<IconCheck style={{ width: rem(16) }} />
|
||||
) : (
|
||||
<IconCopy style={{ width: rem(16) }} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
}
|
||||
65
front/src/components/data-table/data-table-actions.tsx
Normal file
65
front/src/components/data-table/data-table-actions.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
PiTrashDuotone as DeleteIcon,
|
||||
PiPencilDuotone as EditIcon,
|
||||
PiClockCounterClockwiseDuotone as RestoreIcon,
|
||||
PiEyeDuotone as ShowIcon,
|
||||
} from 'react-icons/pi';
|
||||
import { ActionIcon, Group, GroupProps, Tooltip } from '@mantine/core';
|
||||
|
||||
export interface DataTableActionsProps extends GroupProps {
|
||||
disabledEdit?: boolean;
|
||||
onEdit?: () => void;
|
||||
onView?: () => void;
|
||||
onDelete?: () => void;
|
||||
onRestore?: () => void;
|
||||
}
|
||||
|
||||
export function DataTableActions({
|
||||
gap = 'xs',
|
||||
justify = 'right',
|
||||
wrap = 'nowrap',
|
||||
onEdit,
|
||||
onView,
|
||||
onDelete,
|
||||
onRestore,
|
||||
disabledEdit,
|
||||
children,
|
||||
...props
|
||||
}: DataTableActionsProps) {
|
||||
return (
|
||||
<Group gap={gap} justify={justify} wrap={wrap} {...props}>
|
||||
{onView && (
|
||||
<Tooltip label="Details">
|
||||
<ActionIcon variant="default" onClick={onView}>
|
||||
<ShowIcon size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Tooltip label={disabledEdit ? "Editing disabled" : "Edit"}>
|
||||
<ActionIcon disabled={disabledEdit} variant="default" onClick={onEdit}>
|
||||
<EditIcon size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<Tooltip label="Delete">
|
||||
<ActionIcon variant="default" onClick={onDelete}>
|
||||
<DeleteIcon size="1rem" color="red" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{onRestore && (
|
||||
<Tooltip label="Restore">
|
||||
<ActionIcon variant="default" onClick={onRestore}>
|
||||
<RestoreIcon size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
7
front/src/components/data-table/data-table-container.tsx
Normal file
7
front/src/components/data-table/data-table-container.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Card, CardProps } from '@mantine/core';
|
||||
|
||||
type DataTableContainerProps = CardProps;
|
||||
|
||||
export function DataTableContainer({ children, ...props }: DataTableContainerProps) {
|
||||
return <Card {...props}>{children}</Card>;
|
||||
}
|
||||
7
front/src/components/data-table/data-table-content.tsx
Normal file
7
front/src/components/data-table/data-table-content.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { CardSection, CardSectionProps, ElementProps } from '@mantine/core';
|
||||
|
||||
export const DataTableContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
CardSectionProps & ElementProps<'div', keyof CardSectionProps>
|
||||
>(({ children }, ref) => <CardSection ref={ref}>{children}</CardSection>);
|
||||
59
front/src/components/data-table/data-table-filters.tsx
Normal file
59
front/src/components/data-table/data-table-filters.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { PiTrashBold as ClearIcon } from 'react-icons/pi';
|
||||
import { Button, CardSection, Group, Pill, Text, type GroupProps } from '@mantine/core';
|
||||
import { FilteringType } from '@/api/helpers';
|
||||
|
||||
export interface DataTableFiltersProps extends Omit<GroupProps, 'children'> {
|
||||
filters: FilteringType[];
|
||||
onClear?: () => void;
|
||||
onRemove?: (field: string) => void;
|
||||
}
|
||||
|
||||
const formatFilterValue = (value: FilteringType['value']): string => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ');
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString();
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const DataTableFilters = forwardRef<HTMLDivElement, DataTableFiltersProps>(
|
||||
({ filters, onClear, onRemove, py = 'md', ...props }, ref) => {
|
||||
if (filters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardSection inheritPadding withBorder ref={ref}>
|
||||
<Group py={py} {...props}>
|
||||
{filters.map((filter) => (
|
||||
<Text fz="sm" c="dimmed" key={filter.field}>
|
||||
{filter.label || filter.field}:
|
||||
<Pill
|
||||
ml="0.25rem"
|
||||
withRemoveButton
|
||||
onRemove={() => onRemove?.(filter.field)}
|
||||
>
|
||||
{formatFilterValue(filter.value)}
|
||||
</Pill>
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{onClear && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
leftSection={<ClearIcon size="1rem" />}
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</CardSection>
|
||||
);
|
||||
}
|
||||
);
|
||||
77
front/src/components/data-table/data-table-tabs.tsx
Normal file
77
front/src/components/data-table/data-table-tabs.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { forwardRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
CardSection,
|
||||
CardSectionProps,
|
||||
Indicator,
|
||||
Tabs,
|
||||
type IndicatorProps,
|
||||
type TabsTabProps as MantineTabsTabProps,
|
||||
} from '@mantine/core';
|
||||
|
||||
interface TabsTabProps extends Omit<MantineTabsTabProps, 'children'> {
|
||||
label: string;
|
||||
counter?: number;
|
||||
hasIndicator?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableTabsProps extends Omit<CardSectionProps, 'size' | 'c' | 'fw' | 'tt'> {
|
||||
tabs: TabsTabProps[];
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
function IndicatorWrapper({ children, color }: Pick<IndicatorProps, 'children' | 'color'>) {
|
||||
return (
|
||||
<Indicator processing color={color} size={6} position="middle-end" offset={-8}>
|
||||
{children}
|
||||
</Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export const DataTableTabs = forwardRef<HTMLDivElement, DataTableTabsProps>(
|
||||
({ tabs, onChange, ...props }, ref) => {
|
||||
const [activeTab, setActiveTab] = useState<string | null>(tabs[0].value);
|
||||
|
||||
const handleTabChange = (value: string | null) => {
|
||||
setActiveTab(value);
|
||||
if (value) onChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<CardSection ref={ref} {...props}>
|
||||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.List>
|
||||
{tabs.map(({ counter, hasIndicator, rightSection, color, ...tab }) => {
|
||||
const BadgeWrapper = hasIndicator ? IndicatorWrapper : Box;
|
||||
|
||||
const badge =
|
||||
counter !== undefined ? (
|
||||
<BadgeWrapper color={color}>
|
||||
<Badge
|
||||
variant={activeTab === tab.value ? 'filled' : 'light'}
|
||||
color={color}
|
||||
radius="md"
|
||||
>
|
||||
{counter}
|
||||
</Badge>
|
||||
</BadgeWrapper>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Tabs.Tab
|
||||
{...tab}
|
||||
key={tab.value}
|
||||
rightSection={badge ?? rightSection}
|
||||
color={color}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</CardSection>
|
||||
);
|
||||
}
|
||||
);
|
||||
@ -0,0 +1,24 @@
|
||||
import { TextInput, type TextInputProps } from '@mantine/core';
|
||||
import type { UseDataTableReturn } from './use-data-table';
|
||||
|
||||
interface DataTableTextInputFilterProps
|
||||
extends Pick<UseDataTableReturn, 'filters'>,
|
||||
Omit<TextInputProps, 'value' | 'onChange'> {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DataTableTextInputFilter({
|
||||
name,
|
||||
label,
|
||||
filters,
|
||||
...props
|
||||
}: DataTableTextInputFilterProps) {
|
||||
// return (
|
||||
// <TextInput
|
||||
// {...props}
|
||||
// value={filters.filters[name]?.value as string}
|
||||
// onChange={(e) => filters.change({ name, label, value: e.currentTarget.value })}
|
||||
// />
|
||||
// );
|
||||
}
|
||||
28
front/src/components/data-table/index.ts
Normal file
28
front/src/components/data-table/index.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { DataTable as MantineDataTable } from 'mantine-datatable';
|
||||
import { capitalize } from '@/utilities/text';
|
||||
import { CardTitle } from '../card-title';
|
||||
import { DataTableActions } from './data-table-actions';
|
||||
import { DataTableContainer } from './data-table-container';
|
||||
import { DataTableContent } from './data-table-content';
|
||||
import { DataTableFilters } from './data-table-filters';
|
||||
import { DataTableTabs } from './data-table-tabs';
|
||||
import { DataTableTextInputFilter } from './data-table-text-input-filter';
|
||||
import { useDataTable } from './use-data-table';
|
||||
|
||||
export const DataTable = {
|
||||
useDataTable,
|
||||
Title: CardTitle,
|
||||
Container: DataTableContainer,
|
||||
Content: DataTableContent,
|
||||
Tabs: DataTableTabs,
|
||||
Filters: DataTableFilters,
|
||||
Actions: DataTableActions,
|
||||
Table: MantineDataTable,
|
||||
// TextInputFilter: DataTableTextInputFilter,
|
||||
recordsPerPageLabel: (resource: string) => `${capitalize(resource)} per page`,
|
||||
noRecordsText: (resource: string) => `No ${resource} found`,
|
||||
paginationText:
|
||||
(resource: string) =>
|
||||
({ from, to, totalRecords }: { from: number; to: number; totalRecords: number }) =>
|
||||
`Showing ${from} to ${to} of ${totalRecords} ${resource}`,
|
||||
};
|
||||
92
front/src/components/data-table/use-data-table.ts
Normal file
92
front/src/components/data-table/use-data-table.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { isDefined } from '@/utilities/is';
|
||||
import { DataTableTabsProps } from './data-table-tabs';
|
||||
import { FilteringType } from '@/api/helpers';
|
||||
|
||||
export interface UseDataTableArgs<SortableFields> {
|
||||
tabsConfig?: DataTableTabsProps;
|
||||
sortConfig?: {
|
||||
column: DataTableSortStatus<SortableFields>['columnAccessor'];
|
||||
direction: DataTableSortStatus<SortableFields>['direction'];
|
||||
};
|
||||
}
|
||||
|
||||
export type UseDataTableReturn<SortableFields = any> = ReturnType<
|
||||
typeof useDataTable<SortableFields>
|
||||
>;
|
||||
|
||||
export function useDataTable<SortableFields>({
|
||||
tabsConfig,
|
||||
sortConfig,
|
||||
}: UseDataTableArgs<SortableFields>) {
|
||||
const [currentTab, setCurrentTab] = useState(tabsConfig?.tabs[0].value);
|
||||
const [filters, setFilters] = useState<FilteringType[]>([]);
|
||||
const [debouncedFilters] = useDebouncedValue(filters, 500);
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<SortableFields>>({
|
||||
columnAccessor: sortConfig?.column ?? '',
|
||||
direction: sortConfig?.direction ?? 'asc',
|
||||
});
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setCurrentTab(value);
|
||||
tabsConfig?.onChange?.(value);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters([]);
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (field: string) => {
|
||||
setFilters((prevFilters) => {
|
||||
return prevFilters.filter(f => f.field !== field);
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangeFilter = (filter: FilteringType) => {
|
||||
if (isDefined(filter.value)) {
|
||||
setFilters(prevFilters => {
|
||||
const existingFilterIndex = prevFilters.findIndex(f => f.field === filter.field);
|
||||
if (existingFilterIndex >= 0) {
|
||||
const newFilters = [...prevFilters];
|
||||
newFilters[existingFilterIndex] = filter;
|
||||
return newFilters;
|
||||
}
|
||||
return [...prevFilters, filter];
|
||||
});
|
||||
} else {
|
||||
handleRemoveFilter(filter.field);
|
||||
}
|
||||
};
|
||||
|
||||
const queryFormattedFilters = useMemo(
|
||||
() =>
|
||||
Object.values(debouncedFilters)
|
||||
.filter(({ value }) => isDefined(value))
|
||||
.reduce((acc, { field, value }) => ({ ...acc, [field]: value }), {}),
|
||||
[debouncedFilters]
|
||||
);
|
||||
|
||||
return {
|
||||
tabs: {
|
||||
value: currentTab,
|
||||
change: handleTabChange,
|
||||
tabs: tabsConfig?.tabs ?? [],
|
||||
},
|
||||
filters: {
|
||||
filters,
|
||||
clear: handleClearFilters,
|
||||
change: handleChangeFilter,
|
||||
remove: handleRemoveFilter,
|
||||
query: queryFormattedFilters,
|
||||
},
|
||||
sort: {
|
||||
change: setSortStatus as any, // TODO: fix type
|
||||
column: sortStatus.columnAccessor as keyof SortableFields,
|
||||
direction: sortStatus.direction,
|
||||
status: sortStatus,
|
||||
query: `${sortStatus.columnAccessor.toString()}:${sortStatus.direction}` as const,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
11
front/src/components/export-button.tsx
Normal file
11
front/src/components/export-button.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button, type ButtonProps, createPolymorphicComponent } from '@mantine/core';
|
||||
import { PiExport as ExportIcon } from 'react-icons/pi';
|
||||
|
||||
export type ExportButtonProps = Omit<ButtonProps, 'leftSection'>;
|
||||
|
||||
export const ExportButton = createPolymorphicComponent<'button', ExportButtonProps>(
|
||||
forwardRef<HTMLButtonElement, ExportButtonProps>((props, ref) => (
|
||||
<Button ref={ref} leftSection={<ExportIcon size="1rem" />} {...props} />
|
||||
))
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user