- All users are equal — no is_admin superuser - Removed AdminPanel, Shield button from sidebar - isOwnerOrAdmin checks only by chat role (owner/admin) - Removed auto-seed of admin user on startup - DB already cleared on server Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
import { FastifyInstance } from 'fastify';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
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.avatar, c.is_public, c.created_at,
|
|
cm.role, cm.last_read_at, cm.is_pinned, cm.is_muted,
|
|
(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.avatar 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_avatar,
|
|
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 cm.is_pinned DESC, COALESCE(
|
|
(SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1),
|
|
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,
|
|
avatar: r.type === 'private' ? (r.private_avatar || null) : (r.avatar || null),
|
|
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,
|
|
isPinned: r.is_pinned,
|
|
isMuted: r.is_muted,
|
|
}));
|
|
});
|
|
|
|
// 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.avatar, u.position, 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());
|
|
|
|
// For private chats, resolve title/avatar/color from the other member
|
|
const other = chat.type === 'private' ? members.find((m: any) => m.id !== userId) : null;
|
|
|
|
return {
|
|
id: chat.id,
|
|
type: chat.type,
|
|
title: chat.type === 'private' ? (other?.display_name || '') : chat.title,
|
|
description: chat.description,
|
|
avatarColor: chat.type === 'private' ? (other?.avatar_color || chat.avatar_color) : chat.avatar_color,
|
|
avatar: chat.type === 'private' ? (other?.avatar || null) : (chat.avatar || null),
|
|
isPublic: chat.is_public,
|
|
myRole: member.role,
|
|
canSendMessages: member.can_send_messages,
|
|
canAddMembers: member.can_add_members,
|
|
privateUserId: chat.type === 'private' ? (other?.id || null) : null,
|
|
createdAt: chat.created_at,
|
|
members: members.map(m => ({
|
|
id: m.id, username: m.username, displayName: m.display_name,
|
|
avatarColor: m.avatar_color, avatar: m.avatar || null,
|
|
position: m.position || null,
|
|
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 });
|
|
});
|
|
|
|
// Upload chat avatar
|
|
app.put('/:id/avatar', 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 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' });
|
|
}
|
|
|
|
const data = await req.file();
|
|
if (!data) return reply.status(400).send({ error: 'No file' });
|
|
if (!data.mimetype.startsWith('image/')) return reply.status(400).send({ error: 'Only images allowed' });
|
|
|
|
const ext = path.extname(data.filename) || '.jpg';
|
|
const filename = `c_${id}_${Date.now()}${ext}`;
|
|
const dir = '/uploads/avatars';
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const buffer = await data.toBuffer();
|
|
fs.writeFileSync(path.join(dir, filename), buffer);
|
|
|
|
const url = `/uploads/avatars/${filename}`;
|
|
await pool.query('UPDATE chats SET avatar = $1 WHERE id = $2', [url, id]);
|
|
return { avatar: url };
|
|
});
|
|
|
|
// Delete chat avatar
|
|
app.delete('/:id/avatar', 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 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 avatar = NULL WHERE id = $1', [id]);
|
|
return { ok: true };
|
|
});
|
|
|
|
// 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 };
|
|
});
|
|
|
|
// Pin/unpin chat for current user
|
|
app.put('/:id/pin', async (req) => {
|
|
const { id: userId } = req.user as { id: string };
|
|
const { id } = req.params as { id: string };
|
|
const { pinned } = req.body as { pinned: boolean };
|
|
await pool.query('UPDATE chat_members SET is_pinned = $1 WHERE chat_id = $2 AND user_id = $3', [pinned, id, userId]);
|
|
return { ok: true };
|
|
});
|
|
|
|
// Mute/unmute chat for current user
|
|
app.put('/:id/mute', async (req) => {
|
|
const { id: userId } = req.user as { id: string };
|
|
const { id } = req.params as { id: string };
|
|
const { muted } = req.body as { muted: boolean };
|
|
await pool.query('UPDATE chat_members SET is_muted = $1 WHERE chat_id = $2 AND user_id = $3', [muted, id, userId]);
|
|
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 } = req.user as { id: string };
|
|
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') {
|
|
return reply.status(403).send({ error: 'No permission' });
|
|
}
|
|
|
|
await pool.query('DELETE FROM chats WHERE id = $1', [id]);
|
|
return { ok: true };
|
|
});
|
|
}
|