feat: initial JaniChat messenger — PWA, WebSocket, admin panel
This commit is contained in:
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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user