Запрос на слияние 'feature/sber_mobile/ai_initiatives' (#32) из feature/sber_mobile/ai_initiatives в sber_mobile
This commit is contained in:
@@ -21,6 +21,7 @@ import escRouter from './routers/esc'
|
||||
import connectmeRouter from './routers/connectme'
|
||||
import questioneerRouter from './routers/questioneer'
|
||||
import { setIo } from './io'
|
||||
const { createChatPollingRouter } = require('./routers/kfu-m-24-1/sber_mobile/polling-chat')
|
||||
|
||||
export const app = express()
|
||||
|
||||
@@ -64,8 +65,6 @@ const initServer = async () => {
|
||||
|
||||
console.log('warming up 🔥')
|
||||
|
||||
const server = setIo(app)
|
||||
|
||||
const sess = {
|
||||
secret: "super-secret-key",
|
||||
resave: true,
|
||||
@@ -90,10 +89,18 @@ const initServer = async () => {
|
||||
)
|
||||
app.use(root)
|
||||
|
||||
// Инициализация Polling для чата (после настройки middleware)
|
||||
const { router: chatPollingRouter, chatHandler } = createChatPollingRouter(express)
|
||||
|
||||
|
||||
/**
|
||||
* Добавляйте сюда свои routers.
|
||||
*/
|
||||
app.use("/kfu-m-24-1", kfuM241Router)
|
||||
|
||||
// Добавляем Polling роутер для чата
|
||||
app.use("/kfu-m-24-1/sber_mobile", chatPollingRouter)
|
||||
|
||||
app.use("/epja-2024-1", epja20241Router)
|
||||
app.use("/v1/todo", todoRouter)
|
||||
app.use("/dogsitters-finder", dogsittersFinderRouter)
|
||||
@@ -109,9 +116,10 @@ const initServer = async () => {
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
server.listen(process.env.PORT ?? 8044, () =>
|
||||
// Создаем обычный HTTP сервер
|
||||
const server = app.listen(process.env.PORT ?? 8044, () => {
|
||||
console.log(`🚀 Сервер запущен на http://localhost:${process.env.PORT ?? 8044}`)
|
||||
)
|
||||
})
|
||||
|
||||
// Обработка сигналов завершения процесса
|
||||
process.on('SIGTERM', () => {
|
||||
@@ -145,6 +153,8 @@ const initServer = async () => {
|
||||
process.exit(1)
|
||||
})
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
initServer().catch(console.error)
|
||||
|
||||
@@ -3,31 +3,22 @@ const { getSupabaseClient } = require('./supabaseClient');
|
||||
|
||||
// Получить все чаты по дому
|
||||
router.get('/chats', async (req, res) => {
|
||||
console.log('🏠 [Server] GET /chats запрос получен');
|
||||
console.log('🏠 [Server] Query параметры:', req.query);
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
const { building_id } = req.query;
|
||||
|
||||
if (!building_id) {
|
||||
console.log('❌ [Server] Ошибка: building_id обязателен');
|
||||
return res.status(400).json({ error: 'building_id required' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔍 [Server] Выполняем запрос к Supabase для здания:', building_id);
|
||||
|
||||
const { data, error } = await supabase.from('chats').select('*').eq('building_id', building_id);
|
||||
|
||||
if (error) {
|
||||
console.log('❌ [Server] Ошибка Supabase:', error);
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
console.log('✅ [Server] Чаты получены:', data?.length || 0, 'шт.');
|
||||
res.json(data || []);
|
||||
} catch (err) {
|
||||
console.log('❌ [Server] Неожиданная ошибка:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
@@ -184,15 +175,10 @@ router.get('/chats/:chat_id/stats', async (req, res) => {
|
||||
|
||||
// Получить последнее сообщение в чате
|
||||
router.get('/chats/:chat_id/last-message', async (req, res) => {
|
||||
console.log('💬 [Server] GET /chats/:chat_id/last-message запрос получен');
|
||||
console.log('💬 [Server] Chat ID:', req.params.chat_id);
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
const { chat_id } = req.params;
|
||||
|
||||
try {
|
||||
console.log('🔍 [Server] Выполняем запрос последнего сообщения для чата:', chat_id);
|
||||
|
||||
// Получаем последнее сообщение
|
||||
const { data: lastMessage, error } = await supabase
|
||||
.from('messages')
|
||||
@@ -205,10 +191,8 @@ router.get('/chats/:chat_id/last-message', async (req, res) => {
|
||||
let data = null;
|
||||
|
||||
if (error && error.code === 'PGRST116') {
|
||||
console.log('ℹ️ [Server] Сообщений в чате нет (PGRST116)');
|
||||
data = null;
|
||||
} else if (error) {
|
||||
console.log('❌ [Server] Ошибка Supabase при получении последнего сообщения:', error);
|
||||
return res.status(400).json({ error: error.message });
|
||||
} else if (lastMessage) {
|
||||
// Получаем профиль пользователя для сообщения
|
||||
@@ -223,12 +207,10 @@ router.get('/chats/:chat_id/last-message', async (req, res) => {
|
||||
...lastMessage,
|
||||
user_profiles: userProfile || null
|
||||
};
|
||||
console.log('✅ [Server] Последнее сообщение получено для чата:', chat_id);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(data);
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.log('❌ [Server] Неожиданная ошибка при получении последнего сообщения:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ const buildingsRouter = require('./buildings');
|
||||
const userApartmentsRouter = require('./user_apartments');
|
||||
const avatarRouter = require('./media');
|
||||
const supportRouter = require('./supportApi');
|
||||
const moderateRouter = require('./moderate.js');
|
||||
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -33,4 +35,6 @@ router.use('', apartmentsRouter);
|
||||
router.use('', buildingsRouter);
|
||||
router.use('', userApartmentsRouter);
|
||||
router.use('', avatarRouter);
|
||||
router.use('', supportRouter);
|
||||
router.use('', supportRouter);
|
||||
router.use('', moderateRouter);
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { GigaChat as GigaChatLang} from 'langchain-gigachat';
|
||||
import { GigaChat } from 'gigachat';
|
||||
import { Agent } from 'node:https';
|
||||
|
||||
const httpsAgent = new Agent({
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
|
||||
export const llm_mod = (GIGA_AUTH) =>
|
||||
new GigaChatLang({
|
||||
credentials: GIGA_AUTH,
|
||||
temperature: 0.2,
|
||||
model: 'GigaChat-2-Max',
|
||||
httpsAgent,
|
||||
});
|
||||
|
||||
export const llm_gen = (GIGA_AUTH) =>
|
||||
new GigaChat({
|
||||
credentials: GIGA_AUTH,
|
||||
model: 'GigaChat-2',
|
||||
httpsAgent,
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { llm_mod } from './llm'
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
// возвращаю комментарий + исправленное предложение + булево значение
|
||||
|
||||
export const moderationText = async (title: string, description: string, GIGA_AUTH): Promise<[string, string | undefined, boolean]> => {
|
||||
|
||||
const moderationLlm = llm_mod(GIGA_AUTH).withStructuredOutput(z.object({
|
||||
comment: z.string(),
|
||||
fixedText: z.string().optional(),
|
||||
isApproved: z.boolean(),
|
||||
}) as any)
|
||||
|
||||
const prompt = `
|
||||
Представь, что ты модерируешь предложения от жильцов многоквартирного дома (это личная инициатива по улучшения,
|
||||
не имеющая отношения к Управляющей компании).
|
||||
|
||||
Заголовок: ${title}
|
||||
Основной текст: ${description}
|
||||
|
||||
Твои задачи:
|
||||
1. Проверь предложение и заголовок на спам.
|
||||
2. Проверь, чтобы заголовок и текст были на одну тему.
|
||||
3. Проверь само предложение пользователя на отсутствие грубой лексики и пошлостей.
|
||||
4. Проверь грамматику.
|
||||
5. Проверь на бессмысленность предложения. Оно не должно содержать только случайные символы.
|
||||
6. Не должно быть рекламы, ссылок и т.д.
|
||||
7. Проверь предложение на информативность, предложение не может быть коротким, оно должно ясно отражжать суть инициативы.
|
||||
8. Предложение должно быть в вежливой форме.
|
||||
|
||||
- Если все правила соблюдены, то предложение принимается!
|
||||
|
||||
- Если предложение отклонено, всегда пиши комментарий и fixedText!
|
||||
|
||||
Правила написания комментария:
|
||||
- Если предложение отклоняется, пиши комментарий со следующей формулировкой:
|
||||
"Предложение отклонено. Причина: (укажи проблему)"
|
||||
|
||||
Правила написания fixedText:
|
||||
- Если предложение отклонено, то верни в поле "fixedText" измененный текст, который будет соответствовать правилам.
|
||||
- Если предложение отклонено и содержит запрещённый контент (рекламу, личные данные), удали всю информацию,
|
||||
которая противоречит правилам, и верни в только подходящий фрагмент, сохраняя общий смысл.
|
||||
- Если текст не представляет никакой ценности, возврати в поле "fixedText" правило,
|
||||
по которому оно не прошло.
|
||||
-Если предложение принимается, то ничего не возвращай в поле fixedText.
|
||||
`
|
||||
|
||||
const result = await moderationLlm.invoke(prompt);
|
||||
console.log(result)
|
||||
// Дополнительная проверка
|
||||
if(!result.isApproved && result.comment.trim() === '' && (!result.fixedText || result.fixedText.trim() === '')) {
|
||||
result.comment = 'Предложение отклонено. Причина: несоблюдение требований к оформлению или содержанию.',
|
||||
result.fixedText = description
|
||||
}
|
||||
|
||||
return [result.comment, result.fixedText, result.isApproved];
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { llm_gen } from './llm'
|
||||
import { detectImage } from 'gigachat';
|
||||
|
||||
export const generatePicture = async (prompt: string, GIGA_AUTH) => {
|
||||
const resp = await llm_gen(GIGA_AUTH).chat({
|
||||
messages: [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Ты — Василий Кандинский для жильцов многоквартирного дома"
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: `Старайся передать атмосферу уюта и безопасности.
|
||||
Нарисуй картинку подходящую для такого события: ${prompt}
|
||||
В картинке не должно быть текста, только изображение.`,
|
||||
},
|
||||
],
|
||||
function_call: 'auto',
|
||||
});
|
||||
|
||||
// Получение изображения по идентификатору
|
||||
const detectedImage = detectImage(resp.choices[0]?.message.content ?? '');
|
||||
|
||||
if (!detectedImage?.uuid) {
|
||||
throw new Error('Не удалось получить UUID изображения из ответа GigaChat');
|
||||
}
|
||||
|
||||
const image = await llm_gen(GIGA_AUTH).getImage(detectedImage.uuid);
|
||||
|
||||
// Возвращаем содержимое изображения, убеждаясь что это Buffer
|
||||
if (Buffer.isBuffer(image.content)) {
|
||||
return image.content;
|
||||
} else if (typeof image.content === 'string') {
|
||||
return Buffer.from(image.content, 'binary');
|
||||
} else {
|
||||
throw new Error('Unexpected image content type: ' + typeof image.content);
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,9 @@ router.get('/initiatives/:id', async (req, res) => {
|
||||
// Создать инициативу
|
||||
router.post('/initiatives', async (req, res) => {
|
||||
const supabase = getSupabaseClient();
|
||||
const { building_id, creator_id, title, description, status, target_amount, image_url } = req.body;
|
||||
const { building_id, creator_id, title, description, status, target_amount, current_amount, image_url } = req.body;
|
||||
const { data, error } = await supabase.from('initiatives').insert([
|
||||
{ building_id, creator_id, title, description, status, target_amount, image_url }
|
||||
{ building_id, creator_id, title, description, status, target_amount, current_amount: current_amount || 0, image_url }
|
||||
]).select().single();
|
||||
if (error) return res.status(400).json({ error: error.message });
|
||||
res.json(data);
|
||||
|
||||
@@ -1,64 +1,59 @@
|
||||
const router = require('express').Router();
|
||||
const { getSupabaseClient } = require('./supabaseClient');
|
||||
const { getIo } = require('../../../io'); // Импортируем Socket.IO
|
||||
|
||||
// Получить все сообщения в чате с информацией о пользователе
|
||||
router.get('/messages', async (req, res) => {
|
||||
console.log('📬 [Server] GET /messages запрос получен');
|
||||
console.log('📬 [Server] Query параметры:', req.query);
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
const { chat_id, limit = 50, offset = 0 } = req.query;
|
||||
|
||||
if (!chat_id) {
|
||||
console.log('❌ [Server] Ошибка: chat_id обязателен');
|
||||
return res.status(400).json({ error: 'chat_id required' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔍 [Server] Выполняем запрос к Supabase для чата:', chat_id);
|
||||
|
||||
// Получаем сообщения
|
||||
const { data: messages, error } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('chat_id', chat_id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) {
|
||||
console.log('❌ [Server] Ошибка получения сообщений:', error);
|
||||
return res.status(400).json({ error: error.message });
|
||||
const { chat_id, limit = 50, offset = 0 } = req.query;
|
||||
|
||||
if (!chat_id) {
|
||||
return res.status(400).json({ error: 'chat_id is required' });
|
||||
}
|
||||
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
// Получаем профили пользователей для всех уникальных user_id
|
||||
let data = messages || [];
|
||||
if (data.length > 0) {
|
||||
const userIds = [...new Set(data.map(msg => msg.user_id))];
|
||||
console.log('👥 [Server] Получаем профили для пользователей:', userIds);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select(`
|
||||
*,
|
||||
user_profiles (
|
||||
id,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('chat_id', chat_id)
|
||||
.order('created_at', { ascending: true })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) {
|
||||
return res.status(500).json({ error: 'Failed to fetch messages' });
|
||||
}
|
||||
|
||||
// Получаем уникальные ID пользователей из сообщений, у которых нет профиля
|
||||
const messagesWithoutProfiles = data.filter(msg => !msg.user_profiles);
|
||||
const userIds = [...new Set(messagesWithoutProfiles.map(msg => msg.user_id))];
|
||||
|
||||
if (userIds.length > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('id, full_name, avatar_url')
|
||||
.in('id', userIds);
|
||||
|
||||
|
||||
if (!profilesError && profiles) {
|
||||
// Объединяем сообщения с профилями
|
||||
data = data.map(msg => ({
|
||||
...msg,
|
||||
user_profiles: profiles.find(profile => profile.id === msg.user_id) || null
|
||||
}));
|
||||
console.log('✅ [Server] Профили пользователей добавлены к сообщениям');
|
||||
} else {
|
||||
console.log('⚠️ [Server] Ошибка получения профилей пользователей:', profilesError);
|
||||
// Добавляем профили к сообщениям
|
||||
data.forEach(message => {
|
||||
if (!message.user_profiles) {
|
||||
message.user_profiles = profiles.find(profile => profile.id === message.user_id) || null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ [Server] Сообщения получены:', data?.length || 0, 'шт.');
|
||||
res.json(data?.reverse() || []); // Возвращаем в хронологическом порядке
|
||||
}
|
||||
|
||||
res.json(data);
|
||||
} catch (err) {
|
||||
console.log('❌ [Server] Неожиданная ошибка:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
res.status(500).json({ error: 'Unexpected error occurred' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -94,6 +89,9 @@ router.post('/messages', async (req, res) => {
|
||||
...newMessage,
|
||||
user_profiles: userProfile || null
|
||||
};
|
||||
|
||||
// Отправка через Socket.IO теперь происходит автоматически через Supabase Real-time подписку
|
||||
// Это предотвращает дублирование сообщений
|
||||
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
164
server/routers/kfu-m-24-1/sber_mobile/moderate.js
Normal file
164
server/routers/kfu-m-24-1/sber_mobile/moderate.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const router = require('express').Router();
|
||||
const { moderationText } = require('./initiatives-ai-agents/moderation.ts');
|
||||
const { generatePicture } = require('./initiatives-ai-agents/picture.ts');
|
||||
const { getSupabaseClient } = require('./supabaseClient');
|
||||
const { getGigaAuth } = require('./get-constants');
|
||||
|
||||
async function getGigaKey() {
|
||||
const GIGA_AUTH = await getGigaAuth();
|
||||
return GIGA_AUTH;
|
||||
}
|
||||
|
||||
// Обработчик для модерации и создания инициативы
|
||||
router.post('/moderate', async (req, res) => {
|
||||
|
||||
const GIGA_AUTH = await getGigaKey();
|
||||
|
||||
try {
|
||||
const { title, description, building_id, creator_id, target_amount, status } = req.body;
|
||||
|
||||
if (!title || !description) {
|
||||
res.status(400).json({ error: 'Заголовок и описание обязательны' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!building_id || !creator_id) {
|
||||
res.status(400).json({ error: 'ID дома и создателя обязательны' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация статуса, если передан
|
||||
const validStatuses = ['moderation', 'review', 'fundraising', 'approved', 'rejected'];
|
||||
if (status && !validStatuses.includes(status)) {
|
||||
res.status(400).json({ error: `Недопустимый статус. Допустимые значения: ${validStatuses.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Запрос на модерацию:', { title: title.substring(0, 50), description: description.substring(0, 100) });
|
||||
|
||||
// Модерация текста (передаем title и description как body)
|
||||
const [comment, fixedText, isApproved] = await moderationText(title, description, GIGA_AUTH);
|
||||
|
||||
console.log('Результат модерации получен:', { comment, fixedText: fixedText?.substring(0, 100), isApproved });
|
||||
|
||||
// Если модерация не прошла, возвращаем undefined
|
||||
if (!isApproved) {
|
||||
if (!comment || comment.trim() === '') {
|
||||
console.warn('Обнаружен некорректный результат модерации - пустой комментарий при отклонении');
|
||||
}
|
||||
|
||||
res.json({
|
||||
comment,
|
||||
fixedText,
|
||||
isApproved,
|
||||
initiative: undefined
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Модерация прошла, генерируем изображение используя заголовок как промпт
|
||||
console.log('Модерация прошла, генерируем изображение с промптом:', title);
|
||||
|
||||
const imageBuffer = await generatePicture(title, GIGA_AUTH);
|
||||
|
||||
if (!imageBuffer || imageBuffer.length === 0) {
|
||||
res.status(500).json({ error: 'Получен пустой буфер изображения' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем Supabase клиент и создаем имя файла
|
||||
const supabase = getSupabaseClient();
|
||||
const timestamp = Date.now();
|
||||
const filename = `image_${creator_id}_${timestamp}.jpg`;
|
||||
|
||||
// Загружаем изображение в Supabase Storage
|
||||
let uploadResult;
|
||||
let retries = 0;
|
||||
const maxRetries = 5;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
try {
|
||||
uploadResult = await supabase.storage
|
||||
.from('images')
|
||||
.upload(filename, imageBuffer, {
|
||||
contentType: 'image/jpeg',
|
||||
upsert: true
|
||||
});
|
||||
|
||||
if (!uploadResult.error) {
|
||||
break; // Успешная загрузка
|
||||
}
|
||||
|
||||
retries++;
|
||||
|
||||
if (retries < maxRetries) {
|
||||
// Ждем перед повторной попыткой
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retries));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Попытка загрузки ${retries + 1} неудачна (исключение):`, error.message);
|
||||
retries++;
|
||||
|
||||
if (retries < maxRetries) {
|
||||
// Ждем перед повторной попыткой
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retries));
|
||||
} else {
|
||||
throw error; // Перебрасываем ошибку после всех попыток
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadResult?.error) {
|
||||
console.error('Supabase storage error after all retries:', uploadResult.error);
|
||||
res.status(500).json({ error: 'Ошибка при сохранении изображения после нескольких попыток' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Изображение успешно загружено в Supabase Storage:', filename);
|
||||
|
||||
// Получаем публичный URL
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('images')
|
||||
.getPublicUrl(filename);
|
||||
|
||||
// Определяем статус: если передан в запросе, используем его, иначе 'review'
|
||||
const finalStatus = status || 'review';
|
||||
|
||||
// Создаем инициативу в базе данных
|
||||
const { data: initiative, error: initiativeError } = await supabase
|
||||
.from('initiatives')
|
||||
.insert([{
|
||||
building_id,
|
||||
creator_id,
|
||||
title: fixedText || title,
|
||||
description,
|
||||
status: finalStatus,
|
||||
target_amount: target_amount || null,
|
||||
current_amount: 0,
|
||||
image_url: urlData.publicUrl
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (initiativeError) {
|
||||
console.error('Ошибка создания инициативы:', initiativeError);
|
||||
res.status(500).json({ error: 'Ошибка при создании инициативы', details: initiativeError.message });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Инициатива успешно создана:', initiative.id);
|
||||
|
||||
res.json({
|
||||
comment,
|
||||
fixedText,
|
||||
isApproved,
|
||||
initiative
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in moderation and initiative creation:', error);
|
||||
res.status(500).json({ error: 'Внутренняя ошибка сервера', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
822
server/routers/kfu-m-24-1/sber_mobile/polling-chat.js
Normal file
822
server/routers/kfu-m-24-1/sber_mobile/polling-chat.js
Normal file
@@ -0,0 +1,822 @@
|
||||
const { getSupabaseClient, initializationPromise } = require('./supabaseClient');
|
||||
|
||||
class ChatPollingHandler {
|
||||
constructor() {
|
||||
this.connectedClients = new Map(); // user_id -> { user_info, chats: Set(), lastActivity: Date }
|
||||
this.chatParticipants = new Map(); // chat_id -> Set(user_id)
|
||||
this.userEventQueues = new Map(); // user_id -> [{id, event, data, timestamp}]
|
||||
this.eventIdCounter = 0;
|
||||
this.realtimeSubscription = null;
|
||||
|
||||
// Инициализируем Supabase подписку с задержкой и проверками
|
||||
this.initializeWithRetry();
|
||||
|
||||
// Очистка старых событий каждые 5 минут
|
||||
setInterval(() => {
|
||||
this.cleanupOldEvents();
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Инициализация с повторными попытками
|
||||
async initializeWithRetry() {
|
||||
try {
|
||||
// Сначала ждем завершения основной инициализации
|
||||
await initializationPromise;
|
||||
|
||||
this.setupRealtimeSubscription();
|
||||
this.testRealtimeConnection();
|
||||
return;
|
||||
|
||||
} catch (error) {
|
||||
console.log('❌ [Supabase] Основная инициализация неудачна, пробуем альтернативный подход');
|
||||
}
|
||||
|
||||
// Если основная инициализация не удалась, используем повторные попытки
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
const baseDelay = 2000; // 2 секунды
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
attempts++;
|
||||
|
||||
// Ждем перед попыткой
|
||||
await new Promise(resolve => setTimeout(resolve, baseDelay * attempts));
|
||||
|
||||
// Проверяем готовность Supabase клиента
|
||||
const supabase = getSupabaseClient();
|
||||
if (supabase) {
|
||||
this.setupRealtimeSubscription();
|
||||
this.testRealtimeConnection();
|
||||
return; // Успех, выходим
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ [Supabase] Попытка #${attempts} неудачна:`, error.message);
|
||||
|
||||
if (attempts === maxAttempts) {
|
||||
console.error('❌ [Supabase] Все попытки инициализации исчерпаны');
|
||||
console.error('❌ [Supabase] Realtime подписка будет недоступна');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Аутентификация пользователя
|
||||
async handleAuthentication(req, res) {
|
||||
const { user_id, token } = req.body;
|
||||
|
||||
if (!user_id) {
|
||||
res.status(400).json({ error: 'user_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем пользователя в базе данных
|
||||
const supabase = getSupabaseClient();
|
||||
const { data: userProfile, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('*')
|
||||
.eq('id', user_id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.log('❌ [Polling Server] Пользователь не найден:', error);
|
||||
res.status(401).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Регистрируем пользователя
|
||||
this.connectedClients.set(user_id, {
|
||||
user_info: {
|
||||
user_id,
|
||||
profile: userProfile,
|
||||
last_seen: new Date()
|
||||
},
|
||||
chats: new Set(),
|
||||
lastActivity: new Date()
|
||||
});
|
||||
|
||||
// Создаем очередь событий для пользователя
|
||||
if (!this.userEventQueues.has(user_id)) {
|
||||
this.userEventQueues.set(user_id, []);
|
||||
}
|
||||
|
||||
// Добавляем событие аутентификации в очередь
|
||||
this.addEventToQueue(user_id, 'authenticated', {
|
||||
message: 'Successfully authenticated',
|
||||
user: userProfile
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Successfully authenticated',
|
||||
user: userProfile
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Polling Server] Ошибка аутентификации:', error);
|
||||
res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
// Эндпоинт для получения событий (polling)
|
||||
async handleGetEvents(req, res) {
|
||||
try {
|
||||
const { user_id, last_event_id } = req.query;
|
||||
|
||||
if (!user_id) {
|
||||
res.status(400).json({ error: 'user_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (!client) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем время последней активности
|
||||
client.lastActivity = new Date();
|
||||
|
||||
// Получаем очередь событий пользователя
|
||||
const eventQueue = this.userEventQueues.get(user_id) || [];
|
||||
|
||||
// Фильтруем события после last_event_id
|
||||
const lastEventId = parseInt(last_event_id) || 0;
|
||||
const newEvents = eventQueue.filter(event => event.id > lastEventId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
events: newEvents,
|
||||
last_event_id: eventQueue.length > 0 ? Math.max(...eventQueue.map(e => e.id)) : lastEventId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Polling Server] Ошибка получения событий:', error);
|
||||
res.status(500).json({ error: 'Failed to get events' });
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP эндпоинт для присоединения к чату
|
||||
async handleJoinChat(req, res) {
|
||||
try {
|
||||
const { user_id, chat_id } = req.body;
|
||||
|
||||
if (!user_id || !chat_id) {
|
||||
res.status(400).json({ error: 'user_id and chat_id are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (!client) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что чат существует и пользователь имеет доступ к нему
|
||||
const supabase = getSupabaseClient();
|
||||
const { data: chat, error } = await supabase
|
||||
.from('chats')
|
||||
.select(`
|
||||
*,
|
||||
buildings (
|
||||
management_company_id,
|
||||
apartments (
|
||||
apartment_residents (
|
||||
user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('id', chat_id)
|
||||
.single();
|
||||
|
||||
if (error || !chat) {
|
||||
res.status(404).json({ error: 'Chat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем доступ пользователя к чату через квартиры в доме
|
||||
const hasAccess = chat.buildings.apartments.some(apartment =>
|
||||
apartment.apartment_residents.some(resident =>
|
||||
resident.user_id === user_id
|
||||
)
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
res.status(403).json({ error: 'Access denied to this chat' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем пользователя в чат
|
||||
client.chats.add(chat_id);
|
||||
|
||||
if (!this.chatParticipants.has(chat_id)) {
|
||||
this.chatParticipants.set(chat_id, new Set());
|
||||
}
|
||||
this.chatParticipants.get(chat_id).add(user_id);
|
||||
|
||||
// Добавляем событие присоединения в очередь пользователя
|
||||
this.addEventToQueue(user_id, 'joined_chat', {
|
||||
chat_id,
|
||||
chat: chat,
|
||||
message: 'Successfully joined chat'
|
||||
});
|
||||
|
||||
// Уведомляем других участников о подключении
|
||||
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_joined', {
|
||||
chat_id,
|
||||
user: client.user_info.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Joined chat successfully' });
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to join chat' });
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP эндпоинт для покидания чата
|
||||
async handleLeaveChat(req, res) {
|
||||
try {
|
||||
const { user_id, chat_id } = req.body;
|
||||
|
||||
if (!user_id || !chat_id) {
|
||||
res.status(400).json({ error: 'user_id and chat_id are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (!client) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем пользователя из чата
|
||||
client.chats.delete(chat_id);
|
||||
|
||||
if (this.chatParticipants.has(chat_id)) {
|
||||
this.chatParticipants.get(chat_id).delete(user_id);
|
||||
|
||||
// Если чат пуст, удаляем его
|
||||
if (this.chatParticipants.get(chat_id).size === 0) {
|
||||
this.chatParticipants.delete(chat_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Уведомляем других участников об отключении
|
||||
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_left', {
|
||||
chat_id,
|
||||
user: client.user_info.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Left chat successfully' });
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to leave chat' });
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP эндпоинт для отправки сообщения
|
||||
async handleSendMessage(req, res) {
|
||||
try {
|
||||
const { user_id, chat_id, text } = req.body;
|
||||
|
||||
if (!user_id || !chat_id || !text) {
|
||||
res.status(400).json({ error: 'user_id, chat_id and text are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (!client) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.chats.has(chat_id)) {
|
||||
res.status(403).json({ error: 'Not joined to this chat' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем сообщение в базу данных
|
||||
const supabase = getSupabaseClient();
|
||||
const { data: message, error } = await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
chat_id,
|
||||
user_id,
|
||||
text
|
||||
})
|
||||
.select(`
|
||||
*,
|
||||
user_profiles (
|
||||
id,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
res.status(500).json({ error: 'Failed to save message' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем сообщение всем участникам чата
|
||||
this.broadcastToChat(chat_id, 'new_message', {
|
||||
message,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Message sent successfully' });
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to send message' });
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP эндпоинт для индикации печатания
|
||||
async handleTypingStart(req, res) {
|
||||
try {
|
||||
const { user_id, chat_id } = req.body;
|
||||
|
||||
if (!user_id || !chat_id) {
|
||||
res.status(400).json({ error: 'user_id and chat_id are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (!client) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.chats.has(chat_id)) {
|
||||
res.status(403).json({ error: 'Not joined to this chat' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_typing_start', {
|
||||
chat_id,
|
||||
user: client.user_info.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to send typing indicator' });
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP эндпоинт для остановки индикации печатания
|
||||
async handleTypingStop(req, res) {
|
||||
try {
|
||||
const { user_id, chat_id } = req.body;
|
||||
|
||||
if (!user_id || !chat_id) {
|
||||
res.status(400).json({ error: 'user_id and chat_id are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (!client) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.chats.has(chat_id)) {
|
||||
res.status(403).json({ error: 'Not joined to this chat' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_typing_stop', {
|
||||
chat_id,
|
||||
user: client.user_info.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to send typing indicator' });
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка отключения клиента
|
||||
handleClientDisconnect(user_id) {
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (!client) return;
|
||||
|
||||
// Удаляем пользователя из всех чатов
|
||||
client.chats.forEach(chat_id => {
|
||||
if (this.chatParticipants.has(chat_id)) {
|
||||
this.chatParticipants.get(chat_id).delete(user_id);
|
||||
|
||||
// Уведомляем других участников об отключении
|
||||
this.broadcastToChatExcludeUser(chat_id, user_id, 'user_left', {
|
||||
chat_id,
|
||||
user: client.user_info.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
// Если чат пуст, удаляем его
|
||||
if (this.chatParticipants.get(chat_id).size === 0) {
|
||||
this.chatParticipants.delete(chat_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Удаляем клиента
|
||||
this.connectedClients.delete(user_id);
|
||||
}
|
||||
|
||||
// Добавление события в очередь пользователя
|
||||
addEventToQueue(user_id, event, data) {
|
||||
if (!this.userEventQueues.has(user_id)) {
|
||||
this.userEventQueues.set(user_id, []);
|
||||
}
|
||||
|
||||
const eventQueue = this.userEventQueues.get(user_id);
|
||||
const eventId = ++this.eventIdCounter;
|
||||
|
||||
eventQueue.push({
|
||||
id: eventId,
|
||||
event,
|
||||
data,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
// Ограничиваем размер очереди (последние 100 событий)
|
||||
if (eventQueue.length > 100) {
|
||||
eventQueue.splice(0, eventQueue.length - 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Рассылка события всем участникам чата
|
||||
broadcastToChat(chat_id, event, data) {
|
||||
const participants = this.chatParticipants.get(chat_id);
|
||||
if (!participants) return;
|
||||
|
||||
participants.forEach(user_id => {
|
||||
this.addEventToQueue(user_id, event, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Рассылка события всем участникам чата кроме отправителя
|
||||
broadcastToChatExcludeUser(chat_id, exclude_user_id, event, data) {
|
||||
const participants = this.chatParticipants.get(chat_id);
|
||||
if (!participants) return;
|
||||
|
||||
participants.forEach(user_id => {
|
||||
if (user_id !== exclude_user_id) {
|
||||
this.addEventToQueue(user_id, event, data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Получение списка онлайн пользователей в чате
|
||||
getOnlineUsersInChat(chat_id) {
|
||||
const participants = this.chatParticipants.get(chat_id) || new Set();
|
||||
const onlineUsers = [];
|
||||
const now = new Date();
|
||||
const ONLINE_THRESHOLD = 2 * 60 * 1000; // 2 минуты
|
||||
|
||||
participants.forEach(user_id => {
|
||||
const client = this.connectedClients.get(user_id);
|
||||
if (client && (now - client.lastActivity) < ONLINE_THRESHOLD) {
|
||||
onlineUsers.push(client.user_info.profile);
|
||||
}
|
||||
});
|
||||
|
||||
return onlineUsers;
|
||||
}
|
||||
|
||||
// Отправка системного сообщения в чат
|
||||
async sendSystemMessage(chat_id, text) {
|
||||
this.broadcastToChat(chat_id, 'system_message', {
|
||||
chat_id,
|
||||
text,
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
// Очистка старых событий
|
||||
cleanupOldEvents() {
|
||||
const now = new Date();
|
||||
const MAX_EVENT_AGE = 24 * 60 * 60 * 1000; // 24 часа
|
||||
const INACTIVE_USER_THRESHOLD = 60 * 60 * 1000; // 1 час
|
||||
|
||||
// Очищаем старые события
|
||||
this.userEventQueues.forEach((eventQueue, user_id) => {
|
||||
const filteredEvents = eventQueue.filter(event =>
|
||||
(now - event.timestamp) < MAX_EVENT_AGE
|
||||
);
|
||||
|
||||
if (filteredEvents.length !== eventQueue.length) {
|
||||
this.userEventQueues.set(user_id, filteredEvents);
|
||||
}
|
||||
});
|
||||
|
||||
// Удаляем неактивных пользователей
|
||||
this.connectedClients.forEach((client, user_id) => {
|
||||
if ((now - client.lastActivity) > INACTIVE_USER_THRESHOLD) {
|
||||
this.handleClientDisconnect(user_id);
|
||||
this.userEventQueues.delete(user_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Тестирование Real-time подписки
|
||||
async testRealtimeConnection() {
|
||||
try {
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Создаем тестовый канал для проверки подключения
|
||||
const testChannel = supabase
|
||||
.channel('test_connection')
|
||||
.subscribe((status, error) => {
|
||||
if (error) {
|
||||
console.error('❌ [Supabase] Тестовый канал - ошибка:', error);
|
||||
}
|
||||
|
||||
if (status === 'SUBSCRIBED') {
|
||||
// Отписываемся от тестового канала
|
||||
setTimeout(() => {
|
||||
testChannel.unsubscribe();
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ [Supabase] Ошибка тестирования Realtime:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка статуса подписки
|
||||
checkSubscriptionStatus() {
|
||||
if (this.realtimeSubscription) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setupRealtimeSubscription() {
|
||||
// Убираем setTimeout, вызываем сразу
|
||||
this._doSetupRealtimeSubscription();
|
||||
}
|
||||
|
||||
_doSetupRealtimeSubscription() {
|
||||
try {
|
||||
const supabase = getSupabaseClient();
|
||||
|
||||
if (!supabase) {
|
||||
console.log('❌ [Supabase] Supabase клиент не найден');
|
||||
throw new Error('Supabase client not available');
|
||||
}
|
||||
|
||||
// Подписываемся на изменения в таблице messages
|
||||
const subscription = supabase
|
||||
.channel('messages_changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'messages'
|
||||
},
|
||||
async (payload) => {
|
||||
try {
|
||||
const newMessage = payload.new;
|
||||
if (!newMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newMessage.chat_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем профиль пользователя
|
||||
const { data: userProfile, error: profileError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('id, full_name, avatar_url')
|
||||
.eq('id', newMessage.user_id)
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
console.error('❌ [Supabase] Ошибка получения профиля пользователя:', profileError);
|
||||
}
|
||||
|
||||
// Объединяем сообщение с профилем
|
||||
const messageWithProfile = {
|
||||
...newMessage,
|
||||
user_profiles: userProfile || null
|
||||
};
|
||||
|
||||
// Отправляем сообщение всем участникам чата
|
||||
this.broadcastToChat(newMessage.chat_id, 'new_message', {
|
||||
message: messageWithProfile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
} catch (callbackError) {
|
||||
console.error('❌ [Supabase] Ошибка в обработчике сообщения:', callbackError);
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe((status, error) => {
|
||||
if (error) {
|
||||
console.error('❌ [Supabase] Ошибка подписки:', error);
|
||||
}
|
||||
|
||||
if (status === 'CHANNEL_ERROR') {
|
||||
console.error('❌ [Supabase] Ошибка канала');
|
||||
} else if (status === 'TIMED_OUT') {
|
||||
console.error('❌ [Supabase] Таймаут подписки');
|
||||
}
|
||||
});
|
||||
|
||||
// Сохраняем ссылку на подписку для возможности отписки
|
||||
this.realtimeSubscription = subscription;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Supabase] Критическая ошибка при настройке подписки:', error);
|
||||
throw error; // Пробрасываем ошибку для обработки в initializeWithRetry
|
||||
}
|
||||
}
|
||||
|
||||
// Получение статистики подключений
|
||||
getConnectionStats() {
|
||||
return {
|
||||
connectedClients: this.connectedClients.size,
|
||||
activeChats: this.chatParticipants.size,
|
||||
totalChatParticipants: Array.from(this.chatParticipants.values())
|
||||
.reduce((total, participants) => total + participants.size, 0),
|
||||
totalEventQueues: this.userEventQueues.size,
|
||||
totalEvents: Array.from(this.userEventQueues.values())
|
||||
.reduce((total, queue) => total + queue.length, 0)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для создания роутера с polling эндпоинтами
|
||||
function createChatPollingRouter(express) {
|
||||
const router = express.Router();
|
||||
const chatHandler = new ChatPollingHandler();
|
||||
|
||||
// CORS middleware для всех запросов
|
||||
router.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type, Cache-Control, Authorization');
|
||||
res.header('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Обрабатываем OPTIONS запросы
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).end();
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Эндпоинт для аутентификации
|
||||
router.post('/auth', (req, res) => {
|
||||
chatHandler.handleAuthentication(req, res);
|
||||
});
|
||||
|
||||
// Эндпоинт для получения событий (polling)
|
||||
router.get('/events', (req, res) => {
|
||||
chatHandler.handleGetEvents(req, res);
|
||||
});
|
||||
|
||||
// HTTP эндпоинты для действий
|
||||
router.post('/join-chat', (req, res) => {
|
||||
chatHandler.handleJoinChat(req, res);
|
||||
});
|
||||
|
||||
router.post('/leave-chat', (req, res) => {
|
||||
chatHandler.handleLeaveChat(req, res);
|
||||
});
|
||||
|
||||
router.post('/send-message', (req, res) => {
|
||||
chatHandler.handleSendMessage(req, res);
|
||||
});
|
||||
|
||||
router.post('/typing-start', (req, res) => {
|
||||
chatHandler.handleTypingStart(req, res);
|
||||
});
|
||||
|
||||
router.post('/typing-stop', (req, res) => {
|
||||
chatHandler.handleTypingStop(req, res);
|
||||
});
|
||||
|
||||
// Эндпоинт для получения онлайн пользователей в чате
|
||||
router.get('/online-users/:chat_id', (req, res) => {
|
||||
const { chat_id } = req.params;
|
||||
const onlineUsers = chatHandler.getOnlineUsersInChat(chat_id);
|
||||
res.json({ onlineUsers });
|
||||
});
|
||||
|
||||
// Эндпоинт для получения статистики
|
||||
router.get('/stats', (req, res) => {
|
||||
const stats = chatHandler.getConnectionStats();
|
||||
res.json(stats);
|
||||
});
|
||||
|
||||
// Эндпоинт для проверки статуса Supabase подписки
|
||||
router.get('/supabase-status', (req, res) => {
|
||||
const isConnected = chatHandler.checkSubscriptionStatus();
|
||||
res.json({
|
||||
supabaseSubscriptionActive: isConnected,
|
||||
subscriptionExists: !!chatHandler.realtimeSubscription,
|
||||
subscriptionInfo: chatHandler.realtimeSubscription ? {
|
||||
channel: chatHandler.realtimeSubscription.topic,
|
||||
state: chatHandler.realtimeSubscription.state
|
||||
} : null
|
||||
});
|
||||
});
|
||||
|
||||
// Эндпоинт для принудительного переподключения к Supabase
|
||||
router.post('/reconnect-supabase', (req, res) => {
|
||||
try {
|
||||
// Отписываемся от текущей подписки
|
||||
if (chatHandler.realtimeSubscription) {
|
||||
chatHandler.realtimeSubscription.unsubscribe();
|
||||
chatHandler.realtimeSubscription = null;
|
||||
}
|
||||
|
||||
// Создаем новую подписку
|
||||
chatHandler.setupRealtimeSubscription();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reconnection initiated'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [Polling Server] Ошибка переподключения:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Reconnection failed',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Тестовый эндпоинт для создания сообщения в обход API
|
||||
router.post('/test-message', async (req, res) => {
|
||||
const { chat_id, user_id, text } = req.body;
|
||||
|
||||
if (!chat_id || !user_id || !text) {
|
||||
res.status(400).json({ error: 'chat_id, user_id и text обязательны' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Создаем тестовое событие напрямую
|
||||
chatHandler.broadcastToChat(chat_id, 'new_message', {
|
||||
message: {
|
||||
id: `test_${Date.now()}`,
|
||||
chat_id,
|
||||
user_id,
|
||||
text,
|
||||
created_at: new Date().toISOString(),
|
||||
user_profiles: {
|
||||
id: user_id,
|
||||
full_name: 'Test User',
|
||||
avatar_url: null
|
||||
}
|
||||
},
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Test message sent to polling clients'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [Polling Server] Ошибка отправки тестового сообщения:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send test message',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { router, chatHandler };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ChatPollingHandler,
|
||||
createChatPollingRouter
|
||||
};
|
||||
@@ -1,340 +0,0 @@
|
||||
const { getSupabaseClient } = require('./supabaseClient');
|
||||
|
||||
class ChatSocketHandler {
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
this.onlineUsers = new Map(); // Хранение онлайн пользователей: socket.id -> user info
|
||||
this.chatRooms = new Map(); // Хранение участников комнат: chat_id -> Set(socket.id)
|
||||
this.setupSocketHandlers();
|
||||
}
|
||||
|
||||
setupSocketHandlers() {
|
||||
this.io.on('connection', (socket) => {
|
||||
console.log(`User connected: ${socket.id}`);
|
||||
|
||||
// Аутентификация пользователя
|
||||
socket.on('authenticate', async (data) => {
|
||||
await this.handleAuthentication(socket, data);
|
||||
});
|
||||
|
||||
// Присоединение к чату
|
||||
socket.on('join_chat', async (data) => {
|
||||
await this.handleJoinChat(socket, data);
|
||||
});
|
||||
|
||||
// Покидание чата
|
||||
socket.on('leave_chat', (data) => {
|
||||
this.handleLeaveChat(socket, data);
|
||||
});
|
||||
|
||||
// Отправка сообщения
|
||||
socket.on('send_message', async (data) => {
|
||||
await this.handleSendMessage(socket, data);
|
||||
});
|
||||
|
||||
// Пользователь начал печатать
|
||||
socket.on('typing_start', (data) => {
|
||||
this.handleTypingStart(socket, data);
|
||||
});
|
||||
|
||||
// Пользователь закончил печатать
|
||||
socket.on('typing_stop', (data) => {
|
||||
this.handleTypingStop(socket, data);
|
||||
});
|
||||
|
||||
// Отключение пользователя
|
||||
socket.on('disconnect', () => {
|
||||
this.handleDisconnect(socket);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleAuthentication(socket, data) {
|
||||
try {
|
||||
const { user_id, token } = data;
|
||||
|
||||
if (!user_id) {
|
||||
socket.emit('auth_error', { message: 'user_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем информацию о пользователе из базы данных
|
||||
const supabase = getSupabaseClient();
|
||||
const { data: userProfile, error } = await supabase
|
||||
.from('user_profiles')
|
||||
.select('*')
|
||||
.eq('id', user_id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
socket.emit('auth_error', { message: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем информацию о пользователе
|
||||
this.onlineUsers.set(socket.id, {
|
||||
user_id,
|
||||
socket_id: socket.id,
|
||||
profile: userProfile,
|
||||
last_seen: new Date()
|
||||
});
|
||||
|
||||
socket.user_id = user_id;
|
||||
socket.emit('authenticated', {
|
||||
message: 'Successfully authenticated',
|
||||
user: userProfile
|
||||
});
|
||||
|
||||
console.log(`User ${user_id} authenticated with socket ${socket.id}`);
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
socket.emit('auth_error', { message: 'Authentication failed' });
|
||||
}
|
||||
}
|
||||
|
||||
async handleJoinChat(socket, data) {
|
||||
try {
|
||||
const { chat_id } = data;
|
||||
|
||||
if (!socket.user_id) {
|
||||
socket.emit('error', { message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chat_id) {
|
||||
socket.emit('error', { message: 'chat_id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что чат существует и пользователь имеет доступ к нему
|
||||
const supabase = getSupabaseClient();
|
||||
const { data: chat, error } = await supabase
|
||||
.from('chats')
|
||||
.select(`
|
||||
*,
|
||||
buildings (
|
||||
management_company_id,
|
||||
apartments (
|
||||
apartment_residents (
|
||||
user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('id', chat_id)
|
||||
.single();
|
||||
|
||||
if (error || !chat) {
|
||||
socket.emit('error', { message: 'Chat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем доступ пользователя к чату через квартиры в доме
|
||||
const hasAccess = chat.buildings.apartments.some(apartment =>
|
||||
apartment.apartment_residents.some(resident =>
|
||||
resident.user_id === socket.user_id
|
||||
)
|
||||
);
|
||||
|
||||
if (!hasAccess) {
|
||||
socket.emit('error', { message: 'Access denied to this chat' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем сокет в комнату
|
||||
socket.join(chat_id);
|
||||
|
||||
// Обновляем список участников комнаты
|
||||
if (!this.chatRooms.has(chat_id)) {
|
||||
this.chatRooms.set(chat_id, new Set());
|
||||
}
|
||||
this.chatRooms.get(chat_id).add(socket.id);
|
||||
|
||||
socket.emit('joined_chat', {
|
||||
chat_id,
|
||||
chat: chat,
|
||||
message: 'Successfully joined chat'
|
||||
});
|
||||
|
||||
// Уведомляем других участников о подключении
|
||||
const userInfo = this.onlineUsers.get(socket.id);
|
||||
socket.to(chat_id).emit('user_joined', {
|
||||
chat_id,
|
||||
user: userInfo?.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
console.log(`User ${socket.user_id} joined chat ${chat_id}`);
|
||||
} catch (error) {
|
||||
console.error('Join chat error:', error);
|
||||
socket.emit('error', { message: 'Failed to join chat' });
|
||||
}
|
||||
}
|
||||
|
||||
handleLeaveChat(socket, data) {
|
||||
const { chat_id } = data;
|
||||
|
||||
if (!chat_id) return;
|
||||
|
||||
socket.leave(chat_id);
|
||||
|
||||
// Удаляем из списка участников
|
||||
if (this.chatRooms.has(chat_id)) {
|
||||
this.chatRooms.get(chat_id).delete(socket.id);
|
||||
|
||||
// Если комната пуста, удаляем её
|
||||
if (this.chatRooms.get(chat_id).size === 0) {
|
||||
this.chatRooms.delete(chat_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Уведомляем других участников об отключении
|
||||
const userInfo = this.onlineUsers.get(socket.id);
|
||||
socket.to(chat_id).emit('user_left', {
|
||||
chat_id,
|
||||
user: userInfo?.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
console.log(`User ${socket.user_id} left chat ${chat_id}`);
|
||||
}
|
||||
|
||||
async handleSendMessage(socket, data) {
|
||||
try {
|
||||
const { chat_id, text } = data;
|
||||
|
||||
if (!socket.user_id) {
|
||||
socket.emit('error', { message: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chat_id || !text) {
|
||||
socket.emit('error', { message: 'chat_id and text are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем сообщение в базу данных
|
||||
const supabase = getSupabaseClient();
|
||||
const { data: message, error } = await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
chat_id,
|
||||
user_id: socket.user_id,
|
||||
text
|
||||
})
|
||||
.select(`
|
||||
*,
|
||||
user_profiles (
|
||||
id,
|
||||
full_name,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
socket.emit('error', { message: 'Failed to save message' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем сообщение всем участникам чата
|
||||
this.io.to(chat_id).emit('new_message', {
|
||||
message,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
console.log(`Message sent to chat ${chat_id} by user ${socket.user_id}`);
|
||||
} catch (error) {
|
||||
console.error('Send message error:', error);
|
||||
socket.emit('error', { message: 'Failed to send message' });
|
||||
}
|
||||
}
|
||||
|
||||
handleTypingStart(socket, data) {
|
||||
const { chat_id } = data;
|
||||
|
||||
if (!socket.user_id || !chat_id) return;
|
||||
|
||||
const userInfo = this.onlineUsers.get(socket.id);
|
||||
socket.to(chat_id).emit('user_typing_start', {
|
||||
chat_id,
|
||||
user: userInfo?.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
handleTypingStop(socket, data) {
|
||||
const { chat_id } = data;
|
||||
|
||||
if (!socket.user_id || !chat_id) return;
|
||||
|
||||
const userInfo = this.onlineUsers.get(socket.id);
|
||||
socket.to(chat_id).emit('user_typing_stop', {
|
||||
chat_id,
|
||||
user: userInfo?.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(socket) {
|
||||
console.log(`User disconnected: ${socket.id}`);
|
||||
|
||||
// Удаляем пользователя из всех комнат
|
||||
this.chatRooms.forEach((participants, chat_id) => {
|
||||
if (participants.has(socket.id)) {
|
||||
participants.delete(socket.id);
|
||||
|
||||
// Уведомляем других участников об отключении
|
||||
const userInfo = this.onlineUsers.get(socket.id);
|
||||
socket.to(chat_id).emit('user_left', {
|
||||
chat_id,
|
||||
user: userInfo?.profile,
|
||||
timestamp: new Date()
|
||||
});
|
||||
|
||||
// Если комната пуста, удаляем её
|
||||
if (participants.size === 0) {
|
||||
this.chatRooms.delete(chat_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Удаляем пользователя из списка онлайн
|
||||
this.onlineUsers.delete(socket.id);
|
||||
}
|
||||
|
||||
// Получение списка онлайн пользователей в чате
|
||||
getOnlineUsersInChat(chat_id) {
|
||||
const participants = this.chatRooms.get(chat_id) || new Set();
|
||||
const onlineUsers = [];
|
||||
|
||||
participants.forEach(socketId => {
|
||||
const userInfo = this.onlineUsers.get(socketId);
|
||||
if (userInfo) {
|
||||
onlineUsers.push(userInfo.profile);
|
||||
}
|
||||
});
|
||||
|
||||
return onlineUsers;
|
||||
}
|
||||
|
||||
// Отправка системного сообщения в чат
|
||||
async sendSystemMessage(chat_id, text) {
|
||||
this.io.to(chat_id).emit('system_message', {
|
||||
chat_id,
|
||||
text,
|
||||
timestamp: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Функция инициализации Socket.IO для чатов
|
||||
function initializeChatSocket(io) {
|
||||
const chatHandler = new ChatSocketHandler(io);
|
||||
return chatHandler;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ChatSocketHandler,
|
||||
initializeChatSocket
|
||||
};
|
||||
@@ -3,12 +3,30 @@ const { createClient } = require('@supabase/supabase-js');
|
||||
const { getSupabaseUrl, getSupabaseKey, getSupabaseServiceKey } = require('./get-constants');
|
||||
|
||||
let supabase = null;
|
||||
let initializationPromise = null;
|
||||
|
||||
async function initSupabaseClient() {
|
||||
const supabaseUrl = await getSupabaseUrl();
|
||||
const supabaseAnonKey = await getSupabaseKey();
|
||||
const supabaseServiceRoleKey = await getSupabaseServiceKey();
|
||||
supabase = createClient(supabaseUrl, supabaseServiceRoleKey);
|
||||
console.log('🔄 [Supabase Client] Начинаем инициализацию...');
|
||||
|
||||
try {
|
||||
console.log('🔄 [Supabase Client] Получаем конфигурацию...');
|
||||
const supabaseUrl = await getSupabaseUrl();
|
||||
const supabaseAnonKey = await getSupabaseKey();
|
||||
const supabaseServiceRoleKey = await getSupabaseServiceKey();
|
||||
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceRoleKey) {
|
||||
throw new Error('Missing required Supabase configuration');
|
||||
}
|
||||
|
||||
supabase = createClient(supabaseUrl, supabaseServiceRoleKey);
|
||||
|
||||
return supabase;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ [Supabase Client] Ошибка инициализации:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getSupabaseClient() {
|
||||
@@ -20,20 +38,49 @@ function getSupabaseClient() {
|
||||
|
||||
// POST /refresh-supabase-client
|
||||
router.post('/refresh-supabase-client', async (req, res) => {
|
||||
try {
|
||||
try {
|
||||
await initSupabaseClient();
|
||||
res.json({ success: true, message: 'Supabase client refreshed' });
|
||||
} catch (error) {
|
||||
} catch (error) {
|
||||
console.error('❌ [Supabase Client] Ошибка обновления:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET /supabase-client-status
|
||||
router.get('/supabase-client-status', (req, res) => {
|
||||
console.log('🔍 [Supabase Client] Проверяем статус клиента...');
|
||||
|
||||
const isInitialized = !!supabase;
|
||||
|
||||
res.json({
|
||||
initialized: isInitialized,
|
||||
clientExists: !!supabase,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Инициализация клиента при старте
|
||||
(async () => {
|
||||
initializationPromise = (async () => {
|
||||
try {
|
||||
await initSupabaseClient();
|
||||
} catch (error) {
|
||||
console.error('❌ [Supabase Client] Ошибка инициализации при старте:', error);
|
||||
// Планируем повторную попытку через 5 секунд
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await initSupabaseClient();
|
||||
} catch (retryError) {
|
||||
console.error('❌ [Supabase Client] Повторная инициализация неудачна:', retryError);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
})();
|
||||
|
||||
module.exports = {
|
||||
getSupabaseClient,
|
||||
supabaseRouter: router
|
||||
initSupabaseClient,
|
||||
supabaseRouter: router,
|
||||
// Экспортируем промис инициализации для возможности ожидания
|
||||
initializationPromise
|
||||
};
|
||||
@@ -6,39 +6,100 @@ router.get('/votes/:initiative_id', async (req, res) => {
|
||||
const supabase = getSupabaseClient();
|
||||
const { initiative_id } = req.params;
|
||||
const { data, error } = await supabase.from('votes').select('*').eq('initiative_id', initiative_id);
|
||||
if (error) return res.status(400).json({ error: error.message });
|
||||
if (error)
|
||||
return res.status(400).json({ error: error.message });
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Получить голос пользователя по инициативе
|
||||
router.get('/votes/:initiative_id/:user_id', async (req, res) => {
|
||||
router.get('/votes/:initiative_id/user/:user_id', async (req, res) => {
|
||||
const supabase = getSupabaseClient();
|
||||
const { initiative_id, user_id } = req.params;
|
||||
const { data, error } = await supabase.from('votes').select('*').eq('initiative_id', initiative_id).eq('user_id', user_id).single();
|
||||
if (error) return res.status(400).json({ error: error.message });
|
||||
if (error) {
|
||||
console.log(error, '/votes/:initiative_id/:user_id')
|
||||
console.log(initiative_id, user_id)
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Получить все голоса по инициативе (через query)
|
||||
router.get('/votes', async (req, res) => {
|
||||
// Получить статистику голосов по инициативе
|
||||
router.get('/votes/stats/:initiative_id', async (req, res) => {
|
||||
const supabase = getSupabaseClient();
|
||||
const { initiative_id } = req.query;
|
||||
if (!initiative_id) return res.status(400).json({ error: 'initiative_id required' });
|
||||
const { data, error } = await supabase.from('votes').select('*').eq('initiative_id', initiative_id);
|
||||
if (error) return res.status(400).json({ error: error.message });
|
||||
res.json(data);
|
||||
const { initiative_id } = req.params;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.select('vote_type')
|
||||
.eq('initiative_id', initiative_id);
|
||||
console.log(data, error)
|
||||
if (error) {
|
||||
console.log('/votes/:initiative_id/stats')
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
const stats = {
|
||||
for: data.filter(vote => vote.vote_type === 'for').length,
|
||||
against: data.filter(vote => vote.vote_type === 'against').length,
|
||||
total: data.length
|
||||
};
|
||||
|
||||
res.json(stats);
|
||||
});
|
||||
|
||||
// Проголосовать (создать или обновить голос)
|
||||
// Проголосовать (создать, обновить или удалить голос)
|
||||
router.post('/votes', async (req, res) => {
|
||||
const supabase = getSupabaseClient();
|
||||
const { initiative_id, user_id, vote_type } = req.body;
|
||||
// upsert: если голос уже есть, обновить, иначе создать
|
||||
const { data, error } = await supabase.from('votes').upsert([
|
||||
{ initiative_id, user_id, vote_type }
|
||||
], { onConflict: ['initiative_id', 'user_id'] }).select().single();
|
||||
if (error) return res.status(400).json({ error: error.message });
|
||||
res.json(data);
|
||||
|
||||
// Проверяем существующий голос
|
||||
const { data: existingVote, error: checkError } = await supabase
|
||||
.from('votes')
|
||||
.select('*')
|
||||
.eq('initiative_id', initiative_id)
|
||||
.eq('user_id', user_id)
|
||||
.single();
|
||||
|
||||
if (checkError && checkError.code !== 'PGRST116') {
|
||||
console.log('1/votes')
|
||||
return res.status(400).json({ error: checkError.message });
|
||||
}
|
||||
|
||||
if (existingVote) {
|
||||
if (existingVote.vote_type === vote_type) {
|
||||
// Если нажали тот же тип голоса - УДАЛЯЕМ (отменяем голос)
|
||||
const { error: deleteError } = await supabase
|
||||
.from('votes')
|
||||
.delete()
|
||||
.eq('initiative_id', initiative_id)
|
||||
.eq('user_id', user_id);
|
||||
|
||||
if (deleteError) return res.status(400).json({ error: deleteError.message });
|
||||
res.json({ message: 'Vote removed', action: 'removed', previous_vote: existingVote.vote_type });
|
||||
} else {
|
||||
// Если нажали другой тип голоса - ОБНОВЛЯЕМ
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.update({ vote_type })
|
||||
.eq('initiative_id', initiative_id)
|
||||
.eq('user_id', user_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return res.status(400).json({ error: error.message });
|
||||
res.json({ ...data, action: 'updated', previous_vote: existingVote.vote_type });
|
||||
}
|
||||
} else {
|
||||
// Если голоса нет - СОЗДАЕМ новый
|
||||
const { data, error } = await supabase
|
||||
.from('votes')
|
||||
.insert([{ initiative_id, user_id, vote_type }])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) return res.status(400).json({ error: error.message });
|
||||
res.json({ ...data, action: 'created' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user