feat: Initialize Yaltopia Telegram bot project with core architecture
This commit is contained in:
commit
23a455db95
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Telegram Bot Configuration
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
|
||||
# API Configuration
|
||||
API_BASE_URL=http://localhost:3000/api
|
||||
101
.gitignore
vendored
Normal file
101
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# 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
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Development Documentation
|
||||
|
||||
docs/
|
||||
110
README.md
Normal file
110
README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# Yaltopia Telegram Bot
|
||||
|
||||
A feature-rich Telegram bot for property listings built with TypeScript and organized using a feature-first architecture.
|
||||
|
||||
## Features
|
||||
|
||||
- 📱 **Phone-based Authentication**: Users share phone number to login/register
|
||||
- 🏠 **Property Management**: Browse, view, and add properties
|
||||
- 👤 **User Registration**: Complete registration flow with validation
|
||||
- 🔐 **Secure Login**: Password-based authentication for existing users
|
||||
- 📋 **Property Listings**: View all available properties
|
||||
- 🏘️ **My Properties**: Manage user's own property listings
|
||||
|
||||
## Architecture
|
||||
|
||||
This project follows a **feature-first approach** for better scalability:
|
||||
|
||||
```
|
||||
src/
|
||||
├── shared/ # Shared utilities and services
|
||||
│ ├── types/ # Common TypeScript interfaces
|
||||
│ └── services/ # Shared services (API, Session)
|
||||
├── features/ # Feature modules
|
||||
│ ├── auth/ # Authentication feature
|
||||
│ └── properties/ # Property management feature
|
||||
└── bot/ # Bot orchestration
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Environment setup:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and add your Telegram bot token:
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
||||
API_BASE_URL=http://localhost:3000/api
|
||||
```
|
||||
|
||||
3. **Build the project:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
4. **Start the bot:**
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
For development:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Bot Flow
|
||||
|
||||
### 1. Authentication Flow
|
||||
- User starts with `/start`
|
||||
- Bot requests phone number
|
||||
- If phone exists → Login with password
|
||||
- If new user → Registration flow (name, email, password, confirm)
|
||||
|
||||
### 2. Main Menu (After Login)
|
||||
- 🏘️ **Browse Properties**: View all available properties
|
||||
- 🏠 **My Properties**: View user's own listings
|
||||
- ➕ **Add Property**: Create new property listing
|
||||
- 👤 **Profile**: View user profile information
|
||||
|
||||
### 3. Property Creation Flow
|
||||
- Title → Description → Type (Rent/Sale) → Price → Area → Rooms → Toilets → Subcity → House Type
|
||||
|
||||
## API Integration
|
||||
|
||||
The bot integrates with your existing backend APIs:
|
||||
|
||||
- `POST /telegram-auth/telegram-register` - User registration
|
||||
- `POST /telegram-auth/telegram-login` - User login
|
||||
- `GET /telegram-auth/phone/{phone}/check` - Check phone existence
|
||||
- `GET /telegram-auth/validate-email/{email}` - Email validation
|
||||
- `GET /listings` - Get all properties
|
||||
- `POST /listings` - Create new property
|
||||
|
||||
## Development
|
||||
|
||||
### Scripts
|
||||
- `npm run build` - Compile TypeScript
|
||||
- `npm run dev` - Run in development mode with ts-node
|
||||
- `npm run watch` - Watch mode for TypeScript compilation
|
||||
- `npm start` - Run compiled JavaScript
|
||||
|
||||
### Adding New Features
|
||||
|
||||
1. Create feature directory in `src/features/`
|
||||
2. Add service class for API interactions
|
||||
3. Add handler class for bot interactions
|
||||
4. Integrate in main bot class (`src/bot/bot.ts`)
|
||||
|
||||
## Getting Your Bot Token
|
||||
|
||||
1. Message [@BotFather](https://t.me/botfather) on Telegram
|
||||
2. Use `/newbot` command
|
||||
3. Follow the setup process
|
||||
4. Copy the token to your `.env` file
|
||||
2733
package-lock.json
generated
Normal file
2733
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "yaltopia-telegram-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Yaltopia property listing Telegram bot",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"watch": "tsc -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-telegram-bot-api": "^0.66.0",
|
||||
"axios": "^1.6.0",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/node-telegram-bot-api": "^0.64.0",
|
||||
"typescript": "^5.3.0",
|
||||
"ts-node": "^10.9.0"
|
||||
}
|
||||
}
|
||||
370
src/bot/bot.ts
Normal file
370
src/bot/bot.ts
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { ApiService } from '../shared/services/api.service';
|
||||
import { SessionService } from '../shared/services/session.service';
|
||||
import { AuthService } from '../features/auth/auth.service';
|
||||
import { AuthHandler } from '../features/auth/auth.handler';
|
||||
import { PropertiesService } from '../features/properties/properties.service';
|
||||
import { PropertiesHandler } from '../features/properties/properties.handler';
|
||||
import { SystemMonitor } from '../shared/monitoring/system-monitor';
|
||||
import { Logger, LogLevel } from '../shared/monitoring/logger';
|
||||
import { AdminNotifier } from '../shared/monitoring/admin-notifier';
|
||||
|
||||
export class YaltopiaBot {
|
||||
private bot: TelegramBot;
|
||||
private apiService: ApiService;
|
||||
private sessionService: SessionService;
|
||||
private authService: AuthService;
|
||||
private authHandler: AuthHandler;
|
||||
private propertiesService: PropertiesService;
|
||||
private propertiesHandler: PropertiesHandler;
|
||||
private systemMonitor: SystemMonitor;
|
||||
private logger: Logger;
|
||||
private adminNotifier: AdminNotifier;
|
||||
|
||||
constructor(token: string, apiBaseUrl: string, adminChatIds: number[] = []) {
|
||||
this.bot = new TelegramBot(token, { polling: true });
|
||||
this.apiService = new ApiService(apiBaseUrl);
|
||||
this.sessionService = new SessionService();
|
||||
|
||||
// Initialize monitoring
|
||||
this.logger = new Logger(LogLevel.INFO);
|
||||
this.systemMonitor = new SystemMonitor({ adminChatId: adminChatIds[0] });
|
||||
this.adminNotifier = new AdminNotifier(this.bot, { adminChatIds });
|
||||
|
||||
// Initialize services
|
||||
this.authService = new AuthService(this.apiService);
|
||||
this.propertiesService = new PropertiesService(this.apiService);
|
||||
|
||||
// Initialize handlers
|
||||
this.authHandler = new AuthHandler(this.bot, this.authService, this.sessionService, this.apiService);
|
||||
this.propertiesHandler = new PropertiesHandler(this.bot, this.propertiesService, this.sessionService);
|
||||
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
private setupHandlers(): void {
|
||||
// Start command
|
||||
this.bot.onText(/\/start/, async (msg) => {
|
||||
this.systemMonitor.trackUserActivity(msg.chat.id, 'start', true);
|
||||
this.logger.userAction(msg.chat.id, 'start', true);
|
||||
await this.authHandler.handleStart(msg.chat.id);
|
||||
});
|
||||
|
||||
// Admin commands
|
||||
this.bot.onText(/\/(status|metrics|errors|users|restart)(.*)/, async (msg, match) => {
|
||||
if (match) {
|
||||
const command = match[1];
|
||||
const args = match[2] ? match[2].trim().split(' ').filter(arg => arg) : [];
|
||||
await this.adminNotifier.handleAdminCommand(msg.chat.id, `/${command}`, args);
|
||||
}
|
||||
});
|
||||
|
||||
// Contact handler (phone number)
|
||||
this.bot.on('contact', async (msg) => {
|
||||
if (msg.contact && msg.contact.phone_number) {
|
||||
this.systemMonitor.trackUserActivity(msg.chat.id, 'phone_shared', true);
|
||||
this.logger.userAction(msg.chat.id, 'phone_shared', true, { phone: msg.contact.phone_number });
|
||||
await this.authHandler.handlePhoneNumber(msg.chat.id, msg.contact.phone_number);
|
||||
}
|
||||
});
|
||||
|
||||
// Text message handler
|
||||
this.bot.on('message', async (msg) => {
|
||||
if (msg.text && !msg.text.startsWith('/') && !msg.contact) {
|
||||
await this.handleTextMessage(msg.chat.id, msg.text);
|
||||
}
|
||||
});
|
||||
|
||||
// Callback query handler for inline buttons
|
||||
this.bot.on('callback_query', async (callbackQuery) => {
|
||||
await this.propertiesHandler.handlePropertyCallback(callbackQuery);
|
||||
});
|
||||
|
||||
// Error handler
|
||||
this.bot.on('polling_error', (error) => {
|
||||
this.logger.error('Telegram polling error', undefined, { error: error.message }, error);
|
||||
this.systemMonitor.trackError(`Polling error: ${error.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTextMessage(chatId: number, text: string): Promise<void> {
|
||||
const session = this.sessionService.getSession(chatId);
|
||||
|
||||
// Restore access token if user is logged in
|
||||
if (session.user?.access_token) {
|
||||
this.apiService.setAccessToken(session.user.access_token);
|
||||
}
|
||||
|
||||
console.log(`💬 User ${chatId} sent message: "${text}" (step: ${session.step})`);
|
||||
|
||||
try {
|
||||
switch (session.step) {
|
||||
case 'LOGIN_PASSWORD':
|
||||
await this.authHandler.handleLoginPassword(chatId, text);
|
||||
break;
|
||||
|
||||
case 'REGISTER_NAME':
|
||||
await this.authHandler.handleRegistrationName(chatId, text);
|
||||
break;
|
||||
|
||||
case 'REGISTER_EMAIL':
|
||||
await this.authHandler.handleRegistrationEmail(chatId, text);
|
||||
break;
|
||||
|
||||
case 'REGISTER_PASSWORD':
|
||||
await this.authHandler.handleRegistrationPassword(chatId, text);
|
||||
break;
|
||||
|
||||
case 'REGISTER_CONFIRM_PASSWORD':
|
||||
await this.authHandler.handleRegistrationConfirmPassword(chatId, text);
|
||||
break;
|
||||
|
||||
case 'LOGGED_IN':
|
||||
await this.handleLoggedInCommands(chatId, text);
|
||||
break;
|
||||
|
||||
// Property creation steps
|
||||
case 'ADD_PROPERTY_TITLE':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyTitle(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_DESCRIPTION':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyDescription(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_TYPE':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyType(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_PRICE':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyPrice(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_AREA':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyArea(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_ROOMS':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyRooms(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_TOILETS':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyToilets(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_SUBCITY':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertySubcity(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ADD_PROPERTY_HOUSE_TYPE':
|
||||
if (text === '❌ Cancel') {
|
||||
await this.propertiesHandler.handleCancelPropertyCreation(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handlePropertyHouseType(chatId, text);
|
||||
}
|
||||
break;
|
||||
|
||||
// Edit property steps
|
||||
case 'EDIT_PROPERTY_TITLE':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'title', text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDIT_PROPERTY_DESCRIPTION':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'description', text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDIT_PROPERTY_PRICE':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'price', text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDIT_PROPERTY_AREA':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'area', text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDIT_PROPERTY_ROOMS':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'rooms', text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDIT_PROPERTY_TOILETS':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'toilets', text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDIT_PROPERTY_LOCATION':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'subcity', text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'EDIT_PROPERTY_HOUSE_TYPE':
|
||||
if (text === '❌ Cancel Edit') {
|
||||
await this.propertiesHandler.handleCancelEdit(chatId);
|
||||
} else if (text === '🚪 Logout') {
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
} else {
|
||||
await this.propertiesHandler.handleEditPropertyValue(chatId, 'houseType', text);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`❓ User ${chatId} in unknown step: ${session.step}`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'Please start by typing /start'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`💥 Error handling message for user ${chatId}:`, error);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Something went wrong. Please try again or type /start to restart.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLoggedInCommands(chatId: number, text: string): Promise<void> {
|
||||
switch (text) {
|
||||
case '🏘️ Browse Properties':
|
||||
await this.propertiesHandler.handleBrowseProperties(chatId);
|
||||
break;
|
||||
|
||||
case '🏠 My Properties':
|
||||
await this.propertiesHandler.handleMyProperties(chatId);
|
||||
break;
|
||||
|
||||
case '➕ Add Property':
|
||||
await this.propertiesHandler.handleAddProperty(chatId);
|
||||
break;
|
||||
|
||||
case '👤 Profile':
|
||||
await this.handleProfile(chatId);
|
||||
break;
|
||||
|
||||
case '🚪 Logout':
|
||||
await this.authHandler.handleLogout(chatId);
|
||||
break;
|
||||
|
||||
default:
|
||||
await this.bot.sendMessage(chatId,
|
||||
'Please select an option from the menu below:'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleProfile(chatId: number): Promise<void> {
|
||||
const session = this.sessionService.getSession(chatId);
|
||||
|
||||
if (!session.user) {
|
||||
await this.bot.sendMessage(chatId, '❌ User not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = session.user;
|
||||
console.log('🔍 [DEBUG] Profile - User object:', JSON.stringify(user, null, 2));
|
||||
|
||||
const profileMessage =
|
||||
`👤 Your Profile\n\n` +
|
||||
`📛 Name: ${user.name || 'Not available'}\n` +
|
||||
`📧 Email: ${user.email || 'Not available'}\n` +
|
||||
`📱 Phone: ${user.phone || 'Not available'}\n` +
|
||||
`👔 Role: ${user.role || 'Not available'}`;
|
||||
|
||||
await this.bot.sendMessage(chatId, profileMessage);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
console.log('🤖 Yaltopia Bot started successfully! [VERSION 2.0 - WITH VALIDATION]');
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.bot.stopPolling();
|
||||
console.log('🤖 Yaltopia Bot stopped.');
|
||||
}
|
||||
}
|
||||
265
src/features/auth/auth.handler.ts
Normal file
265
src/features/auth/auth.handler.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SessionService } from '../../shared/services/session.service';
|
||||
import { ApiService } from '../../shared/services/api.service';
|
||||
|
||||
export class AuthHandler {
|
||||
constructor(
|
||||
private bot: TelegramBot,
|
||||
private authService: AuthService,
|
||||
private sessionService: SessionService,
|
||||
private apiService: ApiService
|
||||
) {}
|
||||
|
||||
async handleStart(chatId: number): Promise<void> {
|
||||
console.log(`🚀 User ${chatId} started the bot`);
|
||||
this.sessionService.setSessionStep(chatId, 'PHONE');
|
||||
await this.bot.sendMessage(chatId,
|
||||
'🏠 Welcome to Yaltopia Property Bot!\n\n' +
|
||||
'Please share your phone number to get started.',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: 'Share Phone Number', request_contact: true }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePhoneNumber(chatId: number, phone: string): Promise<void> {
|
||||
console.log(`📱 User ${chatId} shared phone: ${phone}`);
|
||||
|
||||
// Normalize phone number format - ensure it starts with +
|
||||
let normalizedPhone = phone;
|
||||
if (!phone.startsWith('+')) {
|
||||
normalizedPhone = '+' + phone;
|
||||
}
|
||||
|
||||
console.log(`📱 Normalized phone: ${normalizedPhone}`);
|
||||
|
||||
const phoneCheck = await this.authService.checkPhoneExists(normalizedPhone);
|
||||
console.log(`📱 Phone check result for ${chatId}:`, phoneCheck);
|
||||
|
||||
if (!phoneCheck.exists) {
|
||||
// Scenario 3: Phone number doesn't exist - proceed with registration
|
||||
console.log(`📝 User ${chatId} - phone not found, directing to registration`);
|
||||
this.sessionService.setSessionStep(chatId, 'REGISTER_NAME', { phone: normalizedPhone });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'📝 New user! Let\'s create your AGENT account.\n\n' +
|
||||
'Please enter your full name:',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
} else if (phoneCheck.exists && phoneCheck.isAgent) {
|
||||
// Scenario 1: Phone exists and user is AGENT - proceed with login
|
||||
console.log(`🔐 User ${chatId} - AGENT account found, directing to login`);
|
||||
this.sessionService.setSessionStep(chatId, 'LOGIN_PASSWORD', { phone: normalizedPhone });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'<27> AGENT aoccount found! Please enter your password:',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
} else if (phoneCheck.exists && !phoneCheck.isAgent) {
|
||||
// Scenario 2: Phone exists but user has different role - contact admin (don't reveal role)
|
||||
console.log(`❌ User ${chatId} - account exists with role ${phoneCheck.userRole}, not AGENT`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
`❌ Account Access Restricted\n\n` +
|
||||
'📞 Please contact the administrator for assistance or use a different phone number.',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
} else {
|
||||
// Fallback case
|
||||
console.log(`❓ User ${chatId} - unexpected phone check result`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Unable to verify phone number. Please try again or contact support.',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async handleLoginPassword(chatId: number, password: string): Promise<void> {
|
||||
console.log(`🔐 User ${chatId} attempting login`);
|
||||
|
||||
const session = this.sessionService.getSession(chatId);
|
||||
const phone = session.data.phone;
|
||||
|
||||
console.log(`🔐 Login attempt - Phone: ${phone}, Password length: ${password.length}`);
|
||||
|
||||
const result = await this.authService.login(phone, password);
|
||||
|
||||
if (result.success && result.user) {
|
||||
console.log(`✅ User ${chatId} login successful, role: ${result.user.role}`);
|
||||
|
||||
// Additional role check after successful login
|
||||
if (result.user.role !== 'AGENT') {
|
||||
console.log(`❌ User ${chatId} denied access - role is ${result.user.role}, not AGENT`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Access denied. This bot is only available for AGENT accounts.\n\n' +
|
||||
'Please contact support if you believe this is an error.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionService.updateSession(chatId, {
|
||||
step: 'LOGGED_IN',
|
||||
user: result.user
|
||||
});
|
||||
await this.showMainMenu(chatId, result.user.name);
|
||||
} else {
|
||||
console.log(`❌ User ${chatId} login failed: ${result.message}`);
|
||||
|
||||
// Check if it might be an account status issue
|
||||
if (result.message?.toLowerCase().includes('pending') ||
|
||||
result.message?.toLowerCase().includes('inactive') ||
|
||||
result.message?.toLowerCase().includes('disabled')) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'⏳ Your account is pending activation.\n\n' +
|
||||
'Please contact the administrator to activate your account before you can login.'
|
||||
);
|
||||
} else {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid password. Please try again:\n\n' +
|
||||
'💡 Tip: Make sure you\'re using the same password you used during registration.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleRegistrationName(chatId: number, name: string): Promise<void> {
|
||||
console.log(`📝 User ${chatId} entered name: ${name}`);
|
||||
this.sessionService.setSessionStep(chatId, 'REGISTER_EMAIL', { name });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Name saved!\n\n' +
|
||||
'Please enter your email address:'
|
||||
);
|
||||
}
|
||||
|
||||
async handleRegistrationEmail(chatId: number, email: string): Promise<void> {
|
||||
console.log(`📧 User ${chatId} entered email: ${email}`);
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
console.log(`❌ User ${chatId} entered invalid email format`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid email format. Please enter a valid email:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = await this.authService.validateEmail(email);
|
||||
if (!isValid) {
|
||||
console.log(`❌ User ${chatId} email validation failed`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Email already exists or is invalid. Please try another:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ User ${chatId} email validated successfully`);
|
||||
this.sessionService.setSessionStep(chatId, 'REGISTER_PASSWORD', { email });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Email saved!\n\n' +
|
||||
'Please create a password (minimum 6 characters):'
|
||||
);
|
||||
}
|
||||
|
||||
async handleRegistrationPassword(chatId: number, password: string): Promise<void> {
|
||||
if (password.length < 6) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Password must be at least 6 characters. Please try again:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionService.setSessionStep(chatId, 'REGISTER_CONFIRM_PASSWORD', { password });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Password saved!\n\n' +
|
||||
'Please confirm your password:'
|
||||
);
|
||||
}
|
||||
|
||||
async handleRegistrationConfirmPassword(chatId: number, confirmPassword: string): Promise<void> {
|
||||
console.log(`🔐 User ${chatId} confirming password`);
|
||||
|
||||
const session = this.sessionService.getSession(chatId);
|
||||
const { phone, name, email, password } = session.data;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
console.log(`❌ User ${chatId} password confirmation failed`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Passwords don\'t match. Please confirm your password again:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = {
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
password,
|
||||
role: 'AGENT' as const // Backend now accepts role field
|
||||
};
|
||||
|
||||
console.log(`📝 User ${chatId} starting registration with data:`, { ...userData, password: '[HIDDEN]' });
|
||||
|
||||
const result = await this.authService.register(userData);
|
||||
|
||||
if (result.success && result.user) {
|
||||
console.log(`✅ User ${chatId} registration successful, assigned role: ${result.user.role}`);
|
||||
|
||||
// Verify the registered user has AGENT role
|
||||
if (result.user.role !== 'AGENT') {
|
||||
console.log(`❌ User ${chatId} registration failed - role is ${result.user.role}, not AGENT`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Registration failed: Invalid role assignment.\n\n' +
|
||||
'Please contact support for assistance.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// User has AGENT role - proceed with login
|
||||
this.sessionService.updateSession(chatId, {
|
||||
step: 'LOGGED_IN',
|
||||
user: result.user
|
||||
});
|
||||
await this.bot.sendMessage(chatId,
|
||||
'🎉 AGENT registration successful! Welcome to Yaltopia!'
|
||||
);
|
||||
await this.showMainMenu(chatId, result.user.name);
|
||||
} else {
|
||||
console.log(`❌ User ${chatId} registration failed: ${result.message}`);
|
||||
await this.bot.sendMessage(chatId,
|
||||
`❌ Registration failed: ${result.message || 'Unknown error'}\n\n` +
|
||||
'Please try again by typing /start'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async showMainMenu(chatId: number, userName: string): Promise<void> {
|
||||
await this.bot.sendMessage(chatId,
|
||||
`🏠 Welcome ${userName}!\n\n` +
|
||||
'What would you like to do?',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏘️ Browse Properties' }, { text: '🏠 My Properties' }],
|
||||
[{ text: '➕ Add Property' }, { text: '👤 Profile' }],
|
||||
[{ text: '🚪 Logout' }]
|
||||
],
|
||||
resize_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleLogout(chatId: number): Promise<void> {
|
||||
// Clear access token
|
||||
this.apiService.clearAccessToken();
|
||||
|
||||
this.sessionService.clearSession(chatId);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'👋 You have been logged out successfully!\n\n' +
|
||||
'Type /start to login again.',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
}
|
||||
}
|
||||
181
src/features/auth/auth.service.ts
Normal file
181
src/features/auth/auth.service.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { ApiService } from '../../shared/services/api.service';
|
||||
import { User, UserRegistration } from '../../shared/types';
|
||||
|
||||
export class AuthService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async checkPhoneExists(phone: string): Promise<{ exists: boolean; isAgent: boolean; userRole?: string; message?: string }> {
|
||||
console.log(`🔍 Checking phone existence for: ${phone}`);
|
||||
|
||||
const response = await this.apiService.get(`/telegram-auth/phone/${phone}/check`);
|
||||
|
||||
console.log('📞 Phone check API response:', JSON.stringify(response, null, 2));
|
||||
|
||||
if (!response.success) {
|
||||
console.log(`❌ Phone check failed: ${response.message}`);
|
||||
return { exists: false, isAgent: false, message: response.message };
|
||||
}
|
||||
|
||||
// Parse the actual API response structure
|
||||
const apiData = response.data as any;
|
||||
|
||||
if (!apiData) {
|
||||
console.log('📞 No data returned from phone check API');
|
||||
return { exists: false, isAgent: false };
|
||||
}
|
||||
|
||||
// Check if user exists based on the API response
|
||||
// The API uses "flow" to indicate user status: "login" = existing user, "register" = new user
|
||||
const flow = apiData.flow;
|
||||
const hasAccount = flow === 'login';
|
||||
|
||||
console.log(`📞 Phone check result - flow: ${flow}, hasAccount: ${hasAccount}`);
|
||||
|
||||
if (!hasAccount) {
|
||||
console.log('👤 New user detected - directing to registration');
|
||||
return { exists: false, isAgent: false };
|
||||
}
|
||||
|
||||
// User exists - we'll check their role after login since the phone check doesn't provide role info
|
||||
// and the user details endpoint may not be available
|
||||
console.log('👤 Existing user detected - will verify role after login');
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
isAgent: true, // Assume true for now, will verify after login
|
||||
userRole: undefined, // Will be checked after login
|
||||
message: undefined
|
||||
};
|
||||
}
|
||||
|
||||
async getUserByPhone(phone: string): Promise<{ success: boolean; user?: User; message?: string }> {
|
||||
const response = await this.apiService.get<User>(`/telegram-auth/user/${phone}`);
|
||||
return {
|
||||
success: response.success,
|
||||
user: response.data,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async validateEmail(email: string): Promise<boolean> {
|
||||
console.log(`📧 Validating email: ${email}`);
|
||||
const response = await this.apiService.get(`/telegram-auth/validate-email/${email}`);
|
||||
console.log(`📧 Email validation result: ${response.success}`);
|
||||
return response.success;
|
||||
}
|
||||
|
||||
async register(userData: UserRegistration): Promise<{ success: boolean; user?: User; message?: string }> {
|
||||
console.log('📝 Starting user registration:', { ...userData, password: '[HIDDEN]' });
|
||||
|
||||
const response = await this.apiService.post<User>('/telegram-auth/telegram-register', userData);
|
||||
|
||||
console.log('📝 Registration API full response:', JSON.stringify(response, null, 2));
|
||||
|
||||
// Extract user data and access token from the nested response structure
|
||||
let extractedUser = response.data;
|
||||
let accessToken: string | undefined;
|
||||
|
||||
// The API returns nested structure: response.data.user and response.data.access_token
|
||||
if (response.success && response.data) {
|
||||
const responseAny = response as any;
|
||||
|
||||
if (responseAny.data?.user) {
|
||||
extractedUser = responseAny.data.user;
|
||||
accessToken = responseAny.data.access_token;
|
||||
console.log('✅ Found user data in response.data.user');
|
||||
} else if (responseAny.userData?.user) {
|
||||
extractedUser = responseAny.userData.user;
|
||||
accessToken = responseAny.userData.access_token;
|
||||
console.log('✅ Found user data in response.userData.user');
|
||||
} else if (responseAny.user) {
|
||||
extractedUser = responseAny.user;
|
||||
accessToken = responseAny.access_token;
|
||||
console.log('✅ Found user data in response.user');
|
||||
} else {
|
||||
console.log('🔍 User data structure:', responseAny.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Set the access token for future API requests
|
||||
if (accessToken) {
|
||||
this.apiService.setAccessToken(accessToken);
|
||||
// Add token to user object for session storage
|
||||
if (extractedUser) {
|
||||
extractedUser.access_token = accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📝 Final extracted user:', extractedUser ? { ...extractedUser, password: undefined, access_token: '[HIDDEN]' } : 'No user data');
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
user: extractedUser,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async login(phone: string, password: string): Promise<{ success: boolean; user?: User; message?: string }> {
|
||||
console.log(`🔐 Attempting login for phone: ${phone}`);
|
||||
|
||||
const response = await this.apiService.post<User>('/telegram-auth/telegram-login', {
|
||||
phone,
|
||||
password
|
||||
});
|
||||
|
||||
console.log('🔐 Login API full response:', JSON.stringify(response, null, 2));
|
||||
|
||||
// Extract user data and access token from the nested response structure
|
||||
let userData = response.data;
|
||||
let accessToken: string | undefined;
|
||||
|
||||
// Check if the response structure is nested
|
||||
if (response.success && response.data) {
|
||||
const responseAny = response as any;
|
||||
|
||||
console.log('🔍 [DEBUG] Raw response.data:', JSON.stringify(responseAny.data, null, 2));
|
||||
|
||||
if (responseAny.data?.user) {
|
||||
userData = responseAny.data.user;
|
||||
accessToken = responseAny.data.access_token;
|
||||
console.log('✅ Found user data in response.data.user');
|
||||
console.log('🔍 [DEBUG] Extracted user data:', JSON.stringify(userData, null, 2));
|
||||
} else if (responseAny.userData?.user) {
|
||||
userData = responseAny.userData.user;
|
||||
accessToken = responseAny.userData.access_token;
|
||||
console.log('✅ Found user data in response.userData.user');
|
||||
console.log('🔍 [DEBUG] Extracted user data:', JSON.stringify(userData, null, 2));
|
||||
} else if (responseAny.user) {
|
||||
userData = responseAny.user;
|
||||
accessToken = responseAny.access_token;
|
||||
console.log('✅ Found user data in response.user');
|
||||
console.log('🔍 [DEBUG] Extracted user data:', JSON.stringify(userData, null, 2));
|
||||
} else {
|
||||
console.log('🔍 Login response structure:', responseAny.data);
|
||||
// Try to use response.data directly if it has user fields
|
||||
if (responseAny.data && (responseAny.data.name || responseAny.data.email || responseAny.data.phone)) {
|
||||
userData = responseAny.data;
|
||||
accessToken = responseAny.data.access_token || responseAny.access_token;
|
||||
console.log('✅ Using response.data directly as user data');
|
||||
console.log('🔍 [DEBUG] Direct user data:', JSON.stringify(userData, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the access token for future API requests
|
||||
if (accessToken) {
|
||||
this.apiService.setAccessToken(accessToken);
|
||||
// Add token to user object for session storage
|
||||
if (userData) {
|
||||
userData.access_token = accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔐 Final user data:', userData ? { ...userData, password: undefined, access_token: '[HIDDEN]' } : 'No user data');
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
user: userData,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
}
|
||||
811
src/features/properties/properties.handler.ts
Normal file
811
src/features/properties/properties.handler.ts
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import { PropertiesService } from './properties.service';
|
||||
import { SessionService } from '../../shared/services/session.service';
|
||||
import { Property } from '../../shared/types';
|
||||
|
||||
export class PropertiesHandler {
|
||||
constructor(
|
||||
private bot: TelegramBot,
|
||||
private propertiesService: PropertiesService,
|
||||
private sessionService: SessionService
|
||||
) {}
|
||||
|
||||
async handleBrowseProperties(chatId: number): Promise<void> {
|
||||
const result = await this.propertiesService.getProperties();
|
||||
|
||||
if (!result.success || !result.properties) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Failed to load properties. Please try again later.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.properties.length === 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'📭 No properties available at the moment.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.bot.sendMessage(chatId,
|
||||
`🏘️ Found ${result.properties.length} properties:`
|
||||
);
|
||||
|
||||
for (const property of result.properties.slice(0, 10)) { // Show first 10
|
||||
await this.sendPropertyCard(chatId, property);
|
||||
}
|
||||
|
||||
if (result.properties.length > 10) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
`... and ${result.properties.length - 10} more properties.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMyProperties(chatId: number): Promise<void> {
|
||||
const session = this.sessionService.getSession(chatId);
|
||||
|
||||
if (!session.user) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Please login first.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.propertiesService.getUserProperties(session.user.id);
|
||||
|
||||
if (!result.success || !result.properties) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Failed to load your properties. Please try again later.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.properties.length === 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'📭 You haven\'t added any properties yet.\n\n' +
|
||||
'Use "➕ Add Property" to create your first listing!'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.bot.sendMessage(chatId,
|
||||
`🏠 Your Properties (${result.properties.length}):`
|
||||
);
|
||||
|
||||
for (const property of result.properties) {
|
||||
await this.sendPropertyCard(chatId, property, true);
|
||||
}
|
||||
}
|
||||
|
||||
async handleAddProperty(chatId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_TITLE');
|
||||
await this.bot.sendMessage(chatId,
|
||||
'➕ Let\'s add a new property!\n\n' +
|
||||
'Please enter the property title:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyTitle(chatId: number, title: string): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_DESCRIPTION', { title });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Title saved!\n\n' +
|
||||
'Please enter a description for the property:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyDescription(chatId: number, description: string): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_TYPE', { description });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Description saved!\n\n' +
|
||||
'Is this property for rent or sale?',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏠 For Rent' }, { text: '💰 For Sale' }],
|
||||
[{ text: '❌ Cancel' }]
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyType(chatId: number, typeText: string): Promise<void> {
|
||||
console.log(`🔍 [DEBUG] handlePropertyType called with: "${typeText}"`);
|
||||
let type: string;
|
||||
|
||||
if (typeText === '🏠 For Rent') {
|
||||
console.log(`✅ [DEBUG] Valid input: For Rent`);
|
||||
type = 'RENT';
|
||||
} else if (typeText === '💰 For Sale') {
|
||||
console.log(`✅ [DEBUG] Valid input: For Sale`);
|
||||
type = 'SELL';
|
||||
} else {
|
||||
console.log(`❌ [DEBUG] Invalid input: "${typeText}" - showing error message`);
|
||||
// Invalid input - ask user to select from the buttons
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Please select either "🏠 For Rent" or "💰 For Sale" using the buttons below:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏠 For Rent' }, { text: '💰 For Sale' }],
|
||||
[{ text: '❌ Cancel' }]
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ [DEBUG] Setting type to: ${type}`);
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_PRICE', { type });
|
||||
|
||||
const priceLabel = type === 'RENT' ? 'monthly rent' : 'sale price';
|
||||
await this.bot.sendMessage(chatId,
|
||||
`✅ Type saved!\n\n` +
|
||||
`Please enter the ${priceLabel} (in ETB):`,
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyPrice(chatId: number, priceText: string): Promise<void> {
|
||||
const price = parseFloat(priceText);
|
||||
if (isNaN(price) || price <= 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid price. Please enter a valid number:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_AREA', { price });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Price saved!\n\n' +
|
||||
'Please enter the area in square meters:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyArea(chatId: number, areaText: string): Promise<void> {
|
||||
const area = parseFloat(areaText);
|
||||
if (isNaN(area) || area <= 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid area. Please enter a valid number:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_ROOMS', { area });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Area saved!\n\n' +
|
||||
'How many rooms does the property have?',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyRooms(chatId: number, roomsText: string): Promise<void> {
|
||||
const rooms = parseInt(roomsText);
|
||||
if (isNaN(rooms) || rooms <= 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid number of rooms. Please enter a valid number:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_TOILETS', { rooms });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Rooms saved!\n\n' +
|
||||
'How many toilets/bathrooms does the property have?',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyToilets(chatId: number, toiletsText: string): Promise<void> {
|
||||
const toilets = parseInt(toiletsText);
|
||||
if (isNaN(toilets) || toilets <= 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid number of toilets. Please enter a valid number:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_SUBCITY', { toilets });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Toilets saved!\n\n' +
|
||||
'Which subcity is the property located in? (e.g., Bole, Kirkos, etc.)',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertySubcity(chatId: number, subcity: string): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'ADD_PROPERTY_HOUSE_TYPE', { subcity });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Subcity saved!\n\n' +
|
||||
'What type of house is it?',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||
[{ text: '❌ Cancel' }]
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handlePropertyHouseType(chatId: number, houseType: string): Promise<void> {
|
||||
let cleanHouseType: string;
|
||||
|
||||
// Only accept valid house type options
|
||||
if (houseType === '🏢 Apartment') {
|
||||
cleanHouseType = 'Apartment';
|
||||
} else if (houseType === '🏠 Villa') {
|
||||
cleanHouseType = 'Villa';
|
||||
} else if (houseType === '🏘️ Condominium') {
|
||||
cleanHouseType = 'Condominium';
|
||||
} else if (houseType === '🏪 Commercial') {
|
||||
cleanHouseType = 'Commercial';
|
||||
} else {
|
||||
// Invalid input - ask user to select from the buttons
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Please select a house type using the buttons below:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||
[{ text: '❌ Cancel' }]
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.sessionService.getSession(chatId);
|
||||
const propertyData = session.data;
|
||||
|
||||
const property: Omit<Property, 'id'> = {
|
||||
title: propertyData.title,
|
||||
description: propertyData.description,
|
||||
type: propertyData.type,
|
||||
price: propertyData.price,
|
||||
area: propertyData.area,
|
||||
rooms: propertyData.rooms,
|
||||
toilets: propertyData.toilets,
|
||||
subcity: propertyData.subcity,
|
||||
houseType: cleanHouseType,
|
||||
status: 'DRAFT',
|
||||
taxRate: 15,
|
||||
isTaxable: false
|
||||
};
|
||||
|
||||
const result = await this.propertiesService.createProperty(property);
|
||||
|
||||
if (result.success && result.property) {
|
||||
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||
await this.bot.sendMessage(chatId,
|
||||
'🎉 Property added successfully!',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
await this.sendPropertyCard(chatId, result.property, true);
|
||||
await this.showMainMenu(chatId);
|
||||
} else {
|
||||
await this.bot.sendMessage(chatId,
|
||||
`❌ Failed to add property: ${result.message || 'Unknown error'}`,
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
await this.showMainMenu(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
async handleCancelPropertyCreation(chatId: number): Promise<void> {
|
||||
console.log(`❌ User ${chatId} cancelled property creation`);
|
||||
|
||||
// Clear any property creation data and return to main menu
|
||||
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Property creation cancelled.\n\n' +
|
||||
'Returning to main menu...',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
|
||||
await this.showMainMenu(chatId);
|
||||
}
|
||||
|
||||
async handlePropertyCallback(callbackQuery: any): Promise<void> {
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
const data = callbackQuery.data;
|
||||
const messageId = callbackQuery.message.message_id;
|
||||
|
||||
console.log(`🔘 Callback received: ${data} from user ${chatId}`);
|
||||
|
||||
// Answer the callback query to remove loading state
|
||||
await this.bot.answerCallbackQuery(callbackQuery.id);
|
||||
|
||||
if (data.startsWith('edit_property_')) {
|
||||
const propertyId = data.replace('edit_property_', '');
|
||||
await this.handleEditProperty(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('delete_property_')) {
|
||||
const propertyId = data.replace('delete_property_', '');
|
||||
await this.handleDeleteProperty(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('confirm_delete_')) {
|
||||
const propertyId = data.replace('confirm_delete_', '');
|
||||
await this.handleConfirmDelete(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('cancel_delete_')) {
|
||||
const propertyId = data.replace('cancel_delete_', '');
|
||||
await this.handleCancelDelete(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('back_to_property_')) {
|
||||
const propertyId = data.replace('back_to_property_', '');
|
||||
await this.handleBackToProperty(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_title_')) {
|
||||
const propertyId = data.replace('edit_title_', '');
|
||||
await this.handleEditTitle(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_desc_')) {
|
||||
const propertyId = data.replace('edit_desc_', '');
|
||||
await this.handleEditDescription(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_price_')) {
|
||||
const propertyId = data.replace('edit_price_', '');
|
||||
await this.handleEditPrice(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_area_')) {
|
||||
const propertyId = data.replace('edit_area_', '');
|
||||
await this.handleEditArea(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_rooms_')) {
|
||||
const propertyId = data.replace('edit_rooms_', '');
|
||||
await this.handleEditRooms(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_toilets_')) {
|
||||
const propertyId = data.replace('edit_toilets_', '');
|
||||
await this.handleEditToilets(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_location_')) {
|
||||
const propertyId = data.replace('edit_location_', '');
|
||||
await this.handleEditLocation(chatId, propertyId, messageId);
|
||||
} else if (data.startsWith('edit_house_type_')) {
|
||||
const propertyId = data.replace('edit_house_type_', '');
|
||||
await this.handleEditHouseType(chatId, propertyId, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
async handleEditProperty(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
await this.bot.editMessageReplyMarkup({
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '📝 Edit Title', callback_data: `edit_title_${propertyId}` },
|
||||
{ text: '📄 Edit Description', callback_data: `edit_desc_${propertyId}` }
|
||||
],
|
||||
[
|
||||
{ text: '💰 Edit Price', callback_data: `edit_price_${propertyId}` },
|
||||
{ text: '📐 Edit Area', callback_data: `edit_area_${propertyId}` }
|
||||
],
|
||||
[
|
||||
{ text: '🛏️ Edit Rooms', callback_data: `edit_rooms_${propertyId}` },
|
||||
{ text: '🚿 Edit Toilets', callback_data: `edit_toilets_${propertyId}` }
|
||||
],
|
||||
[
|
||||
{ text: '📍 Edit Location', callback_data: `edit_location_${propertyId}` },
|
||||
{ text: '🏠 Edit House Type', callback_data: `edit_house_type_${propertyId}` }
|
||||
],
|
||||
[
|
||||
{ text: '⬅️ Back', callback_data: `back_to_property_${propertyId}` }
|
||||
]
|
||||
]
|
||||
}, { chat_id: chatId, message_id: messageId });
|
||||
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✏️ Select what you want to edit:\n\n' +
|
||||
'💡 Tip: Click on the option you want to modify.'
|
||||
);
|
||||
}
|
||||
|
||||
async handleDeleteProperty(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
await this.bot.editMessageReplyMarkup({
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '⚠️ Yes, Delete', callback_data: `confirm_delete_${propertyId}` },
|
||||
{ text: '❌ Cancel', callback_data: `cancel_delete_${propertyId}` }
|
||||
]
|
||||
]
|
||||
}, { chat_id: chatId, message_id: messageId });
|
||||
|
||||
await this.bot.sendMessage(chatId,
|
||||
'⚠️ Are you sure you want to delete this property?\n\n' +
|
||||
'🚨 This action cannot be undone!'
|
||||
);
|
||||
}
|
||||
|
||||
async handleConfirmDelete(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
try {
|
||||
const result = await this.propertiesService.deleteProperty(propertyId);
|
||||
|
||||
if (result.success) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'✅ Property deleted successfully!'
|
||||
);
|
||||
|
||||
// Delete the property message
|
||||
await this.bot.deleteMessage(chatId, messageId);
|
||||
} else {
|
||||
await this.bot.sendMessage(chatId,
|
||||
`❌ Failed to delete property: ${result.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting property:', error);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ An error occurred while deleting the property.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async handleCancelDelete(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
// Get the property details to restore the original buttons
|
||||
try {
|
||||
const result = await this.propertiesService.getPropertyById(propertyId);
|
||||
|
||||
if (result.success && result.property) {
|
||||
await this.bot.editMessageReplyMarkup({
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '✏️ Edit', callback_data: `edit_property_${propertyId}` },
|
||||
{ text: '🗑️ Delete', callback_data: `delete_property_${propertyId}` }
|
||||
]
|
||||
]
|
||||
}, { chat_id: chatId, message_id: messageId });
|
||||
|
||||
await this.bot.sendMessage(chatId, '❌ Delete cancelled.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cancelling delete:', error);
|
||||
await this.bot.sendMessage(chatId, '❌ An error occurred.');
|
||||
}
|
||||
}
|
||||
|
||||
async handleBackToProperty(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
try {
|
||||
const result = await this.propertiesService.getPropertyById(propertyId);
|
||||
|
||||
if (result.success && result.property) {
|
||||
await this.bot.editMessageReplyMarkup({
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '✏️ Edit', callback_data: `edit_property_${propertyId}` },
|
||||
{ text: '🗑️ Delete', callback_data: `delete_property_${propertyId}` }
|
||||
]
|
||||
]
|
||||
}, { chat_id: chatId, message_id: messageId });
|
||||
|
||||
await this.bot.sendMessage(chatId, '⬅️ Back to property options.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error going back to property:', error);
|
||||
await this.bot.sendMessage(chatId, '❌ An error occurred.');
|
||||
}
|
||||
}
|
||||
|
||||
async handleEditTitle(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_TITLE', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'📝 Enter the new title for the property:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditDescription(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_DESCRIPTION', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'📄 Enter the new description for the property:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditPrice(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_PRICE', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'💰 Enter the new price (in ETB):',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditArea(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_AREA', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'📐 Enter the new area (in square meters):',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditRooms(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_ROOMS', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'🛏️ Enter the new number of rooms:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditToilets(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_TOILETS', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'🚿 Enter the new number of toilets:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditLocation(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_LOCATION', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'📍 Enter the new location (subcity):',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [[{ text: '❌ Cancel Edit' }]],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditHouseType(chatId: number, propertyId: string, messageId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'EDIT_PROPERTY_HOUSE_TYPE', { propertyId, messageId });
|
||||
await this.bot.sendMessage(chatId,
|
||||
'🏠 Select the new house type:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||
[{ text: '❌ Cancel Edit' }]
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async handleEditPropertyValue(chatId: number, field: string, value: string): Promise<void> {
|
||||
const session = this.sessionService.getSession(chatId);
|
||||
const { propertyId, messageId } = session.data;
|
||||
|
||||
try {
|
||||
let updateData: any = {};
|
||||
let processedValue: any = value;
|
||||
|
||||
// Process the value based on the field type
|
||||
switch (field) {
|
||||
case 'title':
|
||||
case 'description':
|
||||
case 'subcity':
|
||||
updateData[field] = value;
|
||||
break;
|
||||
case 'price':
|
||||
case 'area':
|
||||
processedValue = parseFloat(value);
|
||||
if (isNaN(processedValue) || processedValue <= 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid number. Please enter a valid positive number:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateData[field] = processedValue;
|
||||
break;
|
||||
case 'rooms':
|
||||
case 'toilets':
|
||||
processedValue = parseInt(value);
|
||||
if (isNaN(processedValue) || processedValue <= 0) {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Invalid number. Please enter a valid positive integer:'
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateData[field] = processedValue;
|
||||
break;
|
||||
case 'houseType':
|
||||
// Validate house type
|
||||
if (value === '🏢 Apartment') {
|
||||
updateData.houseType = 'Apartment';
|
||||
} else if (value === '🏠 Villa') {
|
||||
updateData.houseType = 'Villa';
|
||||
} else if (value === '🏘️ Condominium') {
|
||||
updateData.houseType = 'Condominium';
|
||||
} else if (value === '🏪 Commercial') {
|
||||
updateData.houseType = 'Commercial';
|
||||
} else {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Please select a house type using the buttons below:',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏢 Apartment' }, { text: '🏠 Villa' }],
|
||||
[{ text: '🏘️ Condominium' }, { text: '🏪 Commercial' }],
|
||||
[{ text: '❌ Cancel Edit' }]
|
||||
],
|
||||
resize_keyboard: true,
|
||||
one_time_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Update the property
|
||||
const result = await this.propertiesService.updateProperty(propertyId, updateData);
|
||||
|
||||
if (result.success && result.property) {
|
||||
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||
await this.bot.sendMessage(chatId,
|
||||
`✅ Property ${field} updated successfully!`,
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
|
||||
// Show the updated property
|
||||
await this.sendPropertyCard(chatId, result.property, true);
|
||||
await this.showMainMenu(chatId);
|
||||
} else {
|
||||
await this.bot.sendMessage(chatId,
|
||||
`❌ Failed to update property: ${result.message || 'Unknown error'}`,
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
await this.showMainMenu(chatId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating property:', error);
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ An error occurred while updating the property.',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
await this.showMainMenu(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
async handleCancelEdit(chatId: number): Promise<void> {
|
||||
this.sessionService.setSessionStep(chatId, 'LOGGED_IN');
|
||||
await this.bot.sendMessage(chatId,
|
||||
'❌ Edit cancelled.',
|
||||
{ reply_markup: { remove_keyboard: true } }
|
||||
);
|
||||
await this.showMainMenu(chatId);
|
||||
}
|
||||
|
||||
private async sendPropertyCard(chatId: number, property: Property, isOwner = false): Promise<void> {
|
||||
console.log(`🔍 [DEBUG] sendPropertyCard - isOwner: ${isOwner}, property.id: ${property.id}`);
|
||||
console.log(`🔍 [DEBUG] Property object:`, JSON.stringify(property, null, 2));
|
||||
|
||||
const typeEmoji = property.type === 'RENT' ? '🏠' : '💰';
|
||||
const statusEmoji = property.status === 'PUBLISHED' ? '✅' : '📝';
|
||||
|
||||
const message =
|
||||
`${typeEmoji} ${property.title}\n` +
|
||||
`${statusEmoji} Status: ${property.status}\n\n` +
|
||||
`📝 ${property.description}\n\n` +
|
||||
`💵 Price: ${property.price.toLocaleString()} ETB ${property.type === 'RENT' ? '/month' : ''}\n` +
|
||||
`📐 Area: ${property.area} m²\n` +
|
||||
`🛏️ Rooms: ${property.rooms}\n` +
|
||||
`🚿 Toilets: ${property.toilets}\n` +
|
||||
`📍 Location: ${property.subcity}\n` +
|
||||
`🏠 Type: ${property.houseType}`;
|
||||
|
||||
if (isOwner && property.id) {
|
||||
console.log(`✅ [DEBUG] Adding inline buttons for property ${property.id}`);
|
||||
// Add action buttons for property owner (removed publish button)
|
||||
await this.bot.sendMessage(chatId, message, {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '✏️ Edit', callback_data: `edit_property_${property.id}` },
|
||||
{ text: '🗑️ Delete', callback_data: `delete_property_${property.id}` }
|
||||
]
|
||||
]
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`❌ [DEBUG] No buttons - isOwner: ${isOwner}, property.id: ${property.id}`);
|
||||
await this.bot.sendMessage(chatId, message);
|
||||
}
|
||||
}
|
||||
|
||||
private async showMainMenu(chatId: number): Promise<void> {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'What would you like to do next?',
|
||||
{
|
||||
reply_markup: {
|
||||
keyboard: [
|
||||
[{ text: '🏘️ Browse Properties' }, { text: '🏠 My Properties' }],
|
||||
[{ text: '➕ Add Property' }, { text: '👤 Profile' }],
|
||||
[{ text: '🚪 Logout' }]
|
||||
],
|
||||
resize_keyboard: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
78
src/features/properties/properties.service.ts
Normal file
78
src/features/properties/properties.service.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { ApiService } from '../../shared/services/api.service';
|
||||
import { Property } from '../../shared/types';
|
||||
|
||||
export class PropertiesService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async getProperties(): Promise<{ success: boolean; properties?: Property[]; message?: string }> {
|
||||
const response = await this.apiService.get<Property[]>('/listings');
|
||||
return {
|
||||
success: response.success,
|
||||
properties: response.data,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async getUserProperties(userId: string): Promise<{ success: boolean; properties?: Property[]; message?: string }> {
|
||||
// Use the correct API endpoint for user's properties
|
||||
const response = await this.apiService.get<Property[]>('/listings/my-listings');
|
||||
return {
|
||||
success: response.success,
|
||||
properties: response.data,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async createProperty(property: Omit<Property, 'id'>): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||
const response = await this.apiService.post<Property>('/listings', property);
|
||||
return {
|
||||
success: response.success,
|
||||
property: response.data,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async getPropertyById(id: string): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||
const response = await this.apiService.get<Property>(`/listings/${id}`);
|
||||
return {
|
||||
success: response.success,
|
||||
property: response.data,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async updateProperty(id: string, property: Partial<Property>): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||
const response = await this.apiService.patch<Property>(`/listings/${id}`, property);
|
||||
return {
|
||||
success: response.success,
|
||||
property: response.data,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProperty(id: string): Promise<{ success: boolean; message?: string }> {
|
||||
const response = await this.apiService.delete(`/listings/${id}`);
|
||||
return {
|
||||
success: response.success,
|
||||
message: response.message
|
||||
};
|
||||
}
|
||||
|
||||
async togglePropertyStatus(id: string): Promise<{ success: boolean; property?: Property; message?: string }> {
|
||||
// First get the current property to know its current status
|
||||
const currentProperty = await this.getPropertyById(id);
|
||||
|
||||
if (!currentProperty.success || !currentProperty.property) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Property not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Toggle the status
|
||||
const newStatus = currentProperty.property.status === 'PUBLISHED' ? 'DRAFT' : 'PUBLISHED';
|
||||
|
||||
// Update the property with new status
|
||||
return await this.updateProperty(id, { status: newStatus });
|
||||
}
|
||||
}
|
||||
30
src/index.ts
Normal file
30
src/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import dotenv from 'dotenv';
|
||||
import { YaltopiaBot } from './bot/bot';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000/api';
|
||||
|
||||
if (!TELEGRAM_BOT_TOKEN) {
|
||||
console.error('❌ TELEGRAM_BOT_TOKEN is required in environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create and start the bot
|
||||
const bot = new YaltopiaBot(TELEGRAM_BOT_TOKEN, API_BASE_URL);
|
||||
bot.start();
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Shutting down bot...');
|
||||
bot.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n🛑 Shutting down bot...');
|
||||
bot.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
287
src/shared/monitoring/admin-notifier.ts
Normal file
287
src/shared/monitoring/admin-notifier.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import TelegramBot from 'node-telegram-bot-api';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface AdminConfig {
|
||||
adminChatIds: number[];
|
||||
webhookUrl?: string;
|
||||
emailConfig?: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
to: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class AdminNotifier {
|
||||
private bot: TelegramBot;
|
||||
private config: AdminConfig;
|
||||
|
||||
constructor(bot: TelegramBot, config: AdminConfig) {
|
||||
this.bot = bot;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async sendAlert(level: 'critical' | 'warning' | 'info', title: string, message: string, data?: any): Promise<void> {
|
||||
const emoji = this.getEmojiForLevel(level);
|
||||
const formattedMessage = this.formatMessage(level, title, message, data);
|
||||
|
||||
// Send to admin chat(s)
|
||||
for (const adminChatId of this.config.adminChatIds) {
|
||||
try {
|
||||
await this.bot.sendMessage(adminChatId, formattedMessage, {
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to send alert to admin ${adminChatId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Send to webhook if configured
|
||||
if (this.config.webhookUrl) {
|
||||
await this.sendWebhookAlert(level, title, message, data);
|
||||
}
|
||||
|
||||
// Send email if configured (would need email library)
|
||||
if (this.config.emailConfig) {
|
||||
await this.sendEmailAlert(level, title, message, data);
|
||||
}
|
||||
}
|
||||
|
||||
async sendSystemReport(report: string): Promise<void> {
|
||||
const chunks = this.splitMessage(report, 4000); // Telegram message limit
|
||||
|
||||
for (const adminChatId of this.config.adminChatIds) {
|
||||
try {
|
||||
for (const chunk of chunks) {
|
||||
await this.bot.sendMessage(adminChatId, chunk, {
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to send report to admin ${adminChatId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendErrorSummary(errors: Array<{ timestamp: Date; error: string; userId?: number; context?: string }>): Promise<void> {
|
||||
if (errors.length === 0) return;
|
||||
|
||||
const summary = this.formatErrorSummary(errors);
|
||||
await this.sendAlert('warning', 'Error Summary', summary);
|
||||
}
|
||||
|
||||
async sendUserActivityAlert(userId: number, activity: string, suspicious: boolean = false): Promise<void> {
|
||||
const level = suspicious ? 'warning' : 'info';
|
||||
const title = suspicious ? 'Suspicious User Activity' : 'User Activity Alert';
|
||||
const message = `User ${userId} performed: ${activity}`;
|
||||
|
||||
await this.sendAlert(level, title, message, { userId, activity, suspicious });
|
||||
}
|
||||
|
||||
async sendSystemHealthAlert(status: 'healthy' | 'warning' | 'critical', issues: string[]): Promise<void> {
|
||||
if (status === 'healthy') return;
|
||||
|
||||
const level = status === 'critical' ? 'critical' : 'warning';
|
||||
const title = `System Health: ${status.toUpperCase()}`;
|
||||
const message = issues.length > 0 ? issues.join('\n') : 'System health check completed';
|
||||
|
||||
await this.sendAlert(level, title, message, { status, issues });
|
||||
}
|
||||
|
||||
private getEmojiForLevel(level: string): string {
|
||||
switch (level) {
|
||||
case 'critical': return '🚨';
|
||||
case 'warning': return '⚠️';
|
||||
case 'info': return 'ℹ️';
|
||||
default: return '📢';
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(level: string, title: string, message: string, data?: any): string {
|
||||
const emoji = this.getEmojiForLevel(level);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
let formatted = `${emoji} **${title}**\n\n`;
|
||||
formatted += `📅 ${timestamp}\n`;
|
||||
formatted += `🔍 Level: ${level.toUpperCase()}\n\n`;
|
||||
formatted += `📝 ${message}\n`;
|
||||
|
||||
if (data) {
|
||||
formatted += `\n📊 **Details:**\n`;
|
||||
formatted += '```json\n';
|
||||
formatted += JSON.stringify(data, null, 2);
|
||||
formatted += '\n```';
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
private formatErrorSummary(errors: Array<{ timestamp: Date; error: string; userId?: number; context?: string }>): string {
|
||||
const recentErrors = errors.slice(0, 10); // Show last 10 errors
|
||||
|
||||
let summary = `📊 **Error Summary** (${errors.length} total errors)\n\n`;
|
||||
|
||||
for (const error of recentErrors) {
|
||||
summary += `🕐 ${error.timestamp.toISOString()}\n`;
|
||||
summary += `👤 User: ${error.userId || 'System'}\n`;
|
||||
summary += `❌ ${error.error}\n`;
|
||||
if (error.context) {
|
||||
summary += `📝 Context: ${error.context}\n`;
|
||||
}
|
||||
summary += '\n';
|
||||
}
|
||||
|
||||
if (errors.length > 10) {
|
||||
summary += `... and ${errors.length - 10} more errors\n`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private splitMessage(message: string, maxLength: number): string[] {
|
||||
if (message.length <= maxLength) {
|
||||
return [message];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
let currentChunk = '';
|
||||
|
||||
const lines = message.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (currentChunk.length + line.length + 1 > maxLength) {
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = '';
|
||||
}
|
||||
|
||||
// If single line is too long, split it
|
||||
if (line.length > maxLength) {
|
||||
const words = line.split(' ');
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
if (currentLine.length + word.length + 1 > maxLength) {
|
||||
if (currentLine) {
|
||||
chunks.push(currentLine);
|
||||
currentLine = '';
|
||||
}
|
||||
currentLine = word;
|
||||
} else {
|
||||
currentLine += (currentLine ? ' ' : '') + word;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
currentChunk = currentLine;
|
||||
}
|
||||
} else {
|
||||
currentChunk = line;
|
||||
}
|
||||
} else {
|
||||
currentChunk += (currentChunk ? '\n' : '') + line;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private async sendWebhookAlert(level: string, title: string, message: string, data?: any): Promise<void> {
|
||||
if (!this.config.webhookUrl) return;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
level,
|
||||
title,
|
||||
message,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
bot: 'Yaltopia Bot'
|
||||
};
|
||||
|
||||
await axios.post(this.config.webhookUrl, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send webhook alert:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendEmailAlert(level: string, title: string, message: string, data?: any): Promise<void> {
|
||||
// Email implementation would go here
|
||||
// You'd need to install nodemailer: npm install nodemailer @types/nodemailer
|
||||
console.log('Email alert would be sent here:', { level, title, message, data });
|
||||
}
|
||||
|
||||
// Admin Commands Handler
|
||||
async handleAdminCommand(chatId: number, command: string, args: string[]): Promise<void> {
|
||||
if (!this.config.adminChatIds.includes(chatId)) {
|
||||
await this.bot.sendMessage(chatId, '❌ Unauthorized access');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case '/status':
|
||||
await this.handleStatusCommand(chatId);
|
||||
break;
|
||||
case '/metrics':
|
||||
await this.handleMetricsCommand(chatId);
|
||||
break;
|
||||
case '/errors':
|
||||
await this.handleErrorsCommand(chatId, args);
|
||||
break;
|
||||
case '/users':
|
||||
await this.handleUsersCommand(chatId, args);
|
||||
break;
|
||||
case '/restart':
|
||||
await this.handleRestartCommand(chatId);
|
||||
break;
|
||||
default:
|
||||
await this.bot.sendMessage(chatId,
|
||||
'🤖 **Admin Commands:**\n\n' +
|
||||
'/status - System health status\n' +
|
||||
'/metrics - Current metrics\n' +
|
||||
'/errors [count] - Recent errors\n' +
|
||||
'/users [count] - Recent user activity\n' +
|
||||
'/restart - Restart bot (with confirmation)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStatusCommand(chatId: number): Promise<void> {
|
||||
// This would integrate with SystemMonitor
|
||||
await this.bot.sendMessage(chatId, '✅ System status check would be displayed here');
|
||||
}
|
||||
|
||||
private async handleMetricsCommand(chatId: number): Promise<void> {
|
||||
// This would integrate with SystemMonitor
|
||||
await this.bot.sendMessage(chatId, '📊 System metrics would be displayed here');
|
||||
}
|
||||
|
||||
private async handleErrorsCommand(chatId: number, args: string[]): Promise<void> {
|
||||
const count = args[0] ? parseInt(args[0]) : 10;
|
||||
await this.bot.sendMessage(chatId, `📋 Last ${count} errors would be displayed here`);
|
||||
}
|
||||
|
||||
private async handleUsersCommand(chatId: number, args: string[]): Promise<void> {
|
||||
const count = args[0] ? parseInt(args[0]) : 20;
|
||||
await this.bot.sendMessage(chatId, `👥 Last ${count} user activities would be displayed here`);
|
||||
}
|
||||
|
||||
private async handleRestartCommand(chatId: number): Promise<void> {
|
||||
await this.bot.sendMessage(chatId,
|
||||
'⚠️ **Restart Confirmation Required**\n\n' +
|
||||
'Are you sure you want to restart the bot?\n' +
|
||||
'Reply with "CONFIRM RESTART" to proceed.'
|
||||
);
|
||||
}
|
||||
}
|
||||
250
src/shared/monitoring/logger.ts
Normal file
250
src/shared/monitoring/logger.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export enum LogLevel {
|
||||
ERROR = 0,
|
||||
WARN = 1,
|
||||
INFO = 2,
|
||||
DEBUG = 3
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: Date;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
userId?: number;
|
||||
context?: any;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private logLevel: LogLevel;
|
||||
private logDir: string;
|
||||
private maxFileSize: number;
|
||||
private maxFiles: number;
|
||||
|
||||
constructor(
|
||||
logLevel: LogLevel = LogLevel.INFO,
|
||||
logDir: string = './logs',
|
||||
maxFileSize: number = 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: number = 5
|
||||
) {
|
||||
this.logLevel = logLevel;
|
||||
this.logDir = logDir;
|
||||
this.maxFileSize = maxFileSize;
|
||||
this.maxFiles = maxFiles;
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private shouldLog(level: LogLevel): boolean {
|
||||
return level <= this.logLevel;
|
||||
}
|
||||
|
||||
private formatLogEntry(entry: LogEntry): string {
|
||||
const timestamp = entry.timestamp.toISOString();
|
||||
const level = LogLevel[entry.level];
|
||||
const userId = entry.userId ? `[User:${entry.userId}]` : '';
|
||||
const context = entry.context ? `[${JSON.stringify(entry.context)}]` : '';
|
||||
|
||||
let logLine = `${timestamp} [${level}] ${userId} ${entry.message} ${context}`;
|
||||
|
||||
if (entry.stack) {
|
||||
logLine += `\nStack: ${entry.stack}`;
|
||||
}
|
||||
|
||||
return logLine;
|
||||
}
|
||||
|
||||
private writeToFile(entry: LogEntry): void {
|
||||
const filename = `bot-${new Date().toISOString().split('T')[0]}.log`;
|
||||
const filepath = path.join(this.logDir, filename);
|
||||
|
||||
try {
|
||||
const logLine = this.formatLogEntry(entry) + '\n';
|
||||
|
||||
// Check file size and rotate if necessary
|
||||
if (fs.existsSync(filepath)) {
|
||||
const stats = fs.statSync(filepath);
|
||||
if (stats.size > this.maxFileSize) {
|
||||
this.rotateLogFile(filepath);
|
||||
}
|
||||
}
|
||||
|
||||
fs.appendFileSync(filepath, logLine);
|
||||
} catch (error) {
|
||||
console.error('Failed to write to log file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private rotateLogFile(filepath: string): void {
|
||||
const dir = path.dirname(filepath);
|
||||
const basename = path.basename(filepath, '.log');
|
||||
|
||||
// Rotate existing files
|
||||
for (let i = this.maxFiles - 1; i > 0; i--) {
|
||||
const oldFile = path.join(dir, `${basename}.${i}.log`);
|
||||
const newFile = path.join(dir, `${basename}.${i + 1}.log`);
|
||||
|
||||
if (fs.existsSync(oldFile)) {
|
||||
if (i === this.maxFiles - 1) {
|
||||
fs.unlinkSync(oldFile); // Delete oldest file
|
||||
} else {
|
||||
fs.renameSync(oldFile, newFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move current file to .1
|
||||
const rotatedFile = path.join(dir, `${basename}.1.log`);
|
||||
fs.renameSync(filepath, rotatedFile);
|
||||
}
|
||||
|
||||
error(message: string, userId?: number, context?: any, error?: Error): void {
|
||||
if (!this.shouldLog(LogLevel.ERROR)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level: LogLevel.ERROR,
|
||||
message,
|
||||
userId,
|
||||
context,
|
||||
stack: error?.stack
|
||||
};
|
||||
|
||||
console.error(`🚨 ${this.formatLogEntry(entry)}`);
|
||||
this.writeToFile(entry);
|
||||
}
|
||||
|
||||
warn(message: string, userId?: number, context?: any): void {
|
||||
if (!this.shouldLog(LogLevel.WARN)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level: LogLevel.WARN,
|
||||
message,
|
||||
userId,
|
||||
context
|
||||
};
|
||||
|
||||
console.warn(`⚠️ ${this.formatLogEntry(entry)}`);
|
||||
this.writeToFile(entry);
|
||||
}
|
||||
|
||||
info(message: string, userId?: number, context?: any): void {
|
||||
if (!this.shouldLog(LogLevel.INFO)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level: LogLevel.INFO,
|
||||
message,
|
||||
userId,
|
||||
context
|
||||
};
|
||||
|
||||
console.log(`ℹ️ ${this.formatLogEntry(entry)}`);
|
||||
this.writeToFile(entry);
|
||||
}
|
||||
|
||||
debug(message: string, userId?: number, context?: any): void {
|
||||
if (!this.shouldLog(LogLevel.DEBUG)) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level: LogLevel.DEBUG,
|
||||
message,
|
||||
userId,
|
||||
context
|
||||
};
|
||||
|
||||
console.log(`🐛 ${this.formatLogEntry(entry)}`);
|
||||
this.writeToFile(entry);
|
||||
}
|
||||
|
||||
// Structured logging methods
|
||||
userAction(userId: number, action: string, success: boolean, details?: any): void {
|
||||
this.info(`User action: ${action}`, userId, { success, ...details });
|
||||
}
|
||||
|
||||
apiCall(method: string, endpoint: string, status: number, duration: number, userId?: number): void {
|
||||
this.info(`API call: ${method} ${endpoint}`, userId, { status, duration });
|
||||
}
|
||||
|
||||
securityEvent(event: string, userId?: number, details?: any): void {
|
||||
this.warn(`Security event: ${event}`, userId, details);
|
||||
}
|
||||
|
||||
systemEvent(event: string, details?: any): void {
|
||||
this.info(`System event: ${event}`, undefined, details);
|
||||
}
|
||||
|
||||
// Get recent logs
|
||||
getRecentLogs(hours: number = 24, level?: LogLevel): LogEntry[] {
|
||||
const logs: LogEntry[] = [];
|
||||
const cutoffTime = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(this.logDir)
|
||||
.filter(f => f.endsWith('.log'))
|
||||
.sort()
|
||||
.reverse(); // Most recent first
|
||||
|
||||
for (const file of files) {
|
||||
const filepath = path.join(this.logDir, file);
|
||||
const content = fs.readFileSync(filepath, 'utf8');
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z) \[(\w+)\]/);
|
||||
if (match) {
|
||||
const timestamp = new Date(match[1]);
|
||||
if (timestamp < cutoffTime) continue;
|
||||
|
||||
const logLevel = LogLevel[match[2] as keyof typeof LogLevel];
|
||||
if (level !== undefined && logLevel !== level) continue;
|
||||
|
||||
// Parse the rest of the log entry (simplified)
|
||||
logs.push({
|
||||
timestamp,
|
||||
level: logLevel,
|
||||
message: line.substring(match[0].length).trim()
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip malformed log lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.error('Failed to read log files', undefined, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
return logs.slice(0, 1000); // Limit to 1000 entries
|
||||
}
|
||||
|
||||
// Clean old log files
|
||||
cleanOldLogs(daysToKeep: number = 30): void {
|
||||
try {
|
||||
const files = fs.readdirSync(this.logDir);
|
||||
const cutoffTime = Date.now() - daysToKeep * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.log')) continue;
|
||||
|
||||
const filepath = path.join(this.logDir, file);
|
||||
const stats = fs.statSync(filepath);
|
||||
|
||||
if (stats.mtime.getTime() < cutoffTime) {
|
||||
fs.unlinkSync(filepath);
|
||||
this.info(`Cleaned old log file: ${file}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.error('Failed to clean old logs', undefined, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
312
src/shared/monitoring/system-monitor.ts
Normal file
312
src/shared/monitoring/system-monitor.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import axios from 'axios';
|
||||
|
||||
export interface SystemMetrics {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalMessages: number;
|
||||
errorCount: number;
|
||||
apiCalls: number;
|
||||
successfulLogins: number;
|
||||
failedLogins: number;
|
||||
registrations: number;
|
||||
propertiesCreated: number;
|
||||
uptime: number;
|
||||
memoryUsage: NodeJS.MemoryUsage;
|
||||
lastError?: {
|
||||
timestamp: Date;
|
||||
error: string;
|
||||
userId?: number;
|
||||
context?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlertConfig {
|
||||
adminChatId?: number;
|
||||
webhookUrl?: string;
|
||||
emailConfig?: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
to: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class SystemMonitor extends EventEmitter {
|
||||
private metrics: SystemMetrics;
|
||||
private startTime: Date;
|
||||
private alertConfig: AlertConfig;
|
||||
private activeUsers = new Set<number>();
|
||||
private recentErrors: Array<{ timestamp: Date; error: string; userId?: number; context?: string }> = [];
|
||||
|
||||
constructor(alertConfig: AlertConfig = {}) {
|
||||
super();
|
||||
this.startTime = new Date();
|
||||
this.alertConfig = alertConfig;
|
||||
this.metrics = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
totalMessages: 0,
|
||||
errorCount: 0,
|
||||
apiCalls: 0,
|
||||
successfulLogins: 0,
|
||||
failedLogins: 0,
|
||||
registrations: 0,
|
||||
propertiesCreated: 0,
|
||||
uptime: 0,
|
||||
memoryUsage: process.memoryUsage()
|
||||
};
|
||||
|
||||
// Update metrics every minute
|
||||
setInterval(() => {
|
||||
this.updateMetrics();
|
||||
}, 60000);
|
||||
|
||||
// Clean old errors every hour
|
||||
setInterval(() => {
|
||||
this.cleanOldErrors();
|
||||
}, 3600000);
|
||||
}
|
||||
|
||||
// User Activity Tracking
|
||||
trackUserActivity(userId: number, action: string, success: boolean = true, context?: any): void {
|
||||
this.activeUsers.add(userId);
|
||||
this.metrics.totalMessages++;
|
||||
|
||||
const logEntry = {
|
||||
timestamp: new Date(),
|
||||
userId,
|
||||
action,
|
||||
success,
|
||||
context: context ? JSON.stringify(context) : undefined
|
||||
};
|
||||
|
||||
console.log(`📊 [MONITOR] User:${userId} Action:${action} Success:${success}`, context || '');
|
||||
|
||||
// Track specific actions
|
||||
switch (action) {
|
||||
case 'login':
|
||||
if (success) {
|
||||
this.metrics.successfulLogins++;
|
||||
} else {
|
||||
this.metrics.failedLogins++;
|
||||
this.trackError(`Login failed for user ${userId}`, userId, 'authentication');
|
||||
}
|
||||
break;
|
||||
case 'registration':
|
||||
if (success) {
|
||||
this.metrics.registrations++;
|
||||
this.metrics.totalUsers++;
|
||||
}
|
||||
break;
|
||||
case 'property_created':
|
||||
if (success) {
|
||||
this.metrics.propertiesCreated++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
this.emit('userActivity', logEntry);
|
||||
}
|
||||
|
||||
// API Call Tracking
|
||||
trackApiCall(endpoint: string, method: string, status: number, duration: number): void {
|
||||
this.metrics.apiCalls++;
|
||||
|
||||
const logEntry = {
|
||||
timestamp: new Date(),
|
||||
endpoint,
|
||||
method,
|
||||
status,
|
||||
duration
|
||||
};
|
||||
|
||||
console.log(`🌐 [API] ${method} ${endpoint} - ${status} (${duration}ms)`);
|
||||
|
||||
if (status >= 400) {
|
||||
this.trackError(`API Error: ${method} ${endpoint} returned ${status}`, undefined, 'api');
|
||||
}
|
||||
|
||||
this.emit('apiCall', logEntry);
|
||||
}
|
||||
|
||||
// Error Tracking
|
||||
trackError(error: string, userId?: number, context?: string): void {
|
||||
this.metrics.errorCount++;
|
||||
|
||||
const errorEntry = {
|
||||
timestamp: new Date(),
|
||||
error,
|
||||
userId,
|
||||
context
|
||||
};
|
||||
|
||||
this.recentErrors.push(errorEntry);
|
||||
this.metrics.lastError = errorEntry;
|
||||
|
||||
console.error(`🚨 [ERROR] ${error}`, { userId, context });
|
||||
|
||||
// Send alert for critical errors
|
||||
this.sendAlert('error', `🚨 Bot Error: ${error}`, {
|
||||
userId,
|
||||
context,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
this.emit('error', errorEntry);
|
||||
}
|
||||
|
||||
// System Health Check
|
||||
getHealthStatus(): { status: 'healthy' | 'warning' | 'critical'; issues: string[] } {
|
||||
const issues: string[] = [];
|
||||
let status: 'healthy' | 'warning' | 'critical' = 'healthy';
|
||||
|
||||
// Check error rate (last hour)
|
||||
const recentErrorCount = this.recentErrors.filter(
|
||||
e => Date.now() - e.timestamp.getTime() < 3600000
|
||||
).length;
|
||||
|
||||
if (recentErrorCount > 50) {
|
||||
status = 'critical';
|
||||
issues.push(`High error rate: ${recentErrorCount} errors in last hour`);
|
||||
} else if (recentErrorCount > 10) {
|
||||
status = 'warning';
|
||||
issues.push(`Elevated error rate: ${recentErrorCount} errors in last hour`);
|
||||
}
|
||||
|
||||
// Check memory usage
|
||||
const memUsage = process.memoryUsage();
|
||||
const memUsageMB = memUsage.heapUsed / 1024 / 1024;
|
||||
|
||||
if (memUsageMB > 500) {
|
||||
status = status === 'critical' ? 'critical' : 'warning';
|
||||
issues.push(`High memory usage: ${memUsageMB.toFixed(2)}MB`);
|
||||
}
|
||||
|
||||
// Check failed login rate
|
||||
const failedLoginRate = this.metrics.failedLogins / Math.max(this.metrics.successfulLogins, 1);
|
||||
if (failedLoginRate > 0.5) {
|
||||
status = status === 'critical' ? 'critical' : 'warning';
|
||||
issues.push(`High failed login rate: ${(failedLoginRate * 100).toFixed(1)}%`);
|
||||
}
|
||||
|
||||
return { status, issues };
|
||||
}
|
||||
|
||||
// Get Current Metrics
|
||||
getMetrics(): SystemMetrics {
|
||||
this.updateMetrics();
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
// Generate Report
|
||||
generateReport(): string {
|
||||
const health = this.getHealthStatus();
|
||||
const metrics = this.getMetrics();
|
||||
|
||||
return `
|
||||
🤖 **Yaltopia Bot System Report**
|
||||
📅 Generated: ${new Date().toISOString()}
|
||||
|
||||
**System Health: ${health.status.toUpperCase()}**
|
||||
${health.issues.length > 0 ? '⚠️ Issues:\n' + health.issues.map(i => `- ${i}`).join('\n') : '✅ All systems operational'}
|
||||
|
||||
**Usage Statistics:**
|
||||
👥 Total Users: ${metrics.totalUsers}
|
||||
🟢 Active Users (24h): ${metrics.activeUsers}
|
||||
💬 Total Messages: ${metrics.totalMessages}
|
||||
🌐 API Calls: ${metrics.apiCalls}
|
||||
|
||||
**Authentication:**
|
||||
✅ Successful Logins: ${metrics.successfulLogins}
|
||||
❌ Failed Logins: ${metrics.failedLogins}
|
||||
📝 Registrations: ${metrics.registrations}
|
||||
|
||||
**Business Metrics:**
|
||||
🏠 Properties Created: ${metrics.propertiesCreated}
|
||||
|
||||
**System Performance:**
|
||||
⏱️ Uptime: ${Math.floor(metrics.uptime / 3600)}h ${Math.floor((metrics.uptime % 3600) / 60)}m
|
||||
💾 Memory Usage: ${(metrics.memoryUsage.heapUsed / 1024 / 1024).toFixed(2)}MB
|
||||
🚨 Total Errors: ${metrics.errorCount}
|
||||
|
||||
${metrics.lastError ? `**Last Error:**
|
||||
🕐 ${metrics.lastError.timestamp.toISOString()}
|
||||
👤 User: ${metrics.lastError.userId || 'System'}
|
||||
📝 ${metrics.lastError.error}` : '✅ No recent errors'}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// Send Alert
|
||||
private async sendAlert(type: 'error' | 'warning' | 'info', message: string, data?: any): Promise<void> {
|
||||
const alert = {
|
||||
type,
|
||||
message,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
botName: 'Yaltopia Bot'
|
||||
};
|
||||
|
||||
console.log(`🚨 [ALERT] ${type.toUpperCase()}: ${message}`);
|
||||
|
||||
// Send to admin via Telegram (if configured)
|
||||
if (this.alertConfig.adminChatId) {
|
||||
try {
|
||||
// This would need the bot instance to send messages
|
||||
this.emit('adminAlert', { chatId: this.alertConfig.adminChatId, message: `🚨 ${message}` });
|
||||
} catch (error) {
|
||||
console.error('Failed to send Telegram alert:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Send to webhook (if configured)
|
||||
if (this.alertConfig.webhookUrl) {
|
||||
try {
|
||||
await axios.post(this.alertConfig.webhookUrl, alert, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send webhook alert:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('alert', alert);
|
||||
}
|
||||
|
||||
private updateMetrics(): void {
|
||||
this.metrics.uptime = Math.floor((Date.now() - this.startTime.getTime()) / 1000);
|
||||
this.metrics.activeUsers = this.activeUsers.size;
|
||||
this.metrics.memoryUsage = process.memoryUsage();
|
||||
|
||||
// Clear active users older than 24 hours (simplified)
|
||||
// In production, you'd want more sophisticated tracking
|
||||
if (this.metrics.uptime % 86400 === 0) { // Every 24 hours
|
||||
this.activeUsers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private cleanOldErrors(): void {
|
||||
const oneDayAgo = Date.now() - 86400000; // 24 hours
|
||||
this.recentErrors = this.recentErrors.filter(e => e.timestamp.getTime() > oneDayAgo);
|
||||
}
|
||||
|
||||
// Daily Report
|
||||
scheduleDailyReport(): void {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0); // 9 AM daily report
|
||||
|
||||
const msUntilTomorrow = tomorrow.getTime() - now.getTime();
|
||||
|
||||
setTimeout(() => {
|
||||
this.sendAlert('info', 'Daily System Report', this.generateReport());
|
||||
|
||||
// Schedule next daily report
|
||||
setInterval(() => {
|
||||
this.sendAlert('info', 'Daily System Report', this.generateReport());
|
||||
}, 86400000); // 24 hours
|
||||
}, msUntilTomorrow);
|
||||
}
|
||||
}
|
||||
133
src/shared/security/input-validator.ts
Normal file
133
src/shared/security/input-validator.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
export class InputValidator {
|
||||
static readonly MAX_TITLE_LENGTH = 100;
|
||||
static readonly MAX_DESCRIPTION_LENGTH = 500;
|
||||
static readonly MAX_SUBCITY_LENGTH = 50;
|
||||
static readonly MAX_NAME_LENGTH = 100;
|
||||
static readonly MIN_PASSWORD_LENGTH = 6;
|
||||
static readonly MAX_PASSWORD_LENGTH = 128;
|
||||
|
||||
static sanitizeText(input: string): string {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/[<>\"'&]/g, '') // Remove potentially dangerous characters
|
||||
.replace(/\s+/g, ' '); // Normalize whitespace
|
||||
}
|
||||
|
||||
static validateTitle(title: string): { valid: boolean; error?: string } {
|
||||
const sanitized = this.sanitizeText(title);
|
||||
|
||||
if (!sanitized || sanitized.length === 0) {
|
||||
return { valid: false, error: 'Title cannot be empty' };
|
||||
}
|
||||
|
||||
if (sanitized.length > this.MAX_TITLE_LENGTH) {
|
||||
return { valid: false, error: `Title too long (max ${this.MAX_TITLE_LENGTH} characters)` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateDescription(description: string): { valid: boolean; error?: string } {
|
||||
const sanitized = this.sanitizeText(description);
|
||||
|
||||
if (!sanitized || sanitized.length === 0) {
|
||||
return { valid: false, error: 'Description cannot be empty' };
|
||||
}
|
||||
|
||||
if (sanitized.length > this.MAX_DESCRIPTION_LENGTH) {
|
||||
return { valid: false, error: `Description too long (max ${this.MAX_DESCRIPTION_LENGTH} characters)` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateName(name: string): { valid: boolean; error?: string } {
|
||||
const sanitized = this.sanitizeText(name);
|
||||
|
||||
if (!sanitized || sanitized.length === 0) {
|
||||
return { valid: false, error: 'Name cannot be empty' };
|
||||
}
|
||||
|
||||
if (sanitized.length > this.MAX_NAME_LENGTH) {
|
||||
return { valid: false, error: `Name too long (max ${this.MAX_NAME_LENGTH} characters)` };
|
||||
}
|
||||
|
||||
// Check for valid name pattern (letters, spaces, basic punctuation)
|
||||
if (!/^[a-zA-Z\s\-\.]+$/.test(sanitized)) {
|
||||
return { valid: false, error: 'Name contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validatePassword(password: string): { valid: boolean; error?: string } {
|
||||
if (!password || password.length === 0) {
|
||||
return { valid: false, error: 'Password cannot be empty' };
|
||||
}
|
||||
|
||||
if (password.length < this.MIN_PASSWORD_LENGTH) {
|
||||
return { valid: false, error: `Password too short (min ${this.MIN_PASSWORD_LENGTH} characters)` };
|
||||
}
|
||||
|
||||
if (password.length > this.MAX_PASSWORD_LENGTH) {
|
||||
return { valid: false, error: `Password too long (max ${this.MAX_PASSWORD_LENGTH} characters)` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateEmail(email: string): { valid: boolean; error?: string } {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
if (!email || email.length === 0) {
|
||||
return { valid: false, error: 'Email cannot be empty' };
|
||||
}
|
||||
|
||||
if (!emailRegex.test(email)) {
|
||||
return { valid: false, error: 'Invalid email format' };
|
||||
}
|
||||
|
||||
if (email.length > 254) { // RFC 5321 limit
|
||||
return { valid: false, error: 'Email too long' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateNumericInput(input: string, min: number = 0, max: number = Number.MAX_SAFE_INTEGER): { valid: boolean; value?: number; error?: string } {
|
||||
const num = parseFloat(input);
|
||||
|
||||
if (isNaN(num)) {
|
||||
return { valid: false, error: 'Please enter a valid number' };
|
||||
}
|
||||
|
||||
if (num < min) {
|
||||
return { valid: false, error: `Value must be at least ${min}` };
|
||||
}
|
||||
|
||||
if (num > max) {
|
||||
return { valid: false, error: `Value must be at most ${max}` };
|
||||
}
|
||||
|
||||
return { valid: true, value: num };
|
||||
}
|
||||
|
||||
static validateSubcity(subcity: string): { valid: boolean; error?: string } {
|
||||
const sanitized = this.sanitizeText(subcity);
|
||||
|
||||
if (!sanitized || sanitized.length === 0) {
|
||||
return { valid: false, error: 'Subcity cannot be empty' };
|
||||
}
|
||||
|
||||
if (sanitized.length > this.MAX_SUBCITY_LENGTH) {
|
||||
return { valid: false, error: `Subcity name too long (max ${this.MAX_SUBCITY_LENGTH} characters)` };
|
||||
}
|
||||
|
||||
// Check for valid subcity pattern (letters, spaces, basic punctuation)
|
||||
if (!/^[a-zA-Z\s\-\.]+$/.test(sanitized)) {
|
||||
return { valid: false, error: 'Subcity name contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
37
src/shared/security/rate-limiter.ts
Normal file
37
src/shared/security/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export class RateLimiter {
|
||||
private userActions = new Map<number, number[]>();
|
||||
private readonly maxRequests: number;
|
||||
private readonly windowMs: number;
|
||||
|
||||
constructor(maxRequests = 10, windowMs = 60000) { // 10 requests per minute
|
||||
this.maxRequests = maxRequests;
|
||||
this.windowMs = windowMs;
|
||||
}
|
||||
|
||||
isRateLimited(chatId: number): boolean {
|
||||
const now = Date.now();
|
||||
const userRequests = this.userActions.get(chatId) || [];
|
||||
|
||||
// Remove old requests outside the window
|
||||
const validRequests = userRequests.filter(timestamp => now - timestamp < this.windowMs);
|
||||
|
||||
if (validRequests.length >= this.maxRequests) {
|
||||
console.log(`⚠️ Rate limit exceeded for user ${chatId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add current request
|
||||
validRequests.push(now);
|
||||
this.userActions.set(chatId, validRequests);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
getRemainingRequests(chatId: number): number {
|
||||
const now = Date.now();
|
||||
const userRequests = this.userActions.get(chatId) || [];
|
||||
const validRequests = userRequests.filter(timestamp => now - timestamp < this.windowMs);
|
||||
|
||||
return Math.max(0, this.maxRequests - validRequests.length);
|
||||
}
|
||||
}
|
||||
160
src/shared/services/api.service.ts
Normal file
160
src/shared/services/api.service.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import axios, { AxiosInstance } from 'axios';
|
||||
import { ApiResponse } from '../types';
|
||||
|
||||
export class ApiService {
|
||||
private client: AxiosInstance;
|
||||
private accessToken: string | null = null;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.client = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor to include auth token
|
||||
this.client.interceptors.request.use((config) => {
|
||||
if (this.accessToken) {
|
||||
config.headers.Authorization = `Bearer ${this.accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
setAccessToken(token: string): void {
|
||||
console.log('🔑 Setting access token for API requests');
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
clearAccessToken(): void {
|
||||
console.log('🔑 Clearing access token');
|
||||
this.accessToken = null;
|
||||
}
|
||||
|
||||
async get<T>(url: string): Promise<ApiResponse<T>> {
|
||||
console.log(`🌐 GET request to: ${this.client.defaults.baseURL}${url}`);
|
||||
try {
|
||||
const response = await this.client.get(url);
|
||||
console.log(`✅ GET ${url} - Status: ${response.status}`);
|
||||
return { success: true, data: response.data };
|
||||
} catch (error: any) {
|
||||
console.log(`❌ GET ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||
|
||||
// Handle specific connection errors
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async post<T>(url: string, data: any): Promise<ApiResponse<T>> {
|
||||
console.log(`🌐 POST request to: ${this.client.defaults.baseURL}${url}`);
|
||||
console.log(`📤 POST data:`, { ...data, password: data.password ? '[HIDDEN]' : undefined });
|
||||
try {
|
||||
const response = await this.client.post(url, data);
|
||||
console.log(`✅ POST ${url} - Status: ${response.status}`);
|
||||
return { success: true, data: response.data };
|
||||
} catch (error: any) {
|
||||
console.log(`❌ POST ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||
|
||||
// Handle specific connection errors
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async patch<T>(url: string, data: any): Promise<ApiResponse<T>> {
|
||||
console.log(`🌐 PATCH request to: ${this.client.defaults.baseURL}${url}`);
|
||||
console.log(`📤 PATCH data:`, { ...data, password: data.password ? '[HIDDEN]' : undefined });
|
||||
try {
|
||||
const response = await this.client.patch(url, data);
|
||||
console.log(`✅ PATCH ${url} - Status: ${response.status}`);
|
||||
return { success: true, data: response.data };
|
||||
} catch (error: any) {
|
||||
console.log(`❌ PATCH ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||
|
||||
// Handle specific connection errors
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async delete<T>(url: string): Promise<ApiResponse<T>> {
|
||||
console.log(`🌐 DELETE request to: ${this.client.defaults.baseURL}${url}`);
|
||||
try {
|
||||
const response = await this.client.delete(url);
|
||||
console.log(`✅ DELETE ${url} - Status: ${response.status}`);
|
||||
return { success: true, data: response.data };
|
||||
} catch (error: any) {
|
||||
console.log(`❌ DELETE ${url} - Error:`, error.response?.status, error.response?.data?.message || error.message);
|
||||
|
||||
// Handle specific connection errors
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Cannot connect to API server. Please ensure the backend is running.'
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'EPROTO' || error.message?.includes('wrong version number')) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'SSL/TLS protocol error. Check if API URL uses correct protocol (http/https).'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/shared/services/session.service.ts
Normal file
33
src/shared/services/session.service.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { BotSession } from '../types';
|
||||
|
||||
export class SessionService {
|
||||
private sessions: Map<number, BotSession> = new Map();
|
||||
|
||||
getSession(chatId: number): BotSession {
|
||||
if (!this.sessions.has(chatId)) {
|
||||
this.sessions.set(chatId, {
|
||||
chatId,
|
||||
step: 'START',
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
return this.sessions.get(chatId)!;
|
||||
}
|
||||
|
||||
updateSession(chatId: number, updates: Partial<BotSession>): void {
|
||||
const session = this.getSession(chatId);
|
||||
Object.assign(session, updates);
|
||||
}
|
||||
|
||||
clearSession(chatId: number): void {
|
||||
this.sessions.delete(chatId);
|
||||
}
|
||||
|
||||
setSessionStep(chatId: number, step: string, data?: Record<string, any>): void {
|
||||
const session = this.getSession(chatId);
|
||||
session.step = step;
|
||||
if (data) {
|
||||
Object.assign(session.data, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/shared/types/index.ts
Normal file
45
src/shared/types/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: 'AGENT' | 'USER';
|
||||
access_token?: string;
|
||||
}
|
||||
|
||||
export interface UserRegistration {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
password: string;
|
||||
role: 'AGENT';
|
||||
}
|
||||
|
||||
export interface Property {
|
||||
id?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'RENT' | 'SELL';
|
||||
price: number;
|
||||
area: number;
|
||||
rooms: number;
|
||||
toilets: number;
|
||||
subcity: string;
|
||||
houseType: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
taxRate: number;
|
||||
isTaxable: boolean;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface BotSession {
|
||||
chatId: number;
|
||||
step: string;
|
||||
data: Record<string, any>;
|
||||
user?: User;
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user