From 1cabe9d04f6eaf88034eb92d75938c790e89de23 Mon Sep 17 00:00:00 2001 From: Ai Date: Fri, 22 May 2026 11:06:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20initial=20JaniChat=20messenger=20?= =?UTF-8?q?=E2=80=94=20PWA,=20WebSocket,=20admin=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + .gitignore | 6 + backend/Dockerfile | 15 + backend/package.json | 29 ++ backend/src/db.ts | 99 +++++++ backend/src/index.ts | 71 +++++ backend/src/routes/admin.ts | 101 +++++++ backend/src/routes/auth.ts | 76 +++++ backend/src/routes/chats.ts | 275 +++++++++++++++++++ backend/src/routes/messages.ts | 137 +++++++++ backend/src/routes/push.ts | 30 ++ backend/src/routes/users.ts | 55 ++++ backend/src/ws.ts | 202 ++++++++++++++ backend/tsconfig.json | 17 ++ deploy.sh | 17 ++ docker-compose.yml | 31 +++ frontend/Dockerfile | 14 + frontend/index.html | 19 ++ frontend/nginx.conf | 22 ++ frontend/package.json | 29 ++ frontend/postcss.config.js | 3 + frontend/public/manifest.json | 25 ++ frontend/public/sw.js | 58 ++++ frontend/src/App.tsx | 95 +++++++ frontend/src/api/client.ts | 25 ++ frontend/src/api/ws.ts | 70 +++++ frontend/src/components/Avatar.tsx | 34 +++ frontend/src/components/ChatHeader.tsx | 131 +++++++++ frontend/src/components/ChatInfoPanel.tsx | 186 +++++++++++++ frontend/src/components/ChatListItem.tsx | 53 ++++ frontend/src/components/MessageInput.tsx | 199 ++++++++++++++ frontend/src/components/MessageItem.tsx | 144 ++++++++++ frontend/src/components/MessageList.tsx | 180 ++++++++++++ frontend/src/components/NewChatModal.tsx | 227 +++++++++++++++ frontend/src/components/admin/AdminPanel.tsx | 197 +++++++++++++ frontend/src/hooks/usePushNotifications.ts | 46 ++++ frontend/src/index.css | 37 +++ frontend/src/main.tsx | 17 ++ frontend/src/pages/Login.tsx | 94 +++++++ frontend/src/pages/MainLayout.tsx | 206 ++++++++++++++ frontend/src/store/index.ts | 121 ++++++++ frontend/src/types.ts | 74 +++++ frontend/tailwind.config.js | 6 + frontend/tsconfig.json | 19 ++ frontend/vite.config.ts | 13 + nginx-janichat.conf | 58 ++++ 46 files changed, 3570 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/db.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/routes/auth.ts create mode 100644 backend/src/routes/chats.ts create mode 100644 backend/src/routes/messages.ts create mode 100644 backend/src/routes/push.ts create mode 100644 backend/src/routes/users.ts create mode 100644 backend/src/ws.ts create mode 100644 backend/tsconfig.json create mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/ws.ts create mode 100644 frontend/src/components/Avatar.tsx create mode 100644 frontend/src/components/ChatHeader.tsx create mode 100644 frontend/src/components/ChatInfoPanel.tsx create mode 100644 frontend/src/components/ChatListItem.tsx create mode 100644 frontend/src/components/MessageInput.tsx create mode 100644 frontend/src/components/MessageItem.tsx create mode 100644 frontend/src/components/MessageList.tsx create mode 100644 frontend/src/components/NewChatModal.tsx create mode 100644 frontend/src/components/admin/AdminPanel.tsx create mode 100644 frontend/src/hooks/usePushNotifications.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/MainLayout.tsx create mode 100644 frontend/src/store/index.ts create mode 100644 frontend/src/types.ts create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 nginx-janichat.conf diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f2b787 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://hotelsync:PASSWORD@hotelsync-postgres:5432/janichat +JWT_SECRET=your-jwt-secret-here +VAPID_PUBLIC_KEY=your-vapid-public-key +VAPID_PRIVATE_KEY=your-vapid-private-key +VAPID_EMAIL=admin@janichat.ru +ALLOWED_ORIGINS=https://janichat.ru,https://www.janichat.ru +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..356fd0c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.env.local +uploads/ +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..43c2b85 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY tsconfig.json ./ +COPY src ./src + +RUN npm run build + +EXPOSE 3000 + +CMD ["node", "dist/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..2b48c1a --- /dev/null +++ b/backend/package.json @@ -0,0 +1,29 @@ +{ + "name": "janichat-api", + "version": "1.0.0", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "@fastify/cors": "^9.0.1", + "@fastify/jwt": "^8.0.1", + "@fastify/multipart": "^8.3.0", + "@fastify/static": "^7.0.4", + "@fastify/websocket": "^8.3.1", + "bcryptjs": "^2.4.3", + "fastify": "^4.28.1", + "pg": "^8.12.0", + "web-push": "^3.6.7" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.6", + "@types/web-push": "^3.6.4", + "tsx": "^4.15.7", + "typescript": "^5.4.5" + } +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..5dda889 --- /dev/null +++ b/backend/src/db.ts @@ -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'); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e8b1047 --- /dev/null +++ b/backend/src/index.ts @@ -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' }); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..2b3ddd8 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -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), + }; + }); +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..47f1376 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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, + }; + }); +} diff --git a/backend/src/routes/chats.ts b/backend/src/routes/chats.ts new file mode 100644 index 0000000..2f1977a --- /dev/null +++ b/backend/src/routes/chats.ts @@ -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 }; + }); +} diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts new file mode 100644 index 0000000..1b36fb1 --- /dev/null +++ b/backend/src/routes/messages.ts @@ -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 }], + }; + }); +} diff --git a/backend/src/routes/push.ts b/backend/src/routes/push.ts new file mode 100644 index 0000000..fd875f4 --- /dev/null +++ b/backend/src/routes/push.ts @@ -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 }; + }); +} diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..edfd21a --- /dev/null +++ b/backend/src/routes/users.ts @@ -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, + }; + }); +} diff --git a/backend/src/ws.ts b/backend/src/ws.ts new file mode 100644 index 0000000..266afd6 --- /dev/null +++ b/backend/src/ws.ts @@ -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>(); + +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 { + 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 } }); + } + } + }); + }); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..6d45b1e --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..143399d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +echo "=== JaniChat Deploy ===" +cd /opt/janichat + +# Pull latest code +cd app +git pull origin main +cd .. + +# Rebuild and restart +docker compose build --no-cache +docker compose up -d + +echo "=== Done ===" +docker compose ps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2161e3e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + + janichat-api: + build: + context: ./backend + dockerfile: Dockerfile + image: janichat-api:latest + container_name: janichat-api + restart: unless-stopped + env_file: .env + volumes: + - uploads_data:/uploads + networks: + - hotelsync_hotelsync-net + + janichat-frontend: + build: + context: ./frontend + dockerfile: Dockerfile + image: janichat-frontend:latest + container_name: janichat-frontend + restart: unless-stopped + networks: + - hotelsync_hotelsync-net + +volumes: + uploads_data: + +networks: + hotelsync_hotelsync-net: + external: true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f44856e --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..87d78d8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + JaniChat + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..228f5f0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /sw.js { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..985bad4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "janichat-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.2", + "date-fns": "^3.6.0", + "lucide-react": "^0.383.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.3.1" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..e008c9c --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +}; diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..3506e8a --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "JaniChat", + "short_name": "JaniChat", + "description": "Корпоративный мессенджер", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#3b82f6", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["productivity", "business"] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..1c34251 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,58 @@ +const CACHE_NAME = 'janichat-v1'; +const STATIC_ASSETS = ['/', '/index.html']; + +self.addEventListener('install', (event) => { + self.skipWaiting(); + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS).catch(() => {})) + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) return; + event.respondWith( + fetch(event.request).catch(() => caches.match(event.request)) + ); +}); + +self.addEventListener('push', (event) => { + let data = { title: 'JaniChat', body: 'Новое сообщение', chatId: null }; + try { data = event.data.json(); } catch {} + + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + data: { chatId: data.chatId }, + vibrate: [200, 100, 200], + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const chatId = event.notification.data?.chatId; + const url = chatId ? `/?chat=${chatId}` : '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => { + for (const client of clients) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + client.postMessage({ type: 'open_chat', chatId }); + return client.focus(); + } + } + return self.clients.openWindow(url); + }) + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..937a5b9 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,95 @@ +import { useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import LoginPage from './pages/Login'; +import MainLayout from './pages/MainLayout'; +import { useStore } from './store'; +import { wsClient } from './api/ws'; +import api from './api/client'; + +function AuthGuard({ children }: { children: React.ReactNode }) { + const user = useStore(s => s.user); + return user ? <>{children} : ; +} + +export default function App() { + const { setUser, setChats, addMessage, updateMessage, removeMessage, setOnline, setTyping, setConnected, markRead } = useStore(); + + useEffect(() => { + // Restore session + const token = localStorage.getItem('jc_token'); + const userStr = localStorage.getItem('jc_user'); + if (token && userStr) { + try { + setUser(JSON.parse(userStr)); + connectWS(token); + loadChats(); + } catch { + localStorage.removeItem('jc_token'); + localStorage.removeItem('jc_user'); + } + } + }, []); + + async function loadChats() { + try { + const { data } = await api.get('/api/chats'); + setChats(data); + } catch {} + } + + function connectWS(token: string) { + wsClient.disconnect(); + wsClient.connect(token); + + wsClient.on('connected', () => setConnected(true)); + wsClient.on('disconnected', () => setConnected(false)); + + wsClient.on('new_message', (msg) => { + addMessage(msg); + }); + + wsClient.on('message_edited', (msg) => { + updateMessage(msg); + }); + + wsClient.on('message_deleted', ({ messageId, chatId }) => { + removeMessage(chatId, messageId); + }); + + wsClient.on('user_online', ({ userId, online }) => { + setOnline(userId, online); + }); + + wsClient.on('typing', ({ chatId, userId, typing }) => { + setTyping(chatId, userId, typing); + }); + + wsClient.on('messages_read', ({ chatId }) => { + markRead(chatId); + }); + + // Handle SW notification click + if ('serviceWorker' in navigator) { + navigator.serviceWorker.addEventListener('message', (event) => { + if (event.data?.type === 'open_chat') { + // Handled by MainLayout + } + }); + } + } + + return ( + + + { + localStorage.setItem('jc_token', token); + localStorage.setItem('jc_user', JSON.stringify(user)); + setUser(user); + connectWS(token); + loadChats(); + }} />} /> + } /> + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..b83874a --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || '', +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('jc_token'); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('jc_token'); + localStorage.removeItem('jc_user'); + window.location.href = '/login'; + } + return Promise.reject(err); + } +); + +export default api; diff --git a/frontend/src/api/ws.ts b/frontend/src/api/ws.ts new file mode 100644 index 0000000..a6d0cab --- /dev/null +++ b/frontend/src/api/ws.ts @@ -0,0 +1,70 @@ +type Handler = (payload: any) => void; + +class WsClient { + private ws: WebSocket | null = null; + private handlers = new Map(); + private reconnectTimer: ReturnType | null = null; + private token: string | null = null; + + connect(token: string) { + this.token = token; + const wsBase = import.meta.env.VITE_WS_URL || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`; + this.ws = new WebSocket(`${wsBase}/ws?token=${token}`); + + this.ws.onopen = () => { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.emit('connected', null); + }; + + this.ws.onmessage = (event) => { + try { + const { type, payload } = JSON.parse(event.data); + const hs = this.handlers.get(type) || []; + hs.forEach(h => h(payload)); + } catch {} + }; + + this.ws.onclose = () => { + this.emit('disconnected', null); + this.reconnectTimer = setTimeout(() => { + if (this.token) this.connect(this.token); + }, 3000); + }; + + this.ws.onerror = () => { + this.ws?.close(); + }; + } + + on(type: string, handler: Handler) { + if (!this.handlers.has(type)) this.handlers.set(type, []); + this.handlers.get(type)!.push(handler); + return () => { + const hs = this.handlers.get(type) || []; + this.handlers.set(type, hs.filter(h => h !== handler)); + }; + } + + private emit(type: string, payload: any) { + const hs = this.handlers.get(type) || []; + hs.forEach(h => h(payload)); + } + + send(type: string, payload: any) { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type, payload })); + } + } + + disconnect() { + this.token = null; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.ws = null; + } +} + +export const wsClient = new WsClient(); diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx new file mode 100644 index 0000000..c8be403 --- /dev/null +++ b/frontend/src/components/Avatar.tsx @@ -0,0 +1,34 @@ +interface Props { + name: string; + color: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + online?: boolean; + className?: string; +} + +const sizes = { + sm: 'w-8 h-8 text-xs', + md: 'w-10 h-10 text-sm', + lg: 'w-12 h-12 text-base', + xl: 'w-16 h-16 text-xl', +}; + +export default function Avatar({ name, color, size = 'md', online, className = '' }: Props) { + const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase(); + + return ( +
+
+ {initials} +
+ {online !== undefined && ( +
+ )} +
+ ); +} diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx new file mode 100644 index 0000000..113c04d --- /dev/null +++ b/frontend/src/components/ChatHeader.tsx @@ -0,0 +1,131 @@ +import { ArrowLeft, MoreVertical, Users, Info, LogOut, Trash2, Settings } from 'lucide-react'; +import { useState } from 'react'; +import { Chat } from '../types'; +import Avatar from './Avatar'; +import { useStore } from '../store'; +import { wsClient } from '../api/ws'; +import api from '../api/client'; + +interface Props { + chat: Chat; + onBack: () => void; + onRefresh: () => void; + onShowInfo: () => void; +} + +export default function ChatHeader({ chat, onBack, onRefresh, onShowInfo }: Props) { + const { user, typingUsers, onlineUsers, setActiveChat, setChats, chats } = useStore(); + const [menuOpen, setMenuOpen] = useState(false); + + const typing = typingUsers[chat.id] || []; + const isPrivate = chat.type === 'private'; + const isOnline = isPrivate && chat.privateUserId && onlineUsers.has(chat.privateUserId); + + let subtitle = ''; + if (typing.length > 0) { + subtitle = 'печатает...'; + } else if (isPrivate) { + subtitle = isOnline ? 'в сети' : 'не в сети'; + } else { + subtitle = `${chat.memberCount} участников`; + } + + const isOwnerOrAdmin = chat.myRole === 'owner' || chat.myRole === 'admin' || user?.isAdmin; + + async function handleLeave() { + if (!confirm('Выйти из чата?')) return; + await api.delete(`/api/chats/${chat.id}/leave`); + setActiveChat(null); + setChats(chats.filter(c => c.id !== chat.id)); + setMenuOpen(false); + } + + async function handleDelete() { + if (!confirm('Удалить чат? Это действие нельзя отменить.')) return; + await api.delete(`/api/chats/${chat.id}`); + setActiveChat(null); + setChats(chats.filter(c => c.id !== chat.id)); + setMenuOpen(false); + } + + const typeLabel = chat.type === 'channel' ? 'Канал' : chat.type === 'group' ? 'Группа' : ''; + + return ( +
+ {/* Back button (mobile) */} + + + {/* Avatar */} +
+ +
+ + {/* Title & status */} +
+
+ {typeLabel && {typeLabel}} + {chat.title} +
+
0 ? 'text-blue-500' : isOnline ? 'text-green-500' : 'text-gray-400'}`}> + {subtitle} +
+
+ + {/* Menu */} +
+ + {menuOpen && ( + <> +
setMenuOpen(false)} /> +
+ + {chat.type !== 'private' && isOwnerOrAdmin && ( + + )} + {chat.type !== 'private' && ( + + )} + {isOwnerOrAdmin && ( + + )} +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/ChatInfoPanel.tsx b/frontend/src/components/ChatInfoPanel.tsx new file mode 100644 index 0000000..8fcf3f1 --- /dev/null +++ b/frontend/src/components/ChatInfoPanel.tsx @@ -0,0 +1,186 @@ +import { X, UserPlus, Crown, Shield, User, Trash2, Ban } from 'lucide-react'; +import { useState, useEffect } from 'react'; +import { Chat, ChatMember } from '../types'; +import Avatar from './Avatar'; +import { useStore } from '../store'; +import api from '../api/client'; + +interface Props { + chat: Chat; + onClose: () => void; + onRefresh: () => void; +} + +export default function ChatInfoPanel({ chat, onClose, onRefresh }: Props) { + const { user: me, onlineUsers } = useStore(); + const [members, setMembers] = useState(chat.members || []); + const [allUsers, setAllUsers] = useState([]); + const [addMode, setAddMode] = useState(false); + const [search, setSearch] = useState(''); + const [title, setTitle] = useState(chat.title || ''); + const [editing, setEditing] = useState(false); + + const isOwnerOrAdmin = chat.myRole === 'owner' || chat.myRole === 'admin' || me?.isAdmin; + + useEffect(() => { + if (addMode) { + api.get('/api/users').then(r => setAllUsers(r.data)); + } + }, [addMode]); + + async function saveTitle() { + await api.put(`/api/chats/${chat.id}`, { title }); + setEditing(false); + onRefresh(); + } + + async function addMember(userId: string) { + await api.post(`/api/chats/${chat.id}/members`, { memberId: userId }); + const { data } = await api.get(`/api/chats/${chat.id}`); + setMembers(data.members); + setAddMode(false); + } + + async function removeMember(userId: string) { + if (!confirm('Удалить участника?')) return; + await api.delete(`/api/chats/${chat.id}/members/${userId}`); + setMembers(m => m.filter(x => x.id !== userId)); + } + + async function toggleRole(m: ChatMember) { + const newRole = m.role === 'admin' ? 'member' : 'admin'; + await api.put(`/api/chats/${chat.id}/members/${m.id}`, { role: newRole }); + setMembers(mems => mems.map(x => x.id === m.id ? { ...x, role: newRole } : x)); + } + + async function toggleSend(m: ChatMember) { + await api.put(`/api/chats/${chat.id}/members/${m.id}`, { canSendMessages: !m.canSendMessages }); + setMembers(mems => mems.map(x => x.id === m.id ? { ...x, canSendMessages: !x.canSendMessages } : x)); + } + + const memberIds = new Set(members.map(m => m.id)); + const filtered = allUsers.filter(u => + !memberIds.has(u.id) && + (u.displayName.toLowerCase().includes(search.toLowerCase()) || + u.username.toLowerCase().includes(search.toLowerCase())) + ); + + const roleIcon = (role: string) => + role === 'owner' ? + : role === 'admin' ? + : ; + + return ( +
+
+

+ {chat.type === 'channel' ? 'Канал' : chat.type === 'group' ? 'Группа' : 'Контакт'} +

+ +
+ +
+ {/* Avatar & Title */} +
+ +
+ {editing && chat.type !== 'private' ? ( +
+ setTitle(e.target.value)} + className="flex-1 px-2 py-1 border rounded-lg text-sm" + /> + + +
+ ) : ( +
+
isOwnerOrAdmin && chat.type !== 'private' && setEditing(true)} + > + {chat.title} +
+ {chat.description &&

{chat.description}

} +
+ )} +
+
+ + {/* Members */} + {chat.type !== 'private' && ( +
+
+ + Участники ({members.length}) + + {isOwnerOrAdmin && ( + + )} +
+ + {addMode && ( +
+ setSearch(e.target.value)} + placeholder="Поиск пользователей..." + className="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2" + /> +
+ {filtered.map(u => ( + + ))} + {filtered.length === 0 &&

Никого не найдено

} +
+
+ )} + +
+ {members.map(m => ( +
+ +
+
+ {m.displayName} + {roleIcon(m.role)} + {!m.canSendMessages && } +
+
@{m.username}
+
+ {isOwnerOrAdmin && m.id !== me?.id && m.role !== 'owner' && ( +
+ + {chat.type === 'channel' && ( + + )} + +
+ )} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ChatListItem.tsx b/frontend/src/components/ChatListItem.tsx new file mode 100644 index 0000000..97f01c2 --- /dev/null +++ b/frontend/src/components/ChatListItem.tsx @@ -0,0 +1,53 @@ +import { formatDistanceToNow } from 'date-fns'; +import { ru } from 'date-fns/locale'; +import Avatar from './Avatar'; +import { Chat } from '../types'; + +interface Props { + chat: Chat; + active: boolean; + online?: boolean; + onClick: () => void; +} + +export default function ChatListItem({ chat, active, online, onClick }: Props) { + const time = chat.lastMessageAt + ? formatDistanceToNow(new Date(chat.lastMessageAt), { addSuffix: false, locale: ru }) + : ''; + + const icon = chat.type === 'channel' ? '📢' : chat.type === 'group' ? '👥' : null; + + return ( + + ); +} diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx new file mode 100644 index 0000000..e8a227b --- /dev/null +++ b/frontend/src/components/MessageInput.tsx @@ -0,0 +1,199 @@ +import { useState, useRef, useEffect } from 'react'; +import { Send, Paperclip, X, Smile } from 'lucide-react'; +import { wsClient } from '../api/ws'; +import api from '../api/client'; +import { Message } from '../types'; +import { useStore } from '../store'; + +interface Props { + chatId: string; + replyTo: Message | null; + editMsg: Message | null; + onCancelReply: () => void; + onCancelEdit: () => void; + onEditDone: (msg: Message) => void; +} + +const EMOJIS = ['😀','😂','😍','🥰','😎','👍','❤️','🔥','✅','👋','🙏','💪','🎉','💯','😊','🤔','😅','🙌','💬','📌']; + +export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply, onCancelEdit, onEditDone }: Props) { + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [showEmoji, setShowEmoji] = useState(false); + const [uploading, setUploading] = useState(false); + const textareaRef = useRef(null); + const fileRef = useRef(null); + const typingTimeout = useRef | null>(null); + const { addMessage, user } = useStore(); + + useEffect(() => { + if (editMsg) { + setText(editMsg.content); + textareaRef.current?.focus(); + } + }, [editMsg]); + + useEffect(() => { + if (replyTo) textareaRef.current?.focus(); + }, [replyTo]); + + function handleChange(e: React.ChangeEvent) { + setText(e.target.value); + autoResize(); + + // Typing indicator + wsClient.send('typing', { chatId, typing: true }); + if (typingTimeout.current) clearTimeout(typingTimeout.current); + typingTimeout.current = setTimeout(() => { + wsClient.send('typing', { chatId, typing: false }); + }, 2000); + } + + function autoResize() { + const el = textareaRef.current; + if (el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 120) + 'px'; + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + if (e.key === 'Escape') { + onCancelReply(); + onCancelEdit(); + } + } + + async function handleSend() { + const content = text.trim(); + if (!content || sending) return; + + setSending(true); + setText(''); + if (textareaRef.current) textareaRef.current.style.height = 'auto'; + wsClient.send('typing', { chatId, typing: false }); + + if (editMsg) { + wsClient.send('edit_message', { messageId: editMsg.id, content }); + onEditDone({ ...editMsg, content, isEdited: true }); + } else { + wsClient.send('send_message', { + chatId, + content, + type: 'text', + replyToId: replyTo?.id || null, + }); + onCancelReply(); + } + + setSending(false); + } + + async function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + + setUploading(true); + const form = new FormData(); + form.append('file', file); + try { + const { data } = await api.post(`/api/messages/chat/${chatId}/upload`, form, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + addMessage(data); + } catch { + alert('Ошибка загрузки файла'); + } finally { + setUploading(false); + } + } + + function insertEmoji(emoji: string) { + setText(t => t + emoji); + setShowEmoji(false); + textareaRef.current?.focus(); + } + + const isEdit = !!editMsg; + const placeholder = isEdit ? 'Редактирование...' : replyTo ? 'Ответить...' : 'Сообщение...'; + + return ( +
+ {/* Reply/Edit preview */} + {(replyTo || editMsg) && ( +
+
+
+ {isEdit ? 'Редактирование' : `Ответ: ${replyTo?.sender?.displayName}`} +
+
+ {isEdit ? editMsg?.content : replyTo?.content} +
+
+ +
+ )} + +
+ {/* Emoji picker */} +
+ + {showEmoji && ( +
+ {EMOJIS.map(e => ( + + ))} +
+ )} +
+ + {/* Textarea */} +