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
+59
View File
@@ -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 });
}
+45
View File
@@ -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.');
}
+66 -2
View File
@@ -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] }),
}));