This commit is contained in:
Денис 2026-02-14 19:33:09 +03:00
commit 87224f0816
219 changed files with 33584 additions and 0 deletions

22
.env.example Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
node_modules
dist
.git
.gitignore
*.md
*.log

25
back/.eslintrc.js Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,8 @@
{
"singleQuote": true,
"trailingComma": "all",
"quoteProps": "consistent",
"semi": true,
"printWidth": 120,
"tabWidth": 2
}

23
back/Dockerfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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 {}

View 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;
}
}

View 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);
}
}

View 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 { }

View 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();
}
}
}

View 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;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateAdminDto } from './create-admin.dto';
export class UpdateAdminDto extends PartialType(CreateAdminDto) {}

View 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;
}

View 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;
}

View 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
View 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
View File

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View 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;
}
}

View 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;
}
}

View 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 {}

View 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();
}
}
}

View File

@ -0,0 +1,10 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class LoginDto {
@IsEmail()
email: string;
@IsString()
@IsNotEmpty()
password: string;
}

View 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;
}

View 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 {}

View 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);
}
}
}

View File

@ -0,0 +1,4 @@
import { Module } from '@nestjs/common';
@Module({})
export class CommonModule {}

View 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;
});

View 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;
});

View 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 };
});

View File

@ -0,0 +1,3 @@
import { SetMetadata } from '@nestjs/common';
export const ResponseMessage = (message: string) => SetMetadata('response_message', message);

View 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,
};
});

View 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;
}

View 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',
}

View 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;
}
}

View File

@ -0,0 +1,9 @@
export class ColumnDecimalTransformer {
to(data: number): number {
return data;
}
from(data: string): number {
return parseFloat(data);
}
}

View 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
View 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
View 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
View 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
View 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: [],
});

View 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 {}

View 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
View 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
View File

@ -0,0 +1,4 @@
declare module "*.json" {
const value: any;
export default value;
}

4
back/tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

23
back/tsconfig.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
legacy-peer-deps=true

35
front/.prettierrc.mjs Normal file
View 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
View 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;

View 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
View File

@ -0,0 +1 @@
dist

28
front/.stylelintrc.json Normal file
View 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
View File

@ -0,0 +1,3 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.1.1.cjs

20
front/Dockerfile Normal file
View 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
View File

@ -0,0 +1,2 @@
# CRM

7
front/eslint.config.js Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

106
front/package.json Normal file
View 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
View 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
View 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 ?? '');
}

View 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(),
});

View File

@ -0,0 +1 @@
export * from './auth';

View 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(),
});

View 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>;

View File

@ -0,0 +1 @@
export * from './admin';

572
front/src/api/helpers.ts Normal file
View 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),
};
}

View 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,
},
},
});

View File

@ -0,0 +1 @@
export * from './verify-token';

View 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
View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View 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

View 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} />
))
);

View 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</>
)
};

View 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>
)
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>);

View 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>
);
}
);

View 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>
);
}
);

View File

@ -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 })}
// />
// );
}

View 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}`,
};

View 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;
}

View 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