feat: initial JaniChat messenger — PWA, WebSocket, admin panel
This commit is contained in:
99
backend/src/db.ts
Normal file
99
backend/src/db.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Pool } from 'pg';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
export async function initDB() {
|
||||
await pool.query(`
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar_color VARCHAR(7) DEFAULT '#3b82f6',
|
||||
bio TEXT DEFAULT '',
|
||||
phone VARCHAR(20) DEFAULT '',
|
||||
is_admin BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
last_seen TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('private','group','channel')),
|
||||
title VARCHAR(200),
|
||||
description TEXT DEFAULT '',
|
||||
avatar_color VARCHAR(7) DEFAULT '#8b5cf6',
|
||||
is_public BOOLEAN DEFAULT FALSE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_members (
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner','admin','member')),
|
||||
can_send_messages BOOLEAN DEFAULT TRUE,
|
||||
can_send_media BOOLEAN DEFAULT TRUE,
|
||||
can_add_members BOOLEAN DEFAULT FALSE,
|
||||
can_pin_messages BOOLEAN DEFAULT FALSE,
|
||||
is_muted BOOLEAN DEFAULT FALSE,
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
last_read_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (chat_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
|
||||
sender_id UUID REFERENCES users(id),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (type IN ('text','image','file','system')),
|
||||
reply_to_id UUID REFERENCES messages(id),
|
||||
is_edited BOOLEAN DEFAULT FALSE,
|
||||
edited_at TIMESTAMPTZ,
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL,
|
||||
auth_key TEXT NOT NULL,
|
||||
p256dh TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_members_user_id ON chat_members(user_id);
|
||||
`);
|
||||
|
||||
// Seed admin if no users
|
||||
const { rows } = await pool.query('SELECT COUNT(*) FROM users');
|
||||
if (parseInt(rows[0].count) === 0) {
|
||||
const hash = await bcrypt.hash('admin123', 10);
|
||||
await pool.query(
|
||||
`INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ($1,$2,$3,TRUE)`,
|
||||
['admin', 'Администратор', hash]
|
||||
);
|
||||
console.log('Created admin user: admin / admin123');
|
||||
}
|
||||
}
|
||||
71
backend/src/index.ts
Normal file
71
backend/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import Fastify from 'fastify';
|
||||
import fastifyJwt from '@fastify/jwt';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import fastifyWebSocket from '@fastify/websocket';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import webpush from 'web-push';
|
||||
import path from 'path';
|
||||
import { initDB } from './db.js';
|
||||
import { setupWebSocket } from './ws.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import chatRoutes from './routes/chats.js';
|
||||
import messageRoutes from './routes/messages.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import pushRoutes from './routes/push.js';
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// VAPID
|
||||
webpush.setVapidDetails(
|
||||
`mailto:${process.env.VAPID_EMAIL || 'admin@janichat.ru'}`,
|
||||
process.env.VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!
|
||||
);
|
||||
|
||||
// Plugins
|
||||
await app.register(fastifyCors, {
|
||||
origin: (process.env.ALLOWED_ORIGINS || '').split(','),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(fastifyJwt, { secret: process.env.JWT_SECRET! });
|
||||
|
||||
await app.register(fastifyWebSocket);
|
||||
|
||||
await app.register(fastifyMultipart, {
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
await app.register(fastifyStatic, {
|
||||
root: '/uploads',
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
|
||||
// Auth decorator
|
||||
app.decorate('authenticate', async function(request: any, reply: any) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
reply.status(401).send({ error: 'Unauthorized' });
|
||||
}
|
||||
});
|
||||
|
||||
// Routes
|
||||
await app.register(authRoutes, { prefix: '/api/auth' });
|
||||
await app.register(adminRoutes, { prefix: '/api/admin' });
|
||||
await app.register(chatRoutes, { prefix: '/api/chats' });
|
||||
await app.register(messageRoutes, { prefix: '/api/messages' });
|
||||
await app.register(userRoutes, { prefix: '/api/users' });
|
||||
await app.register(pushRoutes, { prefix: '/api/push' });
|
||||
|
||||
// Health check
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
|
||||
// WebSocket
|
||||
setupWebSocket(app);
|
||||
|
||||
// Init
|
||||
await initDB();
|
||||
await app.listen({ port: parseInt(process.env.PORT || '3000'), host: '0.0.0.0' });
|
||||
101
backend/src/routes/admin.ts
Normal file
101
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
|
||||
|
||||
async function requireAdmin(req: any, reply: any) {
|
||||
const { isAdmin } = req.user as { isAdmin: boolean };
|
||||
if (!isAdmin) return reply.status(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
export default async function adminRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.authenticate);
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
// List users
|
||||
app.get('/users', async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, avatar_color, is_admin, is_active, last_seen, created_at
|
||||
FROM users ORDER BY created_at DESC`
|
||||
);
|
||||
return rows.map(u => ({
|
||||
id: u.id, username: u.username, displayName: u.display_name,
|
||||
avatarColor: u.avatar_color, isAdmin: u.is_admin, isActive: u.is_active,
|
||||
lastSeen: u.last_seen, createdAt: u.created_at,
|
||||
}));
|
||||
});
|
||||
|
||||
// Create user
|
||||
app.post('/users', async (req, reply) => {
|
||||
const { username, displayName, password, isAdmin } = req.body as any;
|
||||
const { id: createdBy } = req.user as { id: string };
|
||||
|
||||
if (!username || !displayName || !password) {
|
||||
return reply.status(400).send({ error: 'Заполните все поля' });
|
||||
}
|
||||
|
||||
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
|
||||
try {
|
||||
const { rows: [user] } = await pool.query(
|
||||
`INSERT INTO users (username, display_name, password_hash, avatar_color, is_admin, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, username, display_name, avatar_color, is_admin`,
|
||||
[username, displayName, hash, color, !!isAdmin, createdBy]
|
||||
);
|
||||
return reply.status(201).send({
|
||||
id: user.id, username: user.username, displayName: user.display_name,
|
||||
avatarColor: user.avatar_color, isAdmin: user.is_admin,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.code === '23505') return reply.status(400).send({ error: 'Логин уже занят' });
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// Update user
|
||||
app.put('/users/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { displayName, isAdmin, isActive, password } = req.body as any;
|
||||
|
||||
if (password) {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE users SET
|
||||
display_name = COALESCE($1, display_name),
|
||||
is_admin = COALESCE($2, is_admin),
|
||||
is_active = COALESCE($3, is_active)
|
||||
WHERE id = $4`,
|
||||
[displayName, isAdmin, isActive, id]
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Delete user
|
||||
app.delete('/users/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { id: selfId } = req.user as { id: string };
|
||||
if (id === selfId) return reply.status(400).send({ error: 'Нельзя удалить себя' });
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [id]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Stats
|
||||
app.get('/stats', async () => {
|
||||
const [users, chats, messages] = await Promise.all([
|
||||
pool.query('SELECT COUNT(*) FROM users'),
|
||||
pool.query('SELECT COUNT(*) FROM chats'),
|
||||
pool.query('SELECT COUNT(*) FROM messages WHERE is_deleted = FALSE'),
|
||||
]);
|
||||
return {
|
||||
users: parseInt(users.rows[0].count),
|
||||
chats: parseInt(chats.rows[0].count),
|
||||
messages: parseInt(messages.rows[0].count),
|
||||
};
|
||||
});
|
||||
}
|
||||
76
backend/src/routes/auth.ts
Normal file
76
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
export default async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/login', async (req, reply) => {
|
||||
const { username, password } = req.body as { username: string; password: string };
|
||||
|
||||
const { rows: [user] } = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1 AND is_active = TRUE', [username]
|
||||
);
|
||||
if (!user) return reply.status(401).send({ error: 'Неверный логин или пароль' });
|
||||
|
||||
const ok = await bcrypt.compare(password, user.password_hash);
|
||||
if (!ok) return reply.status(401).send({ error: 'Неверный логин или пароль' });
|
||||
|
||||
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [user.id]);
|
||||
|
||||
const token = app.jwt.sign({ id: user.id, isAdmin: user.is_admin }, { expiresIn: '30d' });
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
avatarColor: user.avatar_color,
|
||||
bio: user.bio,
|
||||
isAdmin: user.is_admin,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/me', { preHandler: [app.authenticate] }, async (req) => {
|
||||
const { id } = req.user as { id: string };
|
||||
const { rows: [user] } = await pool.query(
|
||||
'SELECT id, username, display_name, avatar_color, bio, phone, is_admin, last_seen FROM users WHERE id = $1', [id]
|
||||
);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
avatarColor: user.avatar_color,
|
||||
bio: user.bio,
|
||||
phone: user.phone,
|
||||
isAdmin: user.is_admin,
|
||||
lastSeen: user.last_seen,
|
||||
};
|
||||
});
|
||||
|
||||
app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => {
|
||||
const { id } = req.user as { id: string };
|
||||
const { displayName, bio, phone, password, newPassword } = req.body as any;
|
||||
|
||||
if (newPassword) {
|
||||
const { rows: [user] } = await pool.query('SELECT password_hash FROM users WHERE id = $1', [id]);
|
||||
const ok = await bcrypt.compare(password, user.password_hash);
|
||||
if (!ok) return reply.status(400).send({ error: 'Неверный текущий пароль' });
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET display_name = COALESCE($1, display_name), bio = COALESCE($2, bio), phone = COALESCE($3, phone) WHERE id = $4',
|
||||
[displayName, bio, phone, id]
|
||||
);
|
||||
|
||||
const { rows: [user] } = await pool.query(
|
||||
'SELECT id, username, display_name, avatar_color, bio, phone, is_admin FROM users WHERE id = $1', [id]
|
||||
);
|
||||
return {
|
||||
id: user.id, username: user.username, displayName: user.display_name,
|
||||
avatarColor: user.avatar_color, bio: user.bio, phone: user.phone, isAdmin: user.is_admin,
|
||||
};
|
||||
});
|
||||
}
|
||||
275
backend/src/routes/chats.ts
Normal file
275
backend/src/routes/chats.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { pool } from '../db.js';
|
||||
import { connections } from '../ws.js';
|
||||
|
||||
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
|
||||
|
||||
export default async function chatRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.authenticate);
|
||||
|
||||
// Get my chats
|
||||
app.get('/', async (req) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
c.id, c.type, c.title, c.description, c.avatar_color, c.is_public, c.created_at,
|
||||
cm.role, cm.last_read_at,
|
||||
(SELECT content FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT COUNT(*) FROM messages WHERE chat_id = c.id AND is_deleted = FALSE AND created_at > cm.last_read_at) AS unread_count,
|
||||
(SELECT COUNT(*) FROM chat_members WHERE chat_id = c.id) AS member_count,
|
||||
-- For private chats, get the other user
|
||||
CASE WHEN c.type = 'private' THEN (
|
||||
SELECT u.display_name FROM users u
|
||||
JOIN chat_members cm2 ON cm2.user_id = u.id
|
||||
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
|
||||
) END AS private_name,
|
||||
CASE WHEN c.type = 'private' THEN (
|
||||
SELECT u.avatar_color FROM users u
|
||||
JOIN chat_members cm2 ON cm2.user_id = u.id
|
||||
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
|
||||
) END AS private_color,
|
||||
CASE WHEN c.type = 'private' THEN (
|
||||
SELECT u.id FROM users u
|
||||
JOIN chat_members cm2 ON cm2.user_id = u.id
|
||||
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
|
||||
) END AS private_user_id
|
||||
FROM chats c
|
||||
JOIN chat_members cm ON cm.chat_id = c.id AND cm.user_id = $1
|
||||
ORDER BY COALESCE(last_message_at, c.created_at) DESC
|
||||
`, [userId]);
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
title: r.type === 'private' ? r.private_name : r.title,
|
||||
description: r.description,
|
||||
avatarColor: r.type === 'private' ? r.private_color : r.avatar_color,
|
||||
isPublic: r.is_public,
|
||||
role: r.role,
|
||||
lastMessage: r.last_message,
|
||||
lastMessageAt: r.last_message_at,
|
||||
unreadCount: parseInt(r.unread_count),
|
||||
memberCount: parseInt(r.member_count),
|
||||
privateUserId: r.private_user_id,
|
||||
createdAt: r.created_at,
|
||||
}));
|
||||
});
|
||||
|
||||
// Get chat by id (with members)
|
||||
app.get('/:id', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role, can_send_messages, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2',
|
||||
[id, userId]
|
||||
);
|
||||
if (!member) return reply.status(403).send({ error: 'Not a member' });
|
||||
|
||||
const { rows: [chat] } = await pool.query('SELECT * FROM chats WHERE id = $1', [id]);
|
||||
if (!chat) return reply.status(404).send({ error: 'Not found' });
|
||||
|
||||
const { rows: members } = await pool.query(`
|
||||
SELECT u.id, u.username, u.display_name, u.avatar_color, u.last_seen, cm.role, cm.can_send_messages
|
||||
FROM chat_members cm
|
||||
JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.chat_id = $1
|
||||
ORDER BY cm.role DESC, u.display_name
|
||||
`, [id]);
|
||||
|
||||
const onlineSet = new Set(connections.keys());
|
||||
|
||||
return {
|
||||
id: chat.id,
|
||||
type: chat.type,
|
||||
title: chat.title,
|
||||
description: chat.description,
|
||||
avatarColor: chat.avatar_color,
|
||||
isPublic: chat.is_public,
|
||||
myRole: member.role,
|
||||
canSendMessages: member.can_send_messages,
|
||||
canAddMembers: member.can_add_members,
|
||||
createdAt: chat.created_at,
|
||||
members: members.map(m => ({
|
||||
id: m.id, username: m.username, displayName: m.display_name,
|
||||
avatarColor: m.avatar_color, role: m.role, online: onlineSet.has(m.id),
|
||||
lastSeen: m.last_seen, canSendMessages: m.can_send_messages,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Start or get private chat
|
||||
app.post('/private/:userId', async (req, reply) => {
|
||||
const { id: myId } = req.user as { id: string };
|
||||
const { userId } = req.params as { userId: string };
|
||||
|
||||
if (myId === userId) return reply.status(400).send({ error: 'Cannot chat with yourself' });
|
||||
|
||||
// Check if private chat already exists
|
||||
const { rows } = await pool.query(`
|
||||
SELECT c.id FROM chats c
|
||||
JOIN chat_members cm1 ON cm1.chat_id = c.id AND cm1.user_id = $1
|
||||
JOIN chat_members cm2 ON cm2.chat_id = c.id AND cm2.user_id = $2
|
||||
WHERE c.type = 'private'
|
||||
LIMIT 1
|
||||
`, [myId, userId]);
|
||||
|
||||
if (rows.length > 0) return { id: rows[0].id };
|
||||
|
||||
// Create new private chat
|
||||
const { rows: [target] } = await pool.query('SELECT id FROM users WHERE id = $1', [userId]);
|
||||
if (!target) return reply.status(404).send({ error: 'User not found' });
|
||||
|
||||
const { rows: [chat] } = await pool.query(
|
||||
`INSERT INTO chats (type, created_by) VALUES ('private', $1) RETURNING id`, [myId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member'),($1,$3,'member')`,
|
||||
[chat.id, myId, userId]
|
||||
);
|
||||
|
||||
return reply.status(201).send({ id: chat.id });
|
||||
});
|
||||
|
||||
// Create group or channel
|
||||
app.post('/', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { type, title, description, isPublic, memberIds } = req.body as any;
|
||||
|
||||
if (!['group', 'channel'].includes(type)) return reply.status(400).send({ error: 'Invalid type' });
|
||||
if (!title) return reply.status(400).send({ error: 'Title required' });
|
||||
|
||||
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
const { rows: [chat] } = await pool.query(
|
||||
`INSERT INTO chats (type, title, description, avatar_color, is_public, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
[type, title, description || '', color, !!isPublic, userId]
|
||||
);
|
||||
|
||||
// Add creator as owner
|
||||
await pool.query(
|
||||
`INSERT INTO chat_members (chat_id, user_id, role, can_add_members, can_pin_messages)
|
||||
VALUES ($1,$2,'owner',TRUE,TRUE)`,
|
||||
[chat.id, userId]
|
||||
);
|
||||
|
||||
// Add other members
|
||||
if (Array.isArray(memberIds) && memberIds.length > 0) {
|
||||
for (const mid of memberIds) {
|
||||
if (mid === userId) continue;
|
||||
await pool.query(
|
||||
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`,
|
||||
[chat.id, mid]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(201).send({ id: chat.id });
|
||||
});
|
||||
|
||||
// Update chat
|
||||
app.put('/:id', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id } = req.params as { id: string };
|
||||
const { title, description } = req.body as any;
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
|
||||
);
|
||||
if (!member || !['owner', 'admin'].includes(member.role)) {
|
||||
return reply.status(403).send({ error: 'No permission' });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE chats SET title = COALESCE($1, title), description = COALESCE($2, description) WHERE id = $3',
|
||||
[title, description, id]
|
||||
);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Add member
|
||||
app.post('/:id/members', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id } = req.params as { id: string };
|
||||
const { memberId } = req.body as { memberId: string };
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
|
||||
);
|
||||
if (!member || (!member.can_add_members && !['owner', 'admin'].includes(member.role))) {
|
||||
return reply.status(403).send({ error: 'No permission' });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`,
|
||||
[id, memberId]
|
||||
);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Remove member
|
||||
app.delete('/:id/members/:memberId', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id, memberId } = req.params as { id: string; memberId: string };
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
|
||||
);
|
||||
if (!member || (!['owner', 'admin'].includes(member.role) && userId !== memberId)) {
|
||||
return reply.status(403).send({ error: 'No permission' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, memberId]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Update member role/permissions
|
||||
app.put('/:id/members/:memberId', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id, memberId } = req.params as { id: string; memberId: string };
|
||||
const { role, canSendMessages } = req.body as any;
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
|
||||
);
|
||||
if (!member || !['owner', 'admin'].includes(member.role)) {
|
||||
return reply.status(403).send({ error: 'No permission' });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE chat_members SET
|
||||
role = COALESCE($1, role),
|
||||
can_send_messages = COALESCE($2, can_send_messages)
|
||||
WHERE chat_id = $3 AND user_id = $4`,
|
||||
[role, canSendMessages, id, memberId]
|
||||
);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Leave chat
|
||||
app.delete('/:id/leave', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Delete chat (owner only)
|
||||
app.delete('/:id', async (req, reply) => {
|
||||
const { id: userId, isAdmin } = req.user as { id: string; isAdmin: boolean };
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
|
||||
);
|
||||
if (!member || (member.role !== 'owner' && !isAdmin)) {
|
||||
return reply.status(403).send({ error: 'No permission' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM chats WHERE id = $1', [id]);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
137
backend/src/routes/messages.ts
Normal file
137
backend/src/routes/messages.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { pool } from '../db.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export default async function messageRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.authenticate);
|
||||
|
||||
// Get messages for a chat
|
||||
app.get('/chat/:chatId', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { chatId } = req.params as { chatId: string };
|
||||
const { before, limit = '50' } = req.query as any;
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT 1 FROM chat_members WHERE chat_id = $1 AND user_id = $2', [chatId, userId]
|
||||
);
|
||||
if (!member) return reply.status(403).send({ error: 'Not a member' });
|
||||
|
||||
const lim = Math.min(parseInt(limit), 100);
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (before) {
|
||||
query = `
|
||||
SELECT m.*, u.username, u.display_name, u.avatar_color,
|
||||
rm.content AS reply_content,
|
||||
ru.display_name AS reply_sender_name,
|
||||
(SELECT json_agg(json_build_object('id',a.id,'filename',a.filename,'originalName',a.original_name,'mimeType',a.mime_type,'size',a.size,'url',a.url))
|
||||
FROM attachments a WHERE a.message_id = m.id) AS attachments
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON u.id = m.sender_id
|
||||
LEFT JOIN messages rm ON rm.id = m.reply_to_id
|
||||
LEFT JOIN users ru ON ru.id = rm.sender_id
|
||||
WHERE m.chat_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
||||
ORDER BY m.created_at DESC LIMIT $3
|
||||
`;
|
||||
params = [chatId, before, lim];
|
||||
} else {
|
||||
query = `
|
||||
SELECT m.*, u.username, u.display_name, u.avatar_color,
|
||||
rm.content AS reply_content,
|
||||
ru.display_name AS reply_sender_name,
|
||||
(SELECT json_agg(json_build_object('id',a.id,'filename',a.filename,'originalName',a.original_name,'mimeType',a.mime_type,'size',a.size,'url',a.url))
|
||||
FROM attachments a WHERE a.message_id = m.id) AS attachments
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON u.id = m.sender_id
|
||||
LEFT JOIN messages rm ON rm.id = m.reply_to_id
|
||||
LEFT JOIN users ru ON ru.id = rm.sender_id
|
||||
WHERE m.chat_id = $1
|
||||
ORDER BY m.created_at DESC LIMIT $2
|
||||
`;
|
||||
params = [chatId, lim];
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(query, params);
|
||||
|
||||
// Update last_read_at
|
||||
await pool.query(
|
||||
'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2',
|
||||
[chatId, userId]
|
||||
);
|
||||
|
||||
return rows.reverse().map(m => ({
|
||||
id: m.id,
|
||||
chatId: m.chat_id,
|
||||
content: m.content,
|
||||
type: m.type,
|
||||
isDeleted: m.is_deleted,
|
||||
isEdited: m.is_edited,
|
||||
editedAt: m.edited_at,
|
||||
createdAt: m.created_at,
|
||||
replyTo: m.reply_to_id ? {
|
||||
id: m.reply_to_id,
|
||||
content: m.reply_content,
|
||||
senderName: m.reply_sender_name,
|
||||
} : null,
|
||||
attachments: m.attachments || [],
|
||||
sender: m.sender_id ? {
|
||||
id: m.sender_id,
|
||||
username: m.username,
|
||||
displayName: m.display_name,
|
||||
avatarColor: m.avatar_color,
|
||||
} : null,
|
||||
}));
|
||||
});
|
||||
|
||||
// Upload file and send as message
|
||||
app.post('/chat/:chatId/upload', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { chatId } = req.params as { chatId: string };
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT can_send_media FROM chat_members WHERE chat_id = $1 AND user_id = $2', [chatId, userId]
|
||||
);
|
||||
if (!member) return reply.status(403).send({ error: 'Not a member' });
|
||||
if (!member.can_send_media) return reply.status(403).send({ error: 'No media permission' });
|
||||
|
||||
const data = await req.file();
|
||||
if (!data) return reply.status(400).send({ error: 'No file' });
|
||||
|
||||
const ext = path.extname(data.filename);
|
||||
const filename = `${crypto.randomUUID()}${ext}`;
|
||||
const uploadDir = '/uploads';
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const filepath = path.join(uploadDir, filename);
|
||||
|
||||
const buffer = await data.toBuffer();
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
|
||||
const isImage = data.mimetype.startsWith('image/');
|
||||
const msgType = isImage ? 'image' : 'file';
|
||||
const url = `/uploads/${filename}`;
|
||||
|
||||
const { rows: [msg] } = await pool.query(
|
||||
`INSERT INTO messages (chat_id, sender_id, content, type) VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[chatId, userId, data.filename, msgType]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO attachments (message_id, filename, original_name, mime_type, size, url)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[msg.id, filename, data.filename, data.mimetype, buffer.length, url]
|
||||
);
|
||||
|
||||
const { rows: [sender] } = await pool.query(
|
||||
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId]
|
||||
);
|
||||
|
||||
return {
|
||||
id: msg.id, chatId: msg.chat_id, content: msg.content, type: msg.type,
|
||||
createdAt: msg.created_at, sender,
|
||||
attachments: [{ filename, originalName: data.filename, mimeType: data.mimetype, size: buffer.length, url }],
|
||||
};
|
||||
});
|
||||
}
|
||||
30
backend/src/routes/push.ts
Normal file
30
backend/src/routes/push.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
export default async function pushRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.authenticate);
|
||||
|
||||
app.get('/vapid-public-key', async () => {
|
||||
return { key: process.env.VAPID_PUBLIC_KEY };
|
||||
});
|
||||
|
||||
app.post('/subscribe', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { endpoint, keys } = req.body as { endpoint: string; keys: { auth: string; p256dh: string } };
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, auth_key, p256dh)
|
||||
VALUES ($1,$2,$3,$4)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, auth_key = $3, p256dh = $4`,
|
||||
[userId, endpoint, keys.auth, keys.p256dh]
|
||||
);
|
||||
return reply.status(201).send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/subscribe', async (req, reply) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { endpoint } = req.body as { endpoint: string };
|
||||
await pool.query('DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2', [userId, endpoint]);
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
55
backend/src/routes/users.ts
Normal file
55
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { pool } from '../db.js';
|
||||
import { connections } from '../ws.js';
|
||||
|
||||
export default async function userRoutes(app: FastifyInstance) {
|
||||
app.addHook('preHandler', app.authenticate);
|
||||
|
||||
// Search users
|
||||
app.get('/search', async (req) => {
|
||||
const { q } = req.query as { q: string };
|
||||
const { id: userId } = req.user as { id: string };
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, avatar_color, last_seen FROM users
|
||||
WHERE is_active = TRUE AND id != $1
|
||||
AND (username ILIKE $2 OR display_name ILIKE $2)
|
||||
ORDER BY display_name LIMIT 20`,
|
||||
[userId, `%${q}%`]
|
||||
);
|
||||
|
||||
const online = connections;
|
||||
return rows.map(u => ({
|
||||
id: u.id, username: u.username, displayName: u.display_name,
|
||||
avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen,
|
||||
}));
|
||||
});
|
||||
|
||||
// Get all users (for add to chat)
|
||||
app.get('/', async (req) => {
|
||||
const { id: userId } = req.user as { id: string };
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, display_name, avatar_color, last_seen FROM users
|
||||
WHERE is_active = TRUE AND id != $1 ORDER BY display_name`,
|
||||
[userId]
|
||||
);
|
||||
const online = connections;
|
||||
return rows.map(u => ({
|
||||
id: u.id, username: u.username, displayName: u.display_name,
|
||||
avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen,
|
||||
}));
|
||||
});
|
||||
|
||||
// Get user profile
|
||||
app.get('/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { rows: [u] } = await pool.query(
|
||||
'SELECT id, username, display_name, avatar_color, bio, last_seen FROM users WHERE id = $1 AND is_active = TRUE', [id]
|
||||
);
|
||||
if (!u) return reply.status(404).send({ error: 'Not found' });
|
||||
return {
|
||||
id: u.id, username: u.username, displayName: u.display_name,
|
||||
avatarColor: u.avatar_color, bio: u.bio, online: connections.has(u.id), lastSeen: u.last_seen,
|
||||
};
|
||||
});
|
||||
}
|
||||
202
backend/src/ws.ts
Normal file
202
backend/src/ws.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { WebSocket } from 'ws';
|
||||
import { pool } from './db.js';
|
||||
import webpush from 'web-push';
|
||||
|
||||
// userId -> Set of WebSocket connections
|
||||
export const connections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
function broadcast(userIds: string[], data: object) {
|
||||
const msg = JSON.stringify(data);
|
||||
for (const uid of userIds) {
|
||||
const sockets = connections.get(uid);
|
||||
if (sockets) {
|
||||
sockets.forEach(ws => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getChatMemberIds(chatId: string): Promise<string[]> {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT user_id FROM chat_members WHERE chat_id = $1', [chatId]
|
||||
);
|
||||
return rows.map(r => r.user_id);
|
||||
}
|
||||
|
||||
async function sendPushToOfflineUsers(userIds: string[], payload: object) {
|
||||
const online = new Set(connections.keys());
|
||||
const offline = userIds.filter(id => !online.has(id));
|
||||
if (offline.length === 0) return;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT endpoint, auth_key, p256dh FROM push_subscriptions WHERE user_id = ANY($1)',
|
||||
[offline]
|
||||
);
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
for (const sub of rows) {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: sub.endpoint, keys: { auth: sub.auth_key, p256dh: sub.p256dh } },
|
||||
body
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e.statusCode === 410) {
|
||||
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupWebSocket(app: FastifyInstance) {
|
||||
app.get('/ws', { websocket: true }, async (socket, req) => {
|
||||
let userId: string | null = null;
|
||||
|
||||
try {
|
||||
const token = (req.query as any).token;
|
||||
const decoded = app.jwt.verify<{ id: string }>(token);
|
||||
userId = decoded.id;
|
||||
} catch {
|
||||
socket.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Register connection
|
||||
if (!connections.has(userId)) connections.set(userId, new Set());
|
||||
connections.get(userId)!.add(socket);
|
||||
|
||||
// Update last_seen
|
||||
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [userId]);
|
||||
|
||||
// Notify others that user is online
|
||||
const { rows: memberChats } = await pool.query(
|
||||
'SELECT DISTINCT chat_id FROM chat_members WHERE user_id = $1', [userId]
|
||||
);
|
||||
for (const row of memberChats) {
|
||||
const ids = await getChatMemberIds(row.chat_id);
|
||||
broadcast(ids.filter(id => id !== userId), { type: 'user_online', payload: { userId, online: true } });
|
||||
}
|
||||
|
||||
socket.on('message', async (raw) => {
|
||||
try {
|
||||
const { type, payload } = JSON.parse(raw.toString());
|
||||
|
||||
if (type === 'typing') {
|
||||
const members = await getChatMemberIds(payload.chatId);
|
||||
broadcast(members.filter(id => id !== userId), {
|
||||
type: 'typing',
|
||||
payload: { chatId: payload.chatId, userId, typing: payload.typing }
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'read_messages') {
|
||||
await pool.query(
|
||||
`UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2`,
|
||||
[payload.chatId, userId]
|
||||
);
|
||||
const members = await getChatMemberIds(payload.chatId);
|
||||
broadcast(members.filter(id => id !== userId), {
|
||||
type: 'messages_read',
|
||||
payload: { chatId: payload.chatId, userId }
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'send_message') {
|
||||
// Check membership
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role, can_send_messages FROM chat_members WHERE chat_id = $1 AND user_id = $2',
|
||||
[payload.chatId, userId]
|
||||
);
|
||||
if (!member) return;
|
||||
|
||||
const { rows: [chat] } = await pool.query('SELECT type FROM chats WHERE id = $1', [payload.chatId]);
|
||||
// Channels: only owner/admin can send
|
||||
if (chat.type === 'channel' && !['owner', 'admin'].includes(member.role)) return;
|
||||
if (!member.can_send_messages && member.role === 'member') return;
|
||||
|
||||
const { rows: [msg] } = await pool.query(
|
||||
`INSERT INTO messages (chat_id, sender_id, content, type, reply_to_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[payload.chatId, userId, payload.content, payload.type || 'text', payload.replyToId || null]
|
||||
);
|
||||
|
||||
// Get sender info
|
||||
const { rows: [sender] } = await pool.query(
|
||||
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId]
|
||||
);
|
||||
|
||||
const fullMsg = { ...msg, sender };
|
||||
|
||||
const members = await getChatMemberIds(payload.chatId);
|
||||
broadcast(members, { type: 'new_message', payload: fullMsg });
|
||||
|
||||
// Push to offline users
|
||||
await sendPushToOfflineUsers(members, {
|
||||
title: sender.display_name,
|
||||
body: payload.content.substring(0, 100),
|
||||
chatId: payload.chatId
|
||||
});
|
||||
|
||||
// Update sender's last_read_at
|
||||
await pool.query(
|
||||
'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2',
|
||||
[payload.chatId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'delete_message') {
|
||||
const { rows: [msg] } = await pool.query(
|
||||
'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId]
|
||||
);
|
||||
if (!msg) return;
|
||||
|
||||
const { rows: [member] } = await pool.query(
|
||||
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [msg.chat_id, userId]
|
||||
);
|
||||
if (msg.sender_id !== userId && !['owner', 'admin'].includes(member?.role)) return;
|
||||
|
||||
await pool.query('UPDATE messages SET is_deleted = TRUE WHERE id = $1', [payload.messageId]);
|
||||
const members = await getChatMemberIds(msg.chat_id);
|
||||
broadcast(members, { type: 'message_deleted', payload: { messageId: payload.messageId, chatId: msg.chat_id } });
|
||||
}
|
||||
|
||||
if (type === 'edit_message') {
|
||||
const { rows: [msg] } = await pool.query(
|
||||
'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId]
|
||||
);
|
||||
if (!msg || msg.sender_id !== userId) return;
|
||||
|
||||
const { rows: [updated] } = await pool.query(
|
||||
'UPDATE messages SET content = $1, is_edited = TRUE, edited_at = NOW() WHERE id = $2 RETURNING *',
|
||||
[payload.content, payload.messageId]
|
||||
);
|
||||
const members = await getChatMemberIds(msg.chat_id);
|
||||
broadcast(members, { type: 'message_edited', payload: updated });
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('WS message error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', async () => {
|
||||
if (!userId) return;
|
||||
connections.get(userId)!.delete(socket);
|
||||
if (connections.get(userId)!.size === 0) {
|
||||
connections.delete(userId);
|
||||
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [userId]);
|
||||
|
||||
const { rows: memberChats } = await pool.query(
|
||||
'SELECT DISTINCT chat_id FROM chat_members WHERE user_id = $1', [userId]
|
||||
);
|
||||
for (const row of memberChats) {
|
||||
const ids = await getChatMemberIds(row.chat_id);
|
||||
broadcast(ids, { type: 'user_online', payload: { userId, online: false } });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user