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