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:
@@ -21,7 +21,8 @@
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"fastify": "^4.28.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"livekit-server-sdk": "^2.6.1"
|
||||
"livekit-server-sdk": "^2.6.1",
|
||||
"cheerio": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
|
||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { config } from '../config.js';
|
||||
import * as schema from './schema.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
@@ -57,6 +58,7 @@ function createTables(db: Database.Database): void {
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
reply_to_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
|
||||
content TEXT,
|
||||
edited_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
@@ -90,6 +92,62 @@ function createTables(db: Database.Database): void {
|
||||
content TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS friends (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, friend_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS friend_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
to_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#b9bbbe',
|
||||
position INTEGER DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_roles (
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (server_id, user_id, role_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
color TEXT,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_folder_members (
|
||||
folder_id TEXT NOT NULL REFERENCES server_folders(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (folder_id, server_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -99,6 +157,7 @@ export function initDatabase() {
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
createTables(sqlite);
|
||||
runMigrations(sqlite);
|
||||
console.log(`Database initialized at ${config.dbPath}`);
|
||||
return drizzle(sqlite, { schema });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
console.log('Checking for database migrations...');
|
||||
|
||||
const tables = [
|
||||
{
|
||||
name: 'messages',
|
||||
columns: [
|
||||
{ name: 'reply_to_id', type: 'TEXT REFERENCES messages(id) ON DELETE SET NULL' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'users',
|
||||
columns: [
|
||||
{ name: 'status', type: "TEXT DEFAULT 'offline'" },
|
||||
{ name: 'custom_status', type: 'TEXT' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
columns: [
|
||||
{ name: 'permissions', type: 'TEXT' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
const tableInfo = db.pragma(`table_info(${table.name})`) as { name: string }[];
|
||||
const existingColumns = new Set(tableInfo.map(c => c.name));
|
||||
|
||||
for (const column of table.columns) {
|
||||
if (!existingColumns.has(column.name)) {
|
||||
console.log(`Migrating: Adding column ${column.name} to ${table.name}`);
|
||||
try {
|
||||
db.exec(`ALTER TABLE ${table.name} ADD COLUMN ${column.name} ${column.type}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to add column ${column.name} to ${table.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Migrations complete.');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
|
||||
import { sqliteTable, text, integer, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -44,10 +44,16 @@ export const messages = sqliteTable('messages', {
|
||||
id: text('id').primaryKey(),
|
||||
channelId: text('channel_id').notNull().references(() => channels.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => users.id),
|
||||
replyToId: text('reply_to_id'),
|
||||
content: text('content'),
|
||||
editedAt: integer('edited_at'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
}, (table) => ({
|
||||
replyToFk: foreignKey({
|
||||
columns: [table.replyToId],
|
||||
foreignColumns: [table.id],
|
||||
}).onDelete('set null'),
|
||||
}));
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -78,3 +84,61 @@ export const dmMessages = sqliteTable('dm_messages', {
|
||||
content: text('content'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const friends = sqliteTable('friends', {
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
friendId: text('friend_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.friendId] }),
|
||||
}));
|
||||
|
||||
export const friendRequests = sqliteTable('friend_requests', {
|
||||
id: text('id').primaryKey(),
|
||||
fromId: text('from_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
toId: text('to_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
status: text('status').default('pending'), // 'pending', 'accepted', 'declined'
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const reactions = sqliteTable('reactions', {
|
||||
id: text('id').primaryKey(),
|
||||
messageId: text('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
emoji: text('emoji').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const roles = sqliteTable('roles', {
|
||||
id: text('id').primaryKey(),
|
||||
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
color: text('color').default('#b9bbbe'),
|
||||
position: integer('position').default(0),
|
||||
permissions: text('permissions'), // JSON string of permission keys
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const memberRoles = sqliteTable('member_roles', {
|
||||
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
roleId: text('role_id').notNull().references(() => roles.id, { onDelete: 'cascade' }),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.serverId, table.userId, table.roleId] }),
|
||||
}));
|
||||
|
||||
export const serverFolders = sqliteTable('server_folders', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name'),
|
||||
color: text('color'),
|
||||
position: integer('position').default(0),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const serverFolderMembers = sqliteTable('server_folder_members', {
|
||||
folderId: text('folder_id').notNull().references(() => serverFolders.id, { onDelete: 'cascade' }),
|
||||
serverId: text('server_id').notNull().references(() => servers.id, { onDelete: 'cascade' }),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.folderId, table.serverId] }),
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,7 @@ import websocket from '@fastify/websocket';
|
||||
import multipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { config } from './config.js';
|
||||
import { initDatabase } from './db/index.js';
|
||||
import { getDb } from './db/index.js';
|
||||
import { seedDatabase } from './db/seed.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { userRoutes } from './routes/users.js';
|
||||
@@ -14,6 +14,8 @@ import { messageRoutes } from './routes/messages.js';
|
||||
import { uploadRoutes } from './routes/uploads.js';
|
||||
import { dmRoutes } from './routes/dm.js';
|
||||
import { livekitRoutes } from './routes/livekit.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { utilRoutes } from './routes/utils.js';
|
||||
import { registerWebSocket } from './ws/handler.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -50,7 +52,8 @@ async function main(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
// Initialize database
|
||||
getDb();
|
||||
await seedDatabase();
|
||||
|
||||
await app.register(authRoutes);
|
||||
@@ -61,6 +64,8 @@ async function main(): Promise<void> {
|
||||
await app.register(uploadRoutes);
|
||||
await app.register(dmRoutes);
|
||||
await app.register(livekitRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(utilRoutes);
|
||||
await app.register(registerWebSocket);
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { eq, inArray, and } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { connectionManager } from './handler.js';
|
||||
@@ -40,15 +40,54 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
|
||||
createdAt: a.createdAt,
|
||||
}));
|
||||
|
||||
const reactionRows = db.select()
|
||||
.from(schema.reactions)
|
||||
.where(eq(schema.reactions.messageId, messageId))
|
||||
.all();
|
||||
|
||||
const reactions = reactionRows.map(r => ({
|
||||
id: r.id,
|
||||
messageId: r.messageId,
|
||||
userId: r.userId,
|
||||
emoji: r.emoji,
|
||||
createdAt: r.createdAt,
|
||||
}));
|
||||
|
||||
let replyTo: MessageWithUser | null = null;
|
||||
if (message.replyToId) {
|
||||
// Simple fetch for replyTo (one level deep to avoid recursion loops)
|
||||
const replyMsg = db.select().from(schema.messages).where(eq(schema.messages.id, message.replyToId)).get();
|
||||
if (replyMsg) {
|
||||
const replyUser = db.select().from(schema.users).where(eq(schema.users.id, replyMsg.userId)).get();
|
||||
if (replyUser) {
|
||||
replyTo = {
|
||||
id: replyMsg.id,
|
||||
channelId: replyMsg.channelId,
|
||||
userId: replyMsg.userId,
|
||||
replyToId: replyMsg.replyToId,
|
||||
content: replyMsg.content,
|
||||
editedAt: replyMsg.editedAt,
|
||||
createdAt: replyMsg.createdAt,
|
||||
user: sanitizeUser(replyUser),
|
||||
attachments: [], // Don't fetch attachments for replies to save bandwidth
|
||||
reactions: [], // Don't fetch reactions for replies
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
channelId: message.channelId,
|
||||
userId: message.userId,
|
||||
replyToId: message.replyToId,
|
||||
content: message.content,
|
||||
editedAt: message.editedAt,
|
||||
createdAt: message.createdAt,
|
||||
user: sanitizeUser(user),
|
||||
attachments,
|
||||
reactions,
|
||||
replyTo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +126,12 @@ export function handleClientEvent(
|
||||
case 'dm_message_create':
|
||||
handleDmMessageCreate(event, userId);
|
||||
break;
|
||||
case 'reaction_add':
|
||||
handleReactionAdd(event, userId);
|
||||
break;
|
||||
case 'reaction_remove':
|
||||
handleReactionRemove(event, userId);
|
||||
break;
|
||||
default:
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'error',
|
||||
@@ -98,6 +143,7 @@ export function handleClientEvent(
|
||||
function handleMessageCreate(event: Record<string, unknown>, userId: string): void {
|
||||
const channelId = event.channelId as string;
|
||||
const content = event.content as string;
|
||||
const replyToId = event.replyToId as string | undefined;
|
||||
|
||||
if (!channelId || typeof channelId !== 'string') {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'channelId is required' });
|
||||
@@ -128,6 +174,7 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
|
||||
id: messageId,
|
||||
channelId,
|
||||
userId,
|
||||
replyToId: replyToId || null,
|
||||
content: content.trim(),
|
||||
createdAt: now,
|
||||
}).run();
|
||||
@@ -407,3 +454,73 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleReactionAdd(event: Record<string, unknown>, userId: string): void {
|
||||
const messageId = event.messageId as string;
|
||||
const emoji = event.emoji as string;
|
||||
|
||||
if (!messageId || !emoji) return;
|
||||
|
||||
const db = getDb();
|
||||
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
||||
if (!message) return;
|
||||
|
||||
const serverId = getChannelServerId(message.channelId);
|
||||
if (!serverId || !isMember(serverId, userId)) return;
|
||||
|
||||
const reactionId = generateSnowflake();
|
||||
try {
|
||||
db.insert(schema.reactions).values({
|
||||
id: reactionId,
|
||||
messageId,
|
||||
userId,
|
||||
emoji,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'reaction_added',
|
||||
messageId,
|
||||
reaction: {
|
||||
id: reactionId,
|
||||
messageId,
|
||||
userId,
|
||||
emoji,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Unique constraint violation (already reacted)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReactionRemove(event: Record<string, unknown>, userId: string): void {
|
||||
const messageId = event.messageId as string;
|
||||
const emoji = event.emoji as string;
|
||||
|
||||
if (!messageId || !emoji) return;
|
||||
|
||||
const db = getDb();
|
||||
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
||||
if (!message) return;
|
||||
|
||||
const serverId = getChannelServerId(message.channelId);
|
||||
if (!serverId || !isMember(serverId, userId)) return;
|
||||
|
||||
const result = db.delete(schema.reactions)
|
||||
.where(and(
|
||||
eq(schema.reactions.messageId, messageId),
|
||||
eq(schema.reactions.userId, userId),
|
||||
eq(schema.reactions.emoji, emoji)
|
||||
))
|
||||
.run();
|
||||
|
||||
if (result.changes > 0) {
|
||||
connectionManager.sendToServer(serverId, {
|
||||
type: 'reaction_removed',
|
||||
messageId,
|
||||
userId,
|
||||
emoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
Channel,
|
||||
DmChannel,
|
||||
ServerEvent,
|
||||
ServerFolder,
|
||||
} from '@opencord/shared';
|
||||
|
||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||
@@ -185,6 +186,7 @@ function buildReadyPayload(userId: string): {
|
||||
user: User;
|
||||
servers: ServerWithChannelsAndMembers[];
|
||||
dmChannels: DmChannel[];
|
||||
folders: ServerFolder[];
|
||||
} {
|
||||
const db = getDb();
|
||||
|
||||
@@ -217,6 +219,12 @@ function buildReadyPayload(userId: string): {
|
||||
.where(eq(schema.channels.serverId, serverRow.id))
|
||||
.all();
|
||||
|
||||
const roles = db.select()
|
||||
.from(schema.roles)
|
||||
.where(eq(schema.roles.serverId, serverRow.id))
|
||||
.orderBy(schema.roles.position)
|
||||
.all();
|
||||
|
||||
const memberRows = db.select()
|
||||
.from(schema.serverMembers)
|
||||
.where(eq(schema.serverMembers.serverId, serverRow.id))
|
||||
@@ -228,10 +236,31 @@ function buildReadyPayload(userId: string): {
|
||||
: [];
|
||||
const userMap = new Map(users.map(u => [u.id, u]));
|
||||
|
||||
const memberRoleRows = db.select()
|
||||
.from(schema.memberRoles)
|
||||
.where(eq(schema.memberRoles.serverId, serverRow.id))
|
||||
.all();
|
||||
|
||||
const members: MemberWithUser[] = memberRows
|
||||
.map(m => {
|
||||
const u = userMap.get(m.userId);
|
||||
if (!u) 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,
|
||||
@@ -239,6 +268,7 @@ function buildReadyPayload(userId: string): {
|
||||
nickname: m.nickname,
|
||||
joinedAt: m.joinedAt,
|
||||
user: sanitizeUser(u),
|
||||
roles: memberRoles,
|
||||
};
|
||||
})
|
||||
.filter((m): m is MemberWithUser => m !== null);
|
||||
@@ -260,6 +290,14 @@ function buildReadyPayload(userId: string): {
|
||||
createdAt: ch.createdAt,
|
||||
})),
|
||||
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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -316,7 +354,32 @@ function buildReadyPayload(userId: string): {
|
||||
});
|
||||
}
|
||||
|
||||
return { user, servers, dmChannels };
|
||||
// Get Server Folders
|
||||
const folderRows = db.select()
|
||||
.from(schema.serverFolders)
|
||||
.where(eq(schema.serverFolders.userId, userId))
|
||||
.orderBy(schema.serverFolders.position)
|
||||
.all();
|
||||
|
||||
const folders: any[] = [];
|
||||
for (const folder of folderRows) {
|
||||
const serverIds = db.select()
|
||||
.from(schema.serverFolderMembers)
|
||||
.where(eq(schema.serverFolderMembers.folderId, folder.id))
|
||||
.all()
|
||||
.map(m => m.serverId);
|
||||
|
||||
folders.push({
|
||||
id: folder.id,
|
||||
userId: folder.userId,
|
||||
name: folder.name,
|
||||
color: folder.color,
|
||||
position: folder.position,
|
||||
serverIds,
|
||||
});
|
||||
}
|
||||
|
||||
return { user, servers, dmChannels, folders };
|
||||
}
|
||||
|
||||
export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user