init commit

This commit is contained in:
2025-09-12 21:25:54 +09:00
commit 17efb2fb53
37 changed files with 12637 additions and 0 deletions

257
src/services/chatService.ts Normal file
View File

@@ -0,0 +1,257 @@
import { query } from '../database/connection';
import { Message } from '../models/Message';
import { Match } from '../models/Match';
import { ProfileService } from './profileService';
import { v4 as uuidv4 } from 'uuid';
export class ChatService {
private profileService: ProfileService;
constructor() {
this.profileService = new ProfileService();
}
// Получить все чаты (матчи) пользователя
async getUserChats(telegramId: string): Promise<any[]> {
try {
// Сначала получаем userId по telegramId
const userId = await this.profileService.getUserIdByTelegramId(telegramId);
if (!userId) {
return [];
}
const result = await query(`
SELECT
m.*,
CASE
WHEN m.user1_id = $1 THEN m.user2_id
ELSE m.user1_id
END as other_user_id,
p.name as other_user_name,
p.photos as other_user_photos,
msg.content as last_message_content,
msg.created_at as last_message_time,
msg.sender_id as last_message_sender_id,
(
SELECT COUNT(*)
FROM messages msg2
WHERE msg2.match_id = m.id
AND msg2.sender_id != $1
AND msg2.is_read = false
) as unread_count
FROM matches m
LEFT JOIN profiles p ON (
CASE
WHEN m.user1_id = $1 THEN p.user_id = m.user2_id
ELSE p.user_id = m.user1_id
END
)
LEFT JOIN messages msg ON msg.id = (
SELECT id FROM messages
WHERE match_id = m.id
ORDER BY created_at DESC
LIMIT 1
)
WHERE (m.user1_id = $1 OR m.user2_id = $1)
AND m.status = 'active'
ORDER BY
CASE WHEN msg.created_at IS NULL THEN m.matched_at ELSE msg.created_at END DESC
`, [userId]);
return result.rows.map((row: any) => ({
matchId: row.id,
otherUserId: row.other_user_id,
otherUserName: row.other_user_name,
otherUserPhoto: row.other_user_photos?.[0] || null,
lastMessage: row.last_message_content,
lastMessageTime: row.last_message_time || row.matched_at,
lastMessageFromMe: row.last_message_sender_id === userId,
unreadCount: parseInt(row.unread_count) || 0,
matchedAt: row.matched_at
}));
} catch (error) {
console.error('Error getting user chats:', error);
return [];
}
}
// Получить сообщения в чате
async getChatMessages(matchId: string, limit: number = 50, offset: number = 0): Promise<Message[]> {
try {
const result = await query(`
SELECT * FROM messages
WHERE match_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`, [matchId, limit, offset]);
return result.rows.map((row: any) => new Message({
id: row.id,
matchId: row.match_id,
senderId: row.sender_id,
content: row.content,
messageType: row.message_type,
fileId: row.file_id,
isRead: row.is_read,
createdAt: new Date(row.created_at)
})).reverse(); // Возвращаем в хронологическом порядке
} catch (error) {
console.error('Error getting chat messages:', error);
return [];
}
}
// Отправить сообщение
async sendMessage(
matchId: string,
senderTelegramId: string,
content: string,
messageType: 'text' | 'photo' | 'video' | 'voice' | 'sticker' | 'gif' = 'text',
fileId?: string
): Promise<Message | null> {
try {
// Получаем senderId по telegramId
const senderId = await this.profileService.getUserIdByTelegramId(senderTelegramId);
if (!senderId) {
throw new Error('Sender not found');
}
// Проверяем, что матч активен и пользователь является участником
const matchResult = await query(`
SELECT * FROM matches
WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) AND status = 'active'
`, [matchId, senderId]);
if (matchResult.rows.length === 0) {
throw new Error('Match not found or not accessible');
}
const messageId = uuidv4();
// Создаем сообщение
await query(`
INSERT INTO messages (id, match_id, sender_id, content, message_type, file_id, is_read, created_at)
VALUES ($1, $2, $3, $4, $5, $6, false, CURRENT_TIMESTAMP)
`, [messageId, matchId, senderId, content, messageType, fileId]);
// Обновляем время последнего сообщения в матче
await query(`
UPDATE matches
SET last_message_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [matchId]);
// Получаем созданное сообщение
const messageResult = await query(`
SELECT * FROM messages WHERE id = $1
`, [messageId]);
if (messageResult.rows.length === 0) {
return null;
}
const row = messageResult.rows[0];
return new Message({
id: row.id,
matchId: row.match_id,
senderId: row.sender_id,
content: row.content,
messageType: row.message_type,
fileId: row.file_id,
isRead: row.is_read,
createdAt: new Date(row.created_at)
});
} catch (error) {
console.error('Error sending message:', error);
return null;
}
}
// Отметить сообщения как прочитанные
async markMessagesAsRead(matchId: string, readerTelegramId: string): Promise<void> {
try {
const readerId = await this.profileService.getUserIdByTelegramId(readerTelegramId);
if (!readerId) {
return;
}
await query(`
UPDATE messages
SET is_read = true
WHERE match_id = $1 AND sender_id != $2 AND is_read = false
`, [matchId, readerId]);
} catch (error) {
console.error('Error marking messages as read:', error);
}
}
// Получить информацию о матче
async getMatchInfo(matchId: string, userTelegramId: string): Promise<any | null> {
try {
const userId = await this.profileService.getUserIdByTelegramId(userTelegramId);
if (!userId) {
return null;
}
const result = await query(`
SELECT
m.*,
CASE
WHEN m.user1_id = $2 THEN m.user2_id
ELSE m.user1_id
END as other_user_id
FROM matches m
WHERE m.id = $1 AND (m.user1_id = $2 OR m.user2_id = $2) AND m.status = 'active'
`, [matchId, userId]);
if (result.rows.length === 0) {
return null;
}
const match = result.rows[0];
const otherUserProfile = await this.profileService.getProfileByUserId(match.other_user_id);
return {
matchId: match.id,
otherUserId: match.other_user_id,
otherUserProfile,
matchedAt: match.matched_at
};
} catch (error) {
console.error('Error getting match info:', error);
return null;
}
}
// Удалить матч (размэтчиться)
async unmatch(matchId: string, userTelegramId: string): Promise<boolean> {
try {
const userId = await this.profileService.getUserIdByTelegramId(userTelegramId);
if (!userId) {
return false;
}
// Проверяем, что пользователь является участником матча
const matchResult = await query(`
SELECT * FROM matches
WHERE id = $1 AND (user1_id = $2 OR user2_id = $2) AND status = 'active'
`, [matchId, userId]);
if (matchResult.rows.length === 0) {
return false;
}
// Помечаем матч как неактивный
await query(`
UPDATE matches
SET status = 'unmatched'
WHERE id = $1
`, [matchId]);
return true;
} catch (error) {
console.error('Error unmatching:', error);
return false;
}
}
}

View File

@@ -0,0 +1,384 @@
import { v4 as uuidv4 } from 'uuid';
import { query, transaction } from '../database/connection';
import { Swipe, SwipeData, SwipeType } from '../models/Swipe';
import { Match, MatchData } from '../models/Match';
import { Profile } from '../models/Profile';
import { ProfileService } from './profileService';
import { NotificationService } from './notificationService';
import { BotError } from '../types';
export class MatchingService {
private profileService: ProfileService;
private notificationService: NotificationService;
constructor() {
this.profileService = new ProfileService();
this.notificationService = new NotificationService();
}
// Выполнить свайп
// Конвертация типов свайпов между API и БД
private convertSwipeTypeToDirection(swipeType: SwipeType): string {
switch (swipeType) {
case 'like': return 'right';
case 'pass': return 'left';
case 'superlike': return 'super';
default: return 'left';
}
}
private convertDirectionToSwipeType(direction: string): SwipeType {
switch (direction) {
case 'right': return 'like';
case 'left': return 'pass';
case 'super': return 'superlike';
default: return 'pass';
}
}
async performSwipe(telegramId: string, targetTelegramId: string, swipeType: SwipeType): Promise<{
swipe: Swipe;
isMatch: boolean;
match?: Match;
}> {
// Получить профили пользователей
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
const targetProfile = await this.profileService.getProfileByUserId(targetTelegramId); if (!userProfile || !targetProfile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 400);
}
const userId = userProfile.userId;
const targetUserId = targetProfile.userId;
// Проверяем, что пользователь не свайпает сам себя
if (userId === targetUserId) {
throw new BotError('Cannot swipe yourself', 'INVALID_SWIPE');
}
// Проверяем, что свайп еще не был сделан
const existingSwipe = await this.getSwipe(userId, targetUserId);
if (existingSwipe) {
throw new BotError('Already swiped this profile', 'ALREADY_SWIPED');
}
const swipeId = uuidv4();
const direction = this.convertSwipeTypeToDirection(swipeType);
let isMatch = false;
let match: Match | undefined;
await transaction(async (client) => {
// Создаем свайп
await client.query(`
INSERT INTO swipes (id, swiper_id, swiped_id, direction, created_at)
VALUES ($1, $2, $3, $4, $5)
`, [swipeId, userId, targetUserId, direction, new Date()]);
// Если это лайк или суперлайк, проверяем взаимность
if (swipeType === 'like' || swipeType === 'superlike') {
const reciprocalSwipe = await client.query(`
SELECT * FROM swipes
WHERE swiper_id = $1 AND swiped_id = $2 AND direction IN ('like', 'super')
`, [targetUserId, userId]);
if (reciprocalSwipe.rows.length > 0) {
isMatch = true;
const matchId = uuidv4();
const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].direction === 'super';
// Создаем матч
await client.query(`
INSERT INTO matches (id, user1_id, user2_id, matched_at, status)
VALUES ($1, $2, $3, $4, $5)
`, [matchId, userId, targetUserId, new Date(), 'active']);
match = new Match({
id: matchId,
userId1: userId,
userId2: targetUserId,
createdAt: new Date(),
isActive: true,
isSuperMatch: false,
unreadCount1: 0,
unreadCount2: 0
});
}
}
});
const swipe = new Swipe({
id: swipeId,
userId,
targetUserId,
type: swipeType,
timestamp: new Date(),
isMatch
});
// Отправляем уведомления
if (swipeType === 'like' || swipeType === 'superlike') {
this.notificationService.sendLikeNotification(targetTelegramId, telegramId, swipeType === 'superlike');
}
if (isMatch && match) {
this.notificationService.sendMatchNotification(userId, targetUserId);
this.notificationService.sendMatchNotification(targetUserId, userId);
}
return { swipe, isMatch, match };
}
// Получить свайп между двумя пользователями
async getSwipe(userId: string, targetUserId: string): Promise<Swipe | null> {
const result = await query(`
SELECT * FROM swipes
WHERE swiper_id = $1 AND swiped_id = $2
`, [userId, targetUserId]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToSwipe(result.rows[0]);
}
// Получить все матчи пользователя по telegram ID
async getUserMatches(telegramId: string, limit: number = 50): Promise<Match[]> {
// Сначала получаем userId по telegramId
const userId = await this.profileService.getUserIdByTelegramId(telegramId);
if (!userId) {
return [];
}
const result = await query(`
SELECT * FROM matches
WHERE (user1_id = $1 OR user2_id = $1) AND status = 'active'
ORDER BY matched_at DESC
LIMIT $2
`, [userId, limit]);
return result.rows.map((row: any) => this.mapEntityToMatch(row));
}
// Получить матч по ID
async getMatchById(matchId: string): Promise<Match | null> {
const result = await query(`
SELECT * FROM matches WHERE id = $1
`, [matchId]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToMatch(result.rows[0]);
}
// Получить матч между двумя пользователями
async getMatchBetweenUsers(userId1: string, userId2: string): Promise<Match | null> {
const result = await query(`
SELECT * FROM matches
WHERE ((user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1))
AND is_active = true
`, [userId1, userId2]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToMatch(result.rows[0]);
}
// Размэтчить (деактивировать матч)
async unmatch(userId: string, matchId: string): Promise<boolean> {
const match = await this.getMatchById(matchId);
if (!match || !match.includesUser(userId)) {
throw new BotError('Match not found or access denied', 'MATCH_NOT_FOUND');
}
await query(`
UPDATE matches SET is_active = false WHERE id = $1
`, [matchId]);
return true;
}
// Получить недавние лайки
async getRecentLikes(userId: string, limit: number = 20): Promise<Swipe[]> {
const result = await query(`
SELECT * FROM swipes
WHERE swiped_id = $1 AND direction IN ('like', 'super') AND is_match = false
ORDER BY created_at DESC
LIMIT $2
`, [userId, limit]);
return result.rows.map((row: any) => this.mapEntityToSwipe(row));
}
// Получить статистику свайпов пользователя за день
async getDailySwipeStats(userId: string): Promise<{
likes: number;
superlikes: number;
passes: number;
total: number;
}> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const result = await query(`
SELECT direction, COUNT(*) as count
FROM swipes
WHERE swiper_id = $1 AND created_at >= $2
GROUP BY direction
`, [userId, today]);
const stats = {
likes: 0,
superlikes: 0,
passes: 0,
total: 0
};
result.rows.forEach((row: any) => {
const count = parseInt(row.count);
stats.total += count;
switch (row.direction) {
case 'like':
stats.likes = count;
break;
case 'super':
stats.superlikes = count;
break;
case 'pass':
stats.passes = count;
break;
}
});
return stats;
}
// Проверить лимиты свайпов
async checkSwipeLimits(userId: string): Promise<{
canLike: boolean;
canSuperLike: boolean;
likesLeft: number;
superLikesLeft: number;
}> {
const stats = await this.getDailySwipeStats(userId);
const likesPerDay = 100; // Из конфига
const superLikesPerDay = 1; // Из конфига
return {
canLike: stats.likes < likesPerDay,
canSuperLike: stats.superlikes < superLikesPerDay,
likesLeft: Math.max(0, likesPerDay - stats.likes),
superLikesLeft: Math.max(0, superLikesPerDay - stats.superlikes)
};
}
// Получить рекомендации для пользователя
async getRecommendations(userId: string, limit: number = 10): Promise<string[]> {
return this.profileService.findCompatibleProfiles(userId, limit)
.then(profiles => profiles.map(p => p.userId));
}
// Преобразование entity в модель Swipe
private mapEntityToSwipe(entity: any): Swipe {
return new Swipe({
id: entity.id,
userId: entity.swiper_id,
targetUserId: entity.swiped_id,
type: this.convertDirectionToSwipeType(entity.direction),
timestamp: entity.created_at,
isMatch: entity.is_match
});
}
// Преобразование entity в модель Match
private mapEntityToMatch(entity: any): Match {
return new Match({
id: entity.id,
userId1: entity.user1_id,
userId2: entity.user2_id,
createdAt: entity.matched_at || entity.created_at,
lastMessageAt: entity.last_message_at,
isActive: entity.status === 'active',
isSuperMatch: false, // Определяется из swipes если нужно
unreadCount1: 0,
unreadCount2: 0
});
}
// Получить взаимные лайки (потенциальные матчи)
async getMutualLikes(userId: string): Promise<string[]> {
const result = await query(`
SELECT DISTINCT s1.target_user_id
FROM swipes s1
JOIN swipes s2 ON s1.user_id = s2.target_user_id AND s1.target_user_id = s2.user_id
WHERE s1.user_id = $1
AND s1.type IN ('like', 'superlike')
AND s2.type IN ('like', 'superlike')
AND NOT EXISTS (
SELECT 1 FROM matches m
WHERE (m.user_id_1 = s1.user_id AND m.user_id_2 = s1.target_user_id)
OR (m.user_id_1 = s1.target_user_id AND m.user_id_2 = s1.user_id)
)
`, [userId]);
return result.rows.map((row: any) => row.target_user_id);
}
// Получить следующего кандидата для просмотра
async getNextCandidate(telegramId: string): Promise<Profile | null> {
// Сначала получаем профиль пользователя по telegramId
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
if (!userProfile) {
throw new BotError('User profile not found', 'PROFILE_NOT_FOUND');
}
// Получаем UUID пользователя
const userId = userProfile.userId;
// Получаем список уже просмотренных пользователей
const viewedUsers = await query(`
SELECT DISTINCT swiped_id
FROM swipes
WHERE swiper_id = $1
`, [userId]);
const viewedUserIds = viewedUsers.rows.map((row: any) => row.swiped_id);
viewedUserIds.push(userId); // Исключаем самого себя
// Формируем условие для исключения уже просмотренных
const excludeCondition = viewedUserIds.length > 0
? `AND p.user_id NOT IN (${viewedUserIds.map((_: any, i: number) => `$${i + 2}`).join(', ')})`
: '';
// Ищем подходящих кандидатов
const candidateQuery = `
SELECT p.*, u.telegram_id, u.username, u.first_name, u.last_name
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND p.gender = $1
AND p.age BETWEEN ${userProfile.searchPreferences.minAge} AND ${userProfile.searchPreferences.maxAge}
${excludeCondition}
ORDER BY RANDOM()
LIMIT 1
`;
const params = [userProfile.interestedIn, ...viewedUserIds];
const result = await query(candidateQuery, params);
if (result.rows.length === 0) {
return null;
}
const candidateData = result.rows[0];
// Используем ProfileService для правильного маппинга данных
return this.profileService.mapEntityToProfile(candidateData);
}
}

View File

@@ -0,0 +1,334 @@
import TelegramBot from 'node-telegram-bot-api';
import { query } from '../database/connection';
import { ProfileService } from './profileService';
import config from '../../config/default.json';
export interface NotificationData {
userId: string;
type: 'new_match' | 'new_message' | 'new_like' | 'super_like';
data: Record<string, any>;
scheduledAt?: Date;
}
export class NotificationService {
private bot?: TelegramBot;
private profileService: ProfileService;
constructor(bot?: TelegramBot) {
this.bot = bot;
this.profileService = new ProfileService();
}
// Отправить уведомление о новом лайке
async sendLikeNotification(targetTelegramId: string, likerTelegramId: string, isSuperLike: boolean = false): Promise<void> {
try {
const [targetUser, likerProfile] = await Promise.all([
this.getUserByTelegramId(targetTelegramId),
this.profileService.getProfileByTelegramId(likerTelegramId)
]);
if (!targetUser || !likerProfile || !this.bot) {
return;
}
const message = isSuperLike
? `${likerProfile.name} отправил вам суперлайк!`
: `💖 ${likerProfile.name} поставил вам лайк!`;
await this.bot.sendMessage(targetUser.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '👀 Посмотреть профиль', callback_data: `view_profile:${likerProfile.userId}` },
{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }
]]
}
});
// Логируем уведомление
await this.logNotification({
userId: targetUser.id,
type: isSuperLike ? 'super_like' : 'new_like',
data: { likerUserId: likerProfile.userId, likerName: likerProfile.name }
});
} catch (error) {
console.error('Error sending like notification:', error);
}
}
// Отправить уведомление о новом матче
async sendMatchNotification(userId: string, matchedUserId: string): Promise<void> {
try {
const [user, matchedProfile] = await Promise.all([
this.getUserByUserId(userId),
this.profileService.getProfileByUserId(matchedUserId)
]);
if (!user || !matchedProfile || !this.bot) {
return;
}
const message = `🎉 У вас новый матч с ${matchedProfile.name}!\n\nТеперь вы можете начать общение.`;
await this.bot.sendMessage(user.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '💬 Написать сообщение', callback_data: `start_chat:${matchedUserId}` },
{ text: '👀 Посмотреть профиль', callback_data: `view_profile:${matchedUserId}` }
]]
}
});
// Логируем уведомление
await this.logNotification({
userId,
type: 'new_match',
data: { matchedUserId, matchedName: matchedProfile.name }
});
} catch (error) {
console.error('Error sending match notification:', error);
}
}
// Отправить уведомление о новом сообщении
async sendMessageNotification(receiverId: string, senderId: string, messageContent: string): Promise<void> {
try {
const [receiver, senderProfile] = await Promise.all([
this.getUserByUserId(receiverId),
this.profileService.getProfileByUserId(senderId)
]);
if (!receiver || !senderProfile || !this.bot) {
return;
}
// Проверяем, не в чате ли пользователь сейчас
const isUserActive = await this.isUserActiveInChat(receiverId, senderId);
if (isUserActive) {
return; // Не отправляем уведомление, если пользователь активен в чате
}
const truncatedMessage = messageContent.length > 50
? messageContent.substring(0, 50) + '...'
: messageContent;
const message = `💬 Новое сообщение от ${senderProfile.name}:\n\n${truncatedMessage}`;
await this.bot.sendMessage(receiver.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '💬 Ответить', callback_data: `open_chat:${senderId}` }
]]
}
});
// Логируем уведомление
await this.logNotification({
userId: receiverId,
type: 'new_message',
data: { senderId, senderName: senderProfile.name, messageContent: truncatedMessage }
});
} catch (error) {
console.error('Error sending message notification:', error);
}
}
// Отправить напоминание о неактивности
async sendInactivityReminder(userId: string): Promise<void> {
try {
const user = await this.getUserByUserId(userId);
if (!user || !this.bot) {
return;
}
const message = `👋 Давно не виделись!\n\nВозможно, ваш идеальный матч уже ждет. Давайте найдем кого-то особенного?`;
await this.bot.sendMessage(user.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' },
{ text: '⚙️ Настройки', callback_data: 'settings' }
]]
}
});
} catch (error) {
console.error('Error sending inactivity reminder:', error);
}
}
// Отправить уведомление о новых лайках (сводка)
async sendLikesSummary(userId: string, likesCount: number): Promise<void> {
try {
const user = await this.getUserByUserId(userId);
if (!user || !this.bot || likesCount === 0) {
return;
}
const message = likesCount === 1
? `💖 У вас 1 новый лайк! Посмотрите, кто это может быть.`
: `💖 У вас ${likesCount} новых лайков! Посмотрите, кто проявил к вам интерес.`;
await this.bot.sendMessage(user.telegram_id, message, {
reply_markup: {
inline_keyboard: [[
{ text: '👀 Посмотреть лайки', callback_data: 'view_likes' },
{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }
]]
}
});
} catch (error) {
console.error('Error sending likes summary:', error);
}
}
// Логирование уведомлений
private async logNotification(notificationData: NotificationData): Promise<void> {
try {
await query(`
INSERT INTO notifications (user_id, type, data, created_at)
VALUES ($1, $2, $3, $4)
`, [
notificationData.userId,
notificationData.type,
JSON.stringify(notificationData.data),
new Date()
]);
} catch (error) {
console.error('Error logging notification:', error);
}
}
// Получить пользователя по ID
private async getUserByUserId(userId: string): Promise<any> {
try {
const result = await query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
return result.rows[0] || null;
} catch (error) {
console.error('Error getting user:', error);
return null;
}
}
// Получить пользователя по Telegram ID
private async getUserByTelegramId(telegramId: string): Promise<any> {
try {
const result = await query(
'SELECT * FROM users WHERE telegram_id = $1',
[parseInt(telegramId)]
);
return result.rows[0] || null;
} catch (error) {
console.error('Error getting user by telegram ID:', error);
return null;
}
}
// Проверить, активен ли пользователь в чате
private async isUserActiveInChat(userId: string, chatWithUserId: string): Promise<boolean> {
// TODO: Реализовать проверку активности пользователя
// Можно использовать Redis для хранения состояния активности
return false;
}
// Отправить пуш-уведомление (для будущего использования)
async sendPushNotification(userId: string, title: string, body: string, data?: any): Promise<void> {
// TODO: Интеграция с Firebase Cloud Messaging или другим сервисом пуш-уведомлений
console.log(`Push notification for ${userId}: ${title} - ${body}`);
}
// Получить настройки уведомлений пользователя
async getNotificationSettings(userId: string): Promise<{
newMatches: boolean;
newMessages: boolean;
newLikes: boolean;
reminders: boolean;
}> {
try {
const result = await query(
'SELECT notification_settings FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
return {
newMatches: true,
newMessages: true,
newLikes: true,
reminders: true
};
}
return result.rows[0].notification_settings || {
newMatches: true,
newMessages: true,
newLikes: true,
reminders: true
};
} catch (error) {
console.error('Error getting notification settings:', error);
return {
newMatches: true,
newMessages: true,
newLikes: true,
reminders: true
};
}
}
// Обновить настройки уведомлений
async updateNotificationSettings(userId: string, settings: {
newMatches?: boolean;
newMessages?: boolean;
newLikes?: boolean;
reminders?: boolean;
}): Promise<void> {
try {
await query(
'UPDATE users SET notification_settings = $1 WHERE id = $2',
[JSON.stringify(settings), userId]
);
} catch (error) {
console.error('Error updating notification settings:', error);
}
}
// Планировщик уведомлений (вызывается периодически)
async processScheduledNotifications(): Promise<void> {
try {
// Получаем запланированные уведомления
const result = await query(`
SELECT * FROM scheduled_notifications
WHERE scheduled_at <= $1 AND sent = false
ORDER BY scheduled_at ASC
LIMIT 100
`, [new Date()]);
for (const notification of result.rows) {
try {
switch (notification.type) {
case 'inactivity_reminder':
await this.sendInactivityReminder(notification.user_id);
break;
case 'likes_summary':
const likesCount = notification.data?.likesCount || 0;
await this.sendLikesSummary(notification.user_id, likesCount);
break;
// Добавить другие типы уведомлений
}
// Отмечаем как отправленное
await query(
'UPDATE scheduled_notifications SET sent = true, sent_at = $1 WHERE id = $2',
[new Date(), notification.id]
);
} catch (error) {
console.error(`Error processing notification ${notification.id}:`, error);
}
}
} catch (error) {
console.error('Error processing scheduled notifications:', error);
}
}
}

View File

@@ -0,0 +1,470 @@
import { v4 as uuidv4 } from 'uuid';
import { query, transaction } from '../database/connection';
import { Profile, ProfileData } from '../models/Profile';
import { User } from '../models/User';
import {
ProfileEntity,
UserEntity,
ValidationResult,
BotError
} from '../types';
export class ProfileService {
// Создание нового профиля
async createProfile(userId: string, profileData: Partial<ProfileData>): Promise<Profile> {
const validation = this.validateProfileData(profileData);
if (!validation.isValid) {
throw new BotError(validation.errors.join(', '), 'VALIDATION_ERROR');
}
const profileId = uuidv4();
const now = new Date();
const profile = new Profile({
userId,
name: profileData.name!,
age: profileData.age!,
gender: profileData.gender!,
interestedIn: profileData.interestedIn!,
bio: profileData.bio,
photos: profileData.photos || [],
interests: profileData.interests || [],
city: profileData.city,
education: profileData.education,
job: profileData.job,
height: profileData.height,
location: profileData.location,
searchPreferences: profileData.searchPreferences || {
minAge: 18,
maxAge: 50,
maxDistance: 50
},
isVerified: false,
isVisible: true,
createdAt: now,
updatedAt: now
});
// Сохранение в базу данных
await query(`
INSERT INTO profiles (
id, user_id, name, age, gender, looking_for, bio, photos, interests,
location, education, occupation, height, latitude, longitude,
verification_status, is_active, is_visible, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
`, [
profileId, userId, profile.name, profile.age, profile.gender, profile.interestedIn,
profile.bio, profile.photos, profile.interests,
profile.city, profile.education, profile.job, profile.height,
profile.location?.latitude, profile.location?.longitude,
'unverified', true, profile.isVisible, profile.createdAt, profile.updatedAt
]);
return profile;
}
// Получение профиля по ID пользователя
async getProfileByUserId(userId: string): Promise<Profile | null> {
const result = await query(
'SELECT * FROM profiles WHERE user_id = $1',
[userId]
);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToProfile(result.rows[0]);
}
// Получение профиля по Telegram ID
async getProfileByTelegramId(telegramId: string): Promise<Profile | null> {
const result = await query(`
SELECT p.*, u.telegram_id, u.username, u.first_name, u.last_name
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE u.telegram_id = $1
`, [parseInt(telegramId)]);
if (result.rows.length === 0) {
return null;
}
return this.mapEntityToProfile(result.rows[0]);
} // Получение UUID пользователя по Telegram ID
async getUserIdByTelegramId(telegramId: string): Promise<string | null> {
const result = await query(`
SELECT id FROM users WHERE telegram_id = $1
`, [parseInt(telegramId)]);
if (result.rows.length === 0) {
return null;
}
return result.rows[0].id;
}
// Создание пользователя если не существует
async ensureUser(telegramId: string, userData: any): Promise<string> {
// Используем UPSERT для избежания дублирования
const result = await query(`
INSERT INTO users (telegram_id, username, first_name, last_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (telegram_id) DO UPDATE SET
username = EXCLUDED.username,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`, [
parseInt(telegramId),
userData.username || null,
userData.first_name || null,
userData.last_name || null
]);
return result.rows[0].id;
}
// Обновление профиля
async updateProfile(userId: string, updates: Partial<ProfileData>): Promise<Profile> {
const existingProfile = await this.getProfileByUserId(userId);
if (!existingProfile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
const validation = this.validateProfileData(updates, false);
if (!validation.isValid) {
throw new BotError(validation.errors.join(', '), 'VALIDATION_ERROR');
}
const updateFields: string[] = [];
const updateValues: any[] = [];
let paramIndex = 1;
// Строим динамический запрос обновления
Object.entries(updates).forEach(([key, value]) => {
if (value !== undefined) {
switch (key) {
case 'photos':
case 'interests':
updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`);
updateValues.push(JSON.stringify(value));
break;
case 'location':
if (value && typeof value === 'object' && 'latitude' in value) {
updateFields.push(`latitude = $${paramIndex++}`);
updateValues.push(value.latitude);
updateFields.push(`longitude = $${paramIndex++}`);
updateValues.push(value.longitude);
}
break;
case 'searchPreferences':
// Поля search preferences больше не хранятся в БД, пропускаем
break;
default:
updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`);
updateValues.push(value);
}
}
});
if (updateFields.length === 0) {
return existingProfile;
}
updateFields.push(`updated_at = $${paramIndex++}`);
updateValues.push(new Date());
updateValues.push(userId);
const updateQuery = `
UPDATE profiles
SET ${updateFields.join(', ')}
WHERE user_id = $${paramIndex}
RETURNING *
`;
const result = await query(updateQuery, updateValues);
return this.mapEntityToProfile(result.rows[0]);
}
// Добавление фото к профилю
async addPhoto(userId: string, photoFileId: string): Promise<Profile> {
const profile = await this.getProfileByUserId(userId);
if (!profile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
profile.addPhoto(photoFileId);
await query(
'UPDATE profiles SET photos = $1, updated_at = $2 WHERE user_id = $3',
[JSON.stringify(profile.photos), new Date(), userId]
);
return profile;
}
// Удаление фото из профиля
async removePhoto(userId: string, photoId: string): Promise<Profile> {
const profile = await this.getProfileByUserId(userId);
if (!profile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
profile.removePhoto(photoId);
await query(
'UPDATE profiles SET photos = $1, updated_at = $2 WHERE user_id = $3',
[JSON.stringify(profile.photos), new Date(), userId]
);
return profile;
}
// Поиск совместимых профилей
async findCompatibleProfiles(
userId: string,
limit: number = 10,
excludeUserIds: string[] = []
): Promise<Profile[]> {
const userProfile = await this.getProfileByUserId(userId);
if (!userProfile) {
throw new BotError('User profile not found', 'PROFILE_NOT_FOUND', 404);
}
// Получаем ID пользователей, которых уже свайпали
const swipedUsersResult = await query(
'SELECT target_user_id FROM swipes WHERE user_id = $1',
[userId]
);
const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.target_user_id);
const allExcludedIds = [...excludeUserIds, ...swipedUserIds, userId];
// Базовый запрос для поиска совместимых профилей
let searchQuery = `
SELECT p.*, u.id as user_id
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND u.is_active = true
AND p.user_id != $1
AND p.age BETWEEN $2 AND $3
AND p.gender = $4
AND p.interested_in IN ($5, 'both')
AND $6 BETWEEN p.search_min_age AND p.search_max_age
`;
const queryParams: any[] = [
userId,
userProfile.searchPreferences.minAge,
userProfile.searchPreferences.maxAge,
userProfile.interestedIn === 'both' ? userProfile.gender : userProfile.interestedIn,
userProfile.gender,
userProfile.age
];
// Исключаем уже просмотренных пользователей
if (allExcludedIds.length > 0) {
const placeholders = allExcludedIds.map((_, index) => `$${queryParams.length + index + 1}`).join(',');
searchQuery += ` AND p.user_id NOT IN (${placeholders})`;
queryParams.push(...allExcludedIds);
}
// Добавляем фильтр по расстоянию, если есть координаты
if (userProfile.location) {
searchQuery += `
AND (
p.location_lat IS NULL OR
p.location_lon IS NULL OR
(
6371 * acos(
cos(radians($${queryParams.length + 1})) *
cos(radians(p.location_lat)) *
cos(radians(p.location_lon) - radians($${queryParams.length + 2})) +
sin(radians($${queryParams.length + 1})) *
sin(radians(p.location_lat))
)
) <= $${queryParams.length + 3}
)
`;
queryParams.push(
userProfile.location.latitude,
userProfile.location.longitude,
userProfile.searchPreferences.maxDistance
);
}
searchQuery += ` ORDER BY RANDOM() LIMIT $${queryParams.length + 1}`;
queryParams.push(limit);
const result = await query(searchQuery, queryParams);
return result.rows.map((row: any) => this.mapEntityToProfile(row));
}
// Получение статистики профиля
async getProfileStats(userId: string): Promise<{
totalLikes: number;
totalMatches: number;
profileViews: number;
likesReceived: number;
}> {
const [likesResult, matchesResult, likesReceivedResult] = await Promise.all([
query('SELECT COUNT(*) as count FROM swipes WHERE swiper_id = $1 AND direction IN ($2, $3)',
[userId, 'like', 'super']),
query('SELECT COUNT(*) as count FROM matches WHERE (user1_id = $1 OR user2_id = $1) AND status = $2',
[userId, 'active']),
query('SELECT COUNT(*) as count FROM swipes WHERE swiped_id = $1 AND direction IN ($2, $3)',
[userId, 'like', 'super'])
]);
return {
totalLikes: parseInt(likesResult.rows[0].count),
totalMatches: parseInt(matchesResult.rows[0].count),
profileViews: 0, // TODO: implement profile views tracking
likesReceived: parseInt(likesReceivedResult.rows[0].count)
};
}
// Валидация данных профиля
private validateProfileData(data: Partial<ProfileData>, isRequired = true): ValidationResult {
const errors: string[] = [];
if (isRequired || data.name !== undefined) {
if (!data.name || data.name.trim().length === 0) {
errors.push('Name is required');
} else if (data.name.length > 50) {
errors.push('Name must be less than 50 characters');
}
}
if (isRequired || data.age !== undefined) {
if (!data.age || data.age < 18 || data.age > 100) {
errors.push('Age must be between 18 and 100');
}
}
if (isRequired || data.gender !== undefined) {
if (!data.gender || !['male', 'female', 'other'].includes(data.gender)) {
errors.push('Gender must be male, female, or other');
}
}
if (isRequired || data.interestedIn !== undefined) {
if (!data.interestedIn || !['male', 'female', 'both'].includes(data.interestedIn)) {
errors.push('Interested in must be male, female, or both');
}
}
if (data.bio && data.bio.length > 500) {
errors.push('Bio must be less than 500 characters');
}
if (data.photos && data.photos.length > 6) {
errors.push('Maximum 6 photos allowed');
}
if (data.interests && data.interests.length > 10) {
errors.push('Maximum 10 interests allowed');
}
if (data.height && (data.height < 100 || data.height > 250)) {
errors.push('Height must be between 100 and 250 cm');
}
return {
isValid: errors.length === 0,
errors
};
}
// Преобразование entity в модель Profile
public mapEntityToProfile(entity: any): Profile {
// Функция для парсинга PostgreSQL массивов
const parsePostgresArray = (pgArray: string | null): string[] => {
if (!pgArray) return [];
// PostgreSQL возвращает массивы в формате {item1,item2,item3}
if (typeof pgArray === 'string' && pgArray.startsWith('{') && pgArray.endsWith('}')) {
const content = pgArray.slice(1, -1); // Убираем фигурные скобки
if (content === '') return [];
return content.split(',').map(item => item.trim());
}
// Если это уже массив, возвращаем как есть
if (Array.isArray(pgArray)) return pgArray;
return [];
};
return new Profile({
userId: entity.user_id,
name: entity.name,
age: entity.age,
gender: entity.gender,
interestedIn: entity.looking_for,
bio: entity.bio,
photos: parsePostgresArray(entity.photos),
interests: parsePostgresArray(entity.interests),
city: entity.location || entity.city,
education: entity.education,
job: entity.occupation || entity.job,
height: entity.height,
location: entity.latitude && entity.longitude ? {
latitude: entity.latitude,
longitude: entity.longitude
} : undefined,
searchPreferences: {
minAge: 18,
maxAge: 50,
maxDistance: 50
},
isVerified: entity.verification_status === 'verified',
isVisible: entity.is_visible,
createdAt: entity.created_at,
updatedAt: entity.updated_at
});
}
// Преобразование camelCase в snake_case
private camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
}
// Удаление профиля
async deleteProfile(userId: string): Promise<boolean> {
try {
await transaction(async (client) => {
// Удаляем связанные данные
await client.query('DELETE FROM messages WHERE sender_id = $1 OR receiver_id = $1', [userId]);
await client.query('DELETE FROM matches WHERE user1_id = $1 OR user2_id = $1', [userId]);
await client.query('DELETE FROM swipes WHERE swiper_id = $1 OR swiped_id = $1', [userId]);
await client.query('DELETE FROM profiles WHERE user_id = $1', [userId]);
});
return true;
} catch (error) {
console.error('Error deleting profile:', error);
return false;
}
}
// Скрыть/показать профиль
async toggleVisibility(userId: string): Promise<Profile> {
const profile = await this.getProfileByUserId(userId);
if (!profile) {
throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404);
}
const newVisibility = !profile.isVisible;
await query(
'UPDATE profiles SET is_visible = $1, updated_at = $2 WHERE user_id = $3',
[newVisibility, new Date(), userId]
);
profile.isVisible = newVisibility;
return profile;
}
}