init commit
This commit is contained in:
257
src/services/chatService.ts
Normal file
257
src/services/chatService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
384
src/services/matchingService.ts
Normal file
384
src/services/matchingService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
334
src/services/notificationService.ts
Normal file
334
src/services/notificationService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
470
src/services/profileService.ts
Normal file
470
src/services/profileService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user