Files
janichat/backend/src/routes/chats.ts
Ai dbf328b197 feat: remove global admin, open registration, clear DB seed
- 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>
2026-05-25 15:45:59 +03:00

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 };
});
}