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
+4 -1
View File
@@ -41,7 +41,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
const jwt = await token.toJwt();
const response: LiveKitTokenResponse = { token: jwt };
const response: LiveKitTokenResponse = {
token: jwt,
url: config.livekit.url
};
return reply.code(200).send(response);
});
}
+141 -6
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq, lt, desc, inArray } from 'drizzle-orm';
import { eq, desc, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
@@ -11,7 +11,7 @@ import type {
PaginatedQuery,
User,
MessageWithUser,
Attachment,
Reaction,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -26,15 +26,123 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
};
}
/**
* Fetch reactions for a set of message IDs.
* Returns a map from messageId to Reaction[].
*/
function fetchReactionsForMessages(messageIds: string[]): Map<string, Reaction[]> {
if (messageIds.length === 0) return new Map();
const db = getDb();
const reactionRows = db.select()
.from(schema.reactions)
.where(inArray(schema.reactions.messageId, messageIds))
.all();
// Batch fetch users for reactions
const reactionUserIds = [...new Set(reactionRows.map(r => r.userId))];
const reactionUsers = reactionUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, reactionUserIds)).all()
: [];
const reactionUserMap = new Map(reactionUsers.map(u => [u.id, u]));
const map = new Map<string, Reaction[]>();
for (const r of reactionRows) {
const user = reactionUserMap.get(r.userId);
const reaction: Reaction = {
id: r.id,
messageId: r.messageId,
userId: r.userId,
emoji: r.emoji,
createdAt: r.createdAt,
user: user ? sanitizeUser(user) : undefined,
};
if (!map.has(r.messageId)) {
map.set(r.messageId, []);
}
map.get(r.messageId)!.push(reaction);
}
return map;
}
/**
* Fetch reply-to messages for a set of message IDs.
* Returns a map from messageId to its reply parent MessageWithUser.
*/
function fetchReplyToMessages(messages: (typeof schema.messages.$inferSelect)[]): Map<string, MessageWithUser> {
const replyToIds = messages
.map(m => m.replyToId)
.filter((id): id is string => id !== null && id !== undefined);
if (replyToIds.length === 0) return new Map();
const db = getDb();
const uniqueReplyIds = [...new Set(replyToIds)];
const replyMessages = db.select()
.from(schema.messages)
.where(inArray(schema.messages.id, uniqueReplyIds))
.all();
// Fetch users for reply messages
const replyUserIds = [...new Set(replyMessages.map(m => m.userId))];
const replyUsers = replyUserIds.length > 0
? db.select().from(schema.users).where(inArray(schema.users.id, replyUserIds)).all()
: [];
const replyUserMap = new Map(replyUsers.map(u => [u.id, u]));
// Fetch attachments for reply messages
const replyMsgIds = replyMessages.map(m => m.id);
const replyAttachments = replyMsgIds.length > 0
? db.select().from(schema.attachments).where(inArray(schema.attachments.messageId, replyMsgIds)).all()
: [];
const replyAttMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
for (const att of replyAttachments) {
const mid = att.messageId ?? '';
if (!replyAttMap.has(mid)) replyAttMap.set(mid, []);
replyAttMap.get(mid)!.push(att);
}
const map = new Map<string, MessageWithUser>();
for (const rm of replyMessages) {
const user = replyUserMap.get(rm.userId);
if (!user) continue;
const atts = replyAttMap.get(rm.id) ?? [];
map.set(rm.id, {
id: rm.id,
channelId: rm.channelId,
userId: rm.userId,
replyToId: rm.replyToId,
content: rm.content,
editedAt: rm.editedAt,
createdAt: rm.createdAt,
user: sanitizeUser(user),
attachments: atts.map(a => ({
id: a.id,
messageId: a.messageId ?? rm.id,
filename: a.filename,
originalName: a.originalName,
mimetype: a.mimetype,
size: a.size,
createdAt: a.createdAt,
})),
reactions: [],
replyTo: null,
});
}
return map;
}
function buildMessageWithUser(
message: typeof schema.messages.$inferSelect,
user: typeof schema.users.$inferSelect,
attachmentRows: (typeof schema.attachments.$inferSelect)[],
reactions: Reaction[] = [],
replyTo: MessageWithUser | null = null,
): MessageWithUser {
return {
id: message.id,
channelId: message.channelId,
userId: message.userId,
replyToId: message.replyToId,
content: message.content,
editedAt: message.editedAt,
createdAt: message.createdAt,
@@ -48,6 +156,8 @@ function buildMessageWithUser(
size: a.size,
createdAt: a.createdAt,
})),
reactions,
replyTo,
};
}
@@ -118,11 +228,19 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
attachmentMap.get(mid)!.push(att);
}
// Batch fetch reactions for all messages
const reactionsMap = fetchReactionsForMessages(messageIds);
// Batch fetch reply-to messages
const replyToMap = fetchReplyToMessages(messageRows);
const messages: MessageWithUser[] = messageRows
.map(m => {
const user = userMap.get(m.userId);
if (!user) return null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? []);
const reactions = reactionsMap.get(m.id) ?? [];
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [], reactions, replyTo);
})
.filter((m): m is MessageWithUser => m !== null);
@@ -134,7 +252,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds } = request.body;
const { content, attachments: attachmentIds, replyToId } = request.body;
const serverId = getChannelServerId(id);
if (!serverId) {
@@ -158,6 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
id: messageId,
channelId: id,
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
createdAt: now,
}).run();
@@ -187,7 +306,14 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(500).send({ error: 'Failed to create message', statusCode: 500 });
}
const messageWithUser = buildMessageWithUser(message, user, attachmentRows);
// Hydrate the reply-to message if present
let replyTo: MessageWithUser | null = null;
if (message.replyToId) {
const replyToMap = fetchReplyToMessages([message]);
replyTo = replyToMap.get(message.replyToId) ?? null;
}
const messageWithUser = buildMessageWithUser(message, user, attachmentRows, [], replyTo);
// Broadcast via WebSocket
connectionManager.sendToServer(serverId, {
@@ -240,7 +366,16 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
.where(eq(schema.attachments.messageId, id))
.all();
const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows);
// Hydrate reactions and reply-to
const reactionsMap = fetchReactionsForMessages([id]);
const reactions = reactionsMap.get(id) ?? [];
let replyTo: MessageWithUser | null = null;
if (updatedMessage.replyToId) {
const replyToMap = fetchReplyToMessages([updatedMessage]);
replyTo = replyToMap.get(updatedMessage.replyToId) ?? null;
}
const messageWithUser = buildMessageWithUser(updatedMessage, user, attachmentRows, reactions, replyTo);
// Broadcast edit
const serverId = getChannelServerId(message.channelId);
+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 });
});
}
+313
View File
@@ -0,0 +1,313 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, or, ne, like } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { connectionManager } from '../ws/handler.js';
import type {
User,
Friend,
FriendRequest,
SendFriendRequest,
UpdateFriendRequest,
} from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
return {
id: row.id,
username: row.username,
displayName: row.displayName,
avatar: row.avatar,
status: (row.status ?? 'offline') as User['status'],
customStatus: row.customStatus,
createdAt: row.createdAt,
};
}
export async function socialRoutes(app: FastifyInstance): Promise<void> {
// GET /api/social/friends - List all friends
app.get('/api/social/friends', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
// Get all friends where current user is either userId or friendId
const friendRows = db.select()
.from(schema.friends)
.where(or(
eq(schema.friends.userId, request.userId),
eq(schema.friends.friendId, request.userId)
))
.all();
if (friendRows.length === 0) {
return reply.code(200).send([]);
}
// Get the IDs of the actual friends (not the current user)
const friendIds = friendRows.map(f => f.userId === request.userId ? f.friendId : f.userId);
const friendUsers = db.select()
.from(schema.users)
.where(or(...friendIds.map(id => eq(schema.users.id, id))))
.all();
const friends: Friend[] = friendUsers.map(u => {
const relationship = friendRows.find(f => f.userId === u.id || f.friendId === u.id);
return {
...sanitizeUser(u),
addedAt: relationship?.createdAt ?? Date.now(),
};
});
return reply.code(200).send(friends);
});
// GET /api/social/requests - List pending friend requests
app.get('/api/social/requests', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
const requests = db.select()
.from(schema.friendRequests)
.where(and(
or(
eq(schema.friendRequests.fromId, request.userId),
eq(schema.friendRequests.toId, request.userId)
),
eq(schema.friendRequests.status, 'pending')
))
.all();
if (requests.length === 0) {
return reply.code(200).send([]);
}
// Enhance with user data
const userIds = requests.map(r => r.fromId === request.userId ? r.toId : r.fromId);
const users = db.select()
.from(schema.users)
.where(or(...userIds.map(id => eq(schema.users.id, id))))
.all();
const userMap = new Map(users.map(u => [u.id, u]));
const result: FriendRequest[] = requests.map(r => {
const otherId = r.fromId === request.userId ? r.toId : r.fromId;
const otherUser = userMap.get(otherId);
return {
id: r.id,
fromId: r.fromId,
toId: r.toId,
status: (r.status ?? 'pending') as any,
createdAt: r.createdAt,
user: otherUser ? sanitizeUser(otherUser) : undefined,
};
});
return reply.code(200).send(result);
});
// POST /api/social/requests - Send a friend request
app.post<{ Body: SendFriendRequest }>('/api/social/requests', {
preHandler: authenticate,
}, async (request, reply) => {
const { username } = request.body;
const db = getDb();
if (!username) {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
}
// Find the target user
const targetUser = db.select().from(schema.users).where(eq(schema.users.username, username)).get();
if (!targetUser) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (targetUser.id === request.userId) {
return reply.code(400).send({ error: 'You cannot add yourself as a friend', statusCode: 400 });
}
// Check if already friends
const existingFriend = db.select().from(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUser.id)),
and(eq(schema.friends.userId, targetUser.id), eq(schema.friends.friendId, request.userId))
)).get();
if (existingFriend) {
return reply.code(400).send({ error: 'You are already friends with this user', statusCode: 400 });
}
// Check for existing pending request
const existingRequest = db.select().from(schema.friendRequests).where(and(
or(
and(eq(schema.friendRequests.fromId, request.userId), eq(schema.friendRequests.toId, targetUser.id)),
and(eq(schema.friendRequests.fromId, targetUser.id), eq(schema.friendRequests.toId, request.userId))
),
eq(schema.friendRequests.status, 'pending')
)).get();
if (existingRequest) {
return reply.code(400).send({ error: 'A friend request is already pending', statusCode: 400 });
}
// Create the request
const id = generateSnowflake();
const now = Date.now();
db.insert(schema.friendRequests).values({
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
}).run();
// Get the sender user for the WS event
const senderUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
// Broadcast friend_request_received to the target user
const friendRequestPayload: FriendRequest = {
id,
fromId: request.userId,
toId: targetUser.id,
status: 'pending',
createdAt: now,
user: senderUser ? sanitizeUser(senderUser) : undefined,
};
connectionManager.sendToUser(targetUser.id, {
type: 'friend_request_received',
request: friendRequestPayload,
});
return reply.code(201).send({ success: true });
});
// PATCH /api/social/requests/:id - Accept/Decline a friend request
app.patch<{ Params: { id: string }; Body: UpdateFriendRequest }>('/api/social/requests/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const { status } = request.body;
const db = getDb();
if (!['accepted', 'declined'].includes(status)) {
return reply.code(400).send({ error: 'Invalid status', statusCode: 400 });
}
const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get();
if (!friendRequest) {
return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 });
}
if (friendRequest.toId !== request.userId) {
return reply.code(403).send({ error: 'You can only manage requests sent to you', statusCode: 403 });
}
if (status === 'accepted') {
// Add to friends table
const now = Date.now();
db.insert(schema.friends).values({
userId: friendRequest.fromId,
friendId: friendRequest.toId,
createdAt: now,
}).run();
// Get the accepting user's data for the WS event
const acceptingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (acceptingUser) {
const friend: Friend = {
...sanitizeUser(acceptingUser),
addedAt: now,
};
connectionManager.sendToUser(friendRequest.fromId, {
type: 'friend_request_accepted',
friend,
requestId: id,
});
}
}
// Update request status
db.update(schema.friendRequests)
.set({ status })
.where(eq(schema.friendRequests.id, id))
.run();
return reply.code(200).send({ success: true });
});
// DELETE /api/social/requests/:id - Cancel an outgoing friend request
app.delete<{ Params: { id: string } }>('/api/social/requests/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
const friendRequest = db.select().from(schema.friendRequests).where(eq(schema.friendRequests.id, id)).get();
if (!friendRequest) {
return reply.code(404).send({ error: 'Friend request not found', statusCode: 404 });
}
// Only the sender can cancel an outgoing request
if (friendRequest.fromId !== request.userId) {
return reply.code(403).send({ error: 'You can only cancel requests you sent', statusCode: 403 });
}
if (friendRequest.status !== 'pending') {
return reply.code(400).send({ error: 'Can only cancel pending requests', statusCode: 400 });
}
db.delete(schema.friendRequests)
.where(eq(schema.friendRequests.id, id))
.run();
return reply.code(200).send({ success: true });
});
// DELETE /api/social/friends/:id - Remove a friend
app.delete<{ Params: { id: string } }>('/api/social/friends/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const { id } = request.params;
const db = getDb();
db.delete(schema.friends).where(or(
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)),
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
)).run();
return reply.code(200).send({ success: true });
});
// GET /api/social/search?q=... - Search for users to add as friends
app.get<{ Querystring: { q: string } }>('/api/social/search', {
preHandler: authenticate,
}, async (request, reply) => {
const { q } = request.query;
const db = getDb();
if (!q || q.length < 2) {
return reply.code(200).send([]);
}
const pattern = `%${q}%`;
// Search by username or display name with partial matching, excluding current user
const users = db.select()
.from(schema.users)
.where(and(
or(
like(schema.users.username, pattern),
like(schema.users.displayName, pattern)
),
ne(schema.users.id, request.userId)
))
.limit(10)
.all();
return reply.code(200).send(users.map(sanitizeUser));
});
}
+29 -2
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { connectionManager } from '../ws/handler.js';
import type { User, UpdateUserRequest } from '@opencord/shared';
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
@@ -29,7 +30,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
});
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, customStatus } = request.body;
const { displayName, avatar, customStatus, status } = request.body;
const db = getDb();
const updateData: Record<string, string | null | undefined> = {};
@@ -62,6 +63,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
}
}
if (status !== undefined) {
if (!['online', 'idle', 'dnd', 'offline'].includes(status)) {
return reply.code(400).send({ error: 'Invalid status', statusCode: 400 });
}
updateData.status = status;
}
if (Object.keys(updateData).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
}
@@ -73,7 +81,26 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
return reply.code(200).send(sanitizeUser(updatedUser));
const sanitized = sanitizeUser(updatedUser);
// Broadcast presence update if status changed
if (status !== undefined) {
const userServers = connectionManager.getUserServers(sanitized.id);
for (const serverId of userServers) {
connectionManager.sendToServer(serverId, {
type: 'presence_update',
userId: sanitized.id,
status: status,
}, sanitized.id);
}
connectionManager.sendToUser(sanitized.id, {
type: 'presence_update',
userId: sanitized.id,
status: status,
});
}
return reply.code(200).send(sanitized);
});
app.get<{ Params: { id: string } }>('/api/users/:id', { preHandler: authenticate }, async (request, reply) => {
+42
View File
@@ -0,0 +1,42 @@
import type { FastifyInstance } from 'fastify';
import { authenticate } from '../utils/auth.js';
import * as cheerio from 'cheerio';
export async function utilRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Querystring: { url: string } }>('/api/utils/metadata', {
preHandler: authenticate,
}, async (request, reply) => {
const { url } = request.query;
if (!url) {
return reply.code(400).send({ error: 'URL is required' });
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'OpencordBot/1.0',
},
});
if (!response.ok) {
throw new Error('Failed to fetch URL');
}
const html = await response.text();
const $ = cheerio.load(html);
const metadata = {
title: $('meta[property="og:title"]').attr('content') || $('title').text(),
description: $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content'),
image: $('meta[property="og:image"]').attr('content'),
siteName: $('meta[property="og:site_name"]').attr('content'),
url: url,
};
return reply.code(200).send(metadata);
} catch (err) {
return reply.code(200).send({}); // Fail silently with empty object
}
});
}