fix: repair invite links, social features, messaging + Discord UI overhaul

Phase 1 - Feature Repair:
- Fix member kick/leave: add missing db.delete() call in servers.ts
- Stabilize invite codes: return existing code instead of regenerating
- Fix user search: use LIKE instead of exact match in social.ts
- Wire DM button on FriendsPage to create/navigate to DM channels
- Add cancel outgoing friend request (DELETE endpoint + frontend)
- Add accept/decline friend request actions with WS real-time events
- Fix replyToId persistence in message creation
- Hydrate reactions and replyTo in message queries
- Add joinByCode to API client and serverStore
- Add friend_request_received/accepted WebSocket events

Phase 2 - Discord UI Overhaul:
- Remove stray borders between layout columns
- Replace shadow-sm with shadow-header on content headers
- Replace all bg-gray-*/text-gray-* with Discord color tokens
- Ensure flat color contrast (#1E1F22, #2B2D31, #313338)

Testing:
- Set up vitest + @testing-library/react + jsdom
- Add 17 tests across InviteModal, JoinServer, FriendsPage (all passing)
- Fix vite resolve.extensions to prefer .tsx over stale .js files
This commit is contained in:
Jannis Braun
2026-02-18 05:34:45 +01:00
parent 4fd17084a5
commit 5ef502f2e3
82 changed files with 4906 additions and 552 deletions
+153
View File
@@ -5,6 +5,7 @@ import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isOwner, isAdmin } from '../utils/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
import type {
CreateServerRequest,
UpdateServerRequest,
@@ -15,6 +16,7 @@ import type {
Channel,
MemberWithUser,
ServerWithChannelsAndMembers,
Role,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -159,6 +161,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
.where(eq(schema.channels.serverId, id))
.all();
const roles = db.select()
.from(schema.roles)
.where(eq(schema.roles.serverId, id))
.orderBy(schema.roles.position)
.all();
const memberRows = db.select()
.from(schema.serverMembers)
.where(eq(schema.serverMembers.serverId, id))
@@ -171,10 +179,31 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
const userMap = new Map(users.map(u => [u.id, u]));
const memberRoleRows = db.select()
.from(schema.memberRoles)
.where(eq(schema.memberRoles.serverId, id))
.all();
const members: MemberWithUser[] = memberRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
const assignedRoleIds = memberRoleRows
.filter(mr => mr.userId === m.userId)
.map(mr => mr.roleId);
const memberRoles = roles
.filter(r => assignedRoleIds.includes(r.id))
.map(r => ({
id: r.id,
serverId: r.serverId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
createdAt: r.createdAt,
}));
return {
serverId: m.serverId,
userId: m.userId,
@@ -182,6 +211,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
roles: memberRoles,
};
})
.filter((m): m is MemberWithUser => m !== null);
@@ -190,6 +220,14 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
...rowToServer(server),
channels: channels.map(rowToChannel),
members,
roles: roles.map(r => ({
id: r.id,
serverId: r.serverId,
name: r.name,
color: r.color ?? '#b9bbbe',
position: r.position ?? 0,
createdAt: r.createdAt,
})),
};
return reply.code(200).send(result);
@@ -280,6 +318,11 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Only admins can generate invite codes', statusCode: 403 });
}
// Return existing invite code if one exists, otherwise generate a new one
if (server.inviteCode) {
return reply.code(200).send({ inviteCode: server.inviteCode });
}
const inviteCode = generateInviteCode();
db.update(schema.servers).set({ inviteCode }).where(eq(schema.servers.id, id)).run();
@@ -394,6 +437,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
nickname: m.nickname,
joinedAt: m.joinedAt,
user: sanitizeUser(user),
roles: [] as Role[], // TODO: Fetch member roles
};
})
.filter((m): m is MemberWithUser => m !== null);
@@ -470,6 +514,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
nickname: updatedMember.nickname,
joinedAt: updatedMember.joinedAt,
user: sanitizeUser(user),
roles: [] as Role[], // TODO: Fetch member roles
};
return reply.code(200).send(result);
@@ -523,6 +568,114 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
))
.run();
// Broadcast member_left event
connectionManager.sendToServer(id, {
type: 'member_left',
serverId: id,
userId: uid,
});
return reply.code(200).send({ success: true });
});
// Role Management
// POST /api/servers/:id/roles - Create a new role
app.post<{ Params: { id: string }; Body: { name: string; color?: string } }>('/api/servers/:id/roles', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { name, color } = request.body;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
}
const roleId = generateSnowflake();
db.insert(schema.roles).values({
id: roleId,
serverId: id,
name: name || 'new role',
color: color || '#b9bbbe',
position: 0,
createdAt: Date.now(),
}).run();
const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
return reply.code(201).send(role);
});
// PATCH /api/servers/:id/roles/:roleId - Update a role
app.patch<{ Params: { id: string; roleId: string }; Body: { name?: string; color?: string; position?: number } }>('/api/servers/:id/roles/:roleId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, roleId } = request.params;
const updates = request.body;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
}
db.update(schema.roles).set(updates).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run();
const updated = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get();
return reply.code(200).send(updated);
});
// DELETE /api/servers/:id/roles/:roleId - Delete a role
app.delete<{ Params: { id: string; roleId: string } }>('/api/servers/:id/roles/:roleId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, roleId } = request.params;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
}
db.delete(schema.roles).where(and(eq(schema.roles.id, roleId), eq(schema.roles.serverId, id))).run();
return reply.code(200).send({ success: true });
});
// POST /api/servers/:id/members/:uid/roles - Add role to member
app.post<{ Params: { id: string; uid: string }; Body: { roleId: string } }>('/api/servers/:id/members/:uid/roles', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid } = request.params;
const { roleId } = request.body;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
}
db.insert(schema.memberRoles).values({
serverId: id,
userId: uid,
roleId,
}).run();
return reply.code(200).send({ success: true });
});
// DELETE /api/servers/:id/members/:uid/roles/:roleId - Remove role from member
app.delete<{ Params: { id: string; uid: string; roleId: string } }>('/api/servers/:id/members/:uid/roles/:roleId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id, uid, roleId } = request.params;
const db = getDb();
if (!isAdmin(id, request.userId)) {
return reply.code(403).send({ error: 'Unauthorized', statusCode: 403 });
}
db.delete(schema.memberRoles).where(and(
eq(schema.memberRoles.serverId, id),
eq(schema.memberRoles.userId, uid),
eq(schema.memberRoles.roleId, roleId)
)).run();
return reply.code(200).send({ success: true });
});
}