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:
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user