feat: avatar upload for profile/chats, fix session persistence on refresh

- Profile modal: upload/remove user avatar, edit name/bio/phone, change password
- Chat info panel: upload/remove chat photo (owner/admin only)
- Avatar component: render photo when available, fallback to initials
- Session fix: initialize Zustand store from localStorage to prevent redirect on refresh
- Backend: avatar upload endpoints for users and chats, migration adds avatar columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ai
2026-05-22 11:55:11 +03:00
parent 01331d0263
commit 3f54dbf375
9 changed files with 421 additions and 45 deletions

View File

@@ -86,6 +86,12 @@ export async function initDB() {
CREATE INDEX IF NOT EXISTS idx_chat_members_user_id ON chat_members(user_id);
`);
// Migrations: add avatar columns if not exist
await pool.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL;
ALTER TABLE chats ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL;
`);
// Seed admin if no users
const { rows } = await pool.query('SELECT COUNT(*) FROM users');
if (parseInt(rows[0].count) === 0) {

View File

@@ -1,7 +1,18 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { pool } from '../db.js';
function userDto(u: any) {
return {
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, avatar: u.avatar || null,
bio: u.bio, phone: u.phone, isAdmin: u.is_admin,
};
}
export default async function authRoutes(app: FastifyInstance) {
app.post('/login', async (req, reply) => {
const { username, password } = req.body as { username: string; password: string };
@@ -18,34 +29,15 @@ export default async function authRoutes(app: FastifyInstance) {
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,
}
};
return { token, user: userDto(user) };
});
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]
'SELECT id, username, display_name, avatar_color, avatar, 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,
};
return { ...userDto(user), lastSeen: user.last_seen };
});
app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => {
@@ -66,11 +58,35 @@ export default async function authRoutes(app: FastifyInstance) {
);
const { rows: [user] } = await pool.query(
'SELECT id, username, display_name, avatar_color, bio, phone, is_admin FROM users WHERE id = $1', [id]
'SELECT id, username, display_name, avatar_color, avatar, 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,
};
return userDto(user);
});
// Upload profile avatar
app.put('/avatar', { preHandler: [app.authenticate] }, async (req, reply) => {
const { id: userId } = req.user as { id: string };
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 = `u_${userId}_${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 users SET avatar = $1 WHERE id = $2', [url, userId]);
return { avatar: url };
});
// Delete profile avatar
app.delete('/avatar', { preHandler: [app.authenticate] }, async (req) => {
const { id: userId } = req.user as { id: string };
await pool.query('UPDATE users SET avatar = NULL WHERE id = $1', [userId]);
return { ok: true };
});
}

View File

@@ -1,4 +1,6 @@
import { FastifyInstance } from 'fastify';
import path from 'path';
import fs from 'fs';
import { pool } from '../db.js';
import { connections } from '../ws.js';
@@ -13,7 +15,7 @@ export default async function chatRoutes(app: FastifyInstance) {
const { rows } = await pool.query(`
SELECT
c.id, c.type, c.title, c.description, c.avatar_color, c.is_public, c.created_at,
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,
(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,
@@ -30,6 +32,11 @@ export default async function chatRoutes(app: FastifyInstance) {
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
@@ -46,6 +53,7 @@ export default async function chatRoutes(app: FastifyInstance) {
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,
@@ -72,7 +80,7 @@ export default async function chatRoutes(app: FastifyInstance) {
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
SELECT u.id, u.username, u.display_name, u.avatar_color, u.avatar, 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
@@ -87,6 +95,7 @@ export default async function chatRoutes(app: FastifyInstance) {
title: chat.title,
description: chat.description,
avatarColor: chat.avatar_color,
avatar: chat.avatar || null,
isPublic: chat.is_public,
myRole: member.role,
canSendMessages: member.can_send_messages,
@@ -94,7 +103,8 @@ export default async function chatRoutes(app: FastifyInstance) {
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),
avatarColor: m.avatar_color, avatar: m.avatar || null,
role: m.role, online: onlineSet.has(m.id),
lastSeen: m.last_seen, canSendMessages: m.can_send_messages,
})),
};
@@ -169,6 +179,48 @@ export default async function chatRoutes(app: FastifyInstance) {
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 };