feat: channel categories with drag-and-drop reordering
Add channel categories (named groups) with full CRUD, collapsible headers with unread indicators, and native HTML5 drag-and-drop for reordering channels within/between categories and reordering categories themselves. - Schema: channel_categories table, category_id column on channels - Server: category CRUD endpoints, batch channel-layout reorder endpoint - WebSocket: categories in ready payload, category_created/updated/deleted and channel_layout_updated events with per-user VIEW_CHANNEL filtering - Frontend: dynamic category-based sidebar layout replacing hardcoded Text/Voice sections, collapse state persisted to localStorage, category selector in CreateChannel modal, federation-aware store
This commit is contained in:
@@ -111,6 +111,12 @@ export function runMigrations(db: Database.Database): void {
|
||||
columns: [
|
||||
{ name: 'avatar_color', type: 'TEXT' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'channels',
|
||||
columns: [
|
||||
{ name: 'category_id', type: 'TEXT' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -168,6 +174,17 @@ export function runMigrations(db: Database.Database): void {
|
||||
);
|
||||
`);
|
||||
|
||||
// Ensure channel_categories table exists (idempotent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS channel_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Ensure voice_restrictions table exists (idempotent)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS voice_restrictions (
|
||||
|
||||
@@ -42,6 +42,14 @@ export const spaceMembers = sqliteTable('space_members', {
|
||||
pk: primaryKey({ columns: [table.spaceId, table.userId] }),
|
||||
}));
|
||||
|
||||
export const channelCategories = sqliteTable('channel_categories', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
position: integer('position').default(0),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const channels = sqliteTable('channels', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
@@ -49,6 +57,7 @@ export const channels = sqliteTable('channels', {
|
||||
type: text('type').notNull(),
|
||||
topic: text('topic'),
|
||||
position: integer('position').default(0),
|
||||
categoryId: text('category_id'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
CreateChannelRequest,
|
||||
UpdateChannelRequest,
|
||||
Channel,
|
||||
ChannelCategory,
|
||||
} from '@backspace/shared';
|
||||
|
||||
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||
@@ -21,6 +22,17 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||
type: row.type as Channel['type'],
|
||||
topic: row.topic,
|
||||
position: row.position ?? 0,
|
||||
categoryId: row.categoryId ?? null,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function rowToCategory(row: typeof schema.channelCategories.$inferSelect): ChannelCategory {
|
||||
return {
|
||||
id: row.id,
|
||||
spaceId: row.spaceId,
|
||||
name: row.name,
|
||||
position: row.position ?? 0,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -96,7 +108,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { name, type, topic } = request.body;
|
||||
const { name, type, topic, categoryId } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
||||
@@ -121,6 +133,18 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'Channel type must be "text" or "voice"', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Validate categoryId if provided
|
||||
let validCategoryId: string | null = null;
|
||||
if (categoryId) {
|
||||
const cat = db.select().from(schema.channelCategories)
|
||||
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, id)))
|
||||
.get();
|
||||
if (!cat) {
|
||||
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
|
||||
}
|
||||
validCategoryId = categoryId;
|
||||
}
|
||||
|
||||
// Get max position for ordering
|
||||
const existingChannels = db.select()
|
||||
.from(schema.channels)
|
||||
@@ -139,6 +163,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
type,
|
||||
topic: topic?.trim() || null,
|
||||
position: maxPosition + 1,
|
||||
categoryId: validCategoryId,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
@@ -172,7 +197,7 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { name, topic, position } = request.body;
|
||||
const { name, topic, position, categoryId } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, id)).get();
|
||||
@@ -206,6 +231,20 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
updates.position = position;
|
||||
}
|
||||
|
||||
if (categoryId !== undefined) {
|
||||
if (categoryId === null) {
|
||||
updates.categoryId = null;
|
||||
} else {
|
||||
const cat = db.select().from(schema.channelCategories)
|
||||
.where(and(eq(schema.channelCategories.id, categoryId), eq(schema.channelCategories.spaceId, spaceId)))
|
||||
.get();
|
||||
if (!cat) {
|
||||
return reply.code(400).send({ error: 'Category not found in this space', statusCode: 400 });
|
||||
}
|
||||
updates.categoryId = categoryId;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||
}
|
||||
@@ -394,4 +433,281 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Channel Category Endpoints ─────────────────────────────────────────────
|
||||
|
||||
// POST /api/spaces/:id/categories - Create a category
|
||||
app.post<{ Params: { id: string }; Body: { name: string } }>('/api/spaces/:id/categories', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { name } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
||||
if (!space) {
|
||||
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||
return reply.code(400).send({ error: 'Category name is required', statusCode: 400 });
|
||||
}
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (trimmedName.length > 100) {
|
||||
return reply.code(400).send({ error: 'Category name must be 100 characters or less', statusCode: 400 });
|
||||
}
|
||||
|
||||
const existing = db.select().from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.spaceId, id))
|
||||
.all();
|
||||
const maxPos = existing.reduce((max, c) => Math.max(max, c.position ?? 0), -1);
|
||||
|
||||
const categoryId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
db.insert(schema.channelCategories).values({
|
||||
id: categoryId,
|
||||
spaceId: id,
|
||||
name: trimmedName,
|
||||
position: maxPos + 1,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
const category = db.select().from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.id, categoryId)).get();
|
||||
if (!category) {
|
||||
return reply.code(500).send({ error: 'Failed to create category', statusCode: 500 });
|
||||
}
|
||||
|
||||
const categoryData = rowToCategory(category);
|
||||
connectionManager.sendToSpace(id, {
|
||||
type: 'category_created',
|
||||
category: categoryData,
|
||||
spaceId: id,
|
||||
});
|
||||
|
||||
return reply.code(201).send(categoryData);
|
||||
});
|
||||
|
||||
// PATCH /api/categories/:id - Update a category
|
||||
app.patch<{ Params: { id: string }; Body: { name?: string; position?: number } }>('/api/categories/:id', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { name, position } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const category = db.select().from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.id, id)).get();
|
||||
if (!category) {
|
||||
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const updates: Partial<typeof schema.channelCategories.$inferInsert> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || trimmedName.length > 100) {
|
||||
return reply.code(400).send({ error: 'Category name must be 1-100 characters', statusCode: 400 });
|
||||
}
|
||||
updates.name = trimmedName;
|
||||
}
|
||||
|
||||
if (position !== undefined) {
|
||||
if (typeof position !== 'number' || position < 0) {
|
||||
return reply.code(400).send({ error: 'Position must be a non-negative number', statusCode: 400 });
|
||||
}
|
||||
updates.position = position;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||
}
|
||||
|
||||
db.update(schema.channelCategories).set(updates)
|
||||
.where(eq(schema.channelCategories.id, id)).run();
|
||||
|
||||
const updated = db.select().from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.id, id)).get();
|
||||
if (!updated) {
|
||||
return reply.code(500).send({ error: 'Failed to update category', statusCode: 500 });
|
||||
}
|
||||
|
||||
const categoryData = rowToCategory(updated);
|
||||
connectionManager.sendToSpace(category.spaceId, {
|
||||
type: 'category_updated',
|
||||
category: categoryData,
|
||||
spaceId: category.spaceId,
|
||||
});
|
||||
|
||||
return reply.code(200).send(categoryData);
|
||||
});
|
||||
|
||||
// DELETE /api/categories/:id - Delete a category
|
||||
app.delete<{ Params: { id: string } }>('/api/categories/:id', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const category = db.select().from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.id, id)).get();
|
||||
if (!category) {
|
||||
return reply.code(404).send({ error: 'Category not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (!hasPermission(request.userId, category.spaceId, PermissionBits.MANAGE_CHANNELS)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const spaceId = category.spaceId;
|
||||
|
||||
db.transaction((tx) => {
|
||||
// Null out categoryId on all channels in this category
|
||||
tx.update(schema.channels).set({ categoryId: null })
|
||||
.where(eq(schema.channels.categoryId, id)).run();
|
||||
// Delete the category
|
||||
tx.delete(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.id, id)).run();
|
||||
});
|
||||
|
||||
// Broadcast category deletion
|
||||
connectionManager.sendToSpace(spaceId, {
|
||||
type: 'category_deleted',
|
||||
categoryId: id,
|
||||
spaceId,
|
||||
});
|
||||
|
||||
// Also broadcast updated layout so channels reflect null categoryId
|
||||
broadcastChannelLayout(spaceId);
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
|
||||
// PATCH /api/spaces/:id/channel-layout - Batch reorder channels + categories
|
||||
app.patch<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
channels: Array<{ id: string; position: number; categoryId: string | null }>;
|
||||
categories: Array<{ id: string; position: number }>;
|
||||
};
|
||||
}>('/api/spaces/:id/channel-layout', {
|
||||
preHandler: authenticate,
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { channels: channelUpdates, categories: categoryUpdates } = request.body;
|
||||
const db = getDb();
|
||||
|
||||
const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
|
||||
if (!space) {
|
||||
return reply.code(404).send({ error: 'Space not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_CHANNELS)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_CHANNELS permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (!Array.isArray(channelUpdates) || !Array.isArray(categoryUpdates)) {
|
||||
return reply.code(400).send({ error: 'channels and categories arrays are required', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Validate all channel IDs belong to this space
|
||||
const spaceChannels = db.select().from(schema.channels)
|
||||
.where(eq(schema.channels.spaceId, id)).all();
|
||||
const spaceChannelIds = new Set(spaceChannels.map(ch => ch.id));
|
||||
for (const ch of channelUpdates) {
|
||||
if (!spaceChannelIds.has(ch.id)) {
|
||||
return reply.code(400).send({ error: `Channel ${ch.id} does not belong to this space`, statusCode: 400 });
|
||||
}
|
||||
if (typeof ch.position !== 'number' || ch.position < 0) {
|
||||
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all category IDs belong to this space
|
||||
const spaceCategories = db.select().from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.spaceId, id)).all();
|
||||
const spaceCategoryIds = new Set(spaceCategories.map(c => c.id));
|
||||
for (const cat of categoryUpdates) {
|
||||
if (!spaceCategoryIds.has(cat.id)) {
|
||||
return reply.code(400).send({ error: `Category ${cat.id} does not belong to this space`, statusCode: 400 });
|
||||
}
|
||||
if (typeof cat.position !== 'number' || cat.position < 0) {
|
||||
return reply.code(400).send({ error: 'All positions must be non-negative numbers', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate category references in channels
|
||||
for (const ch of channelUpdates) {
|
||||
if (ch.categoryId !== null && !spaceCategoryIds.has(ch.categoryId)) {
|
||||
return reply.code(400).send({ error: `Category ${ch.categoryId} does not belong to this space`, statusCode: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all updates in a transaction
|
||||
db.transaction((tx) => {
|
||||
for (const ch of channelUpdates) {
|
||||
tx.update(schema.channels)
|
||||
.set({ position: ch.position, categoryId: ch.categoryId })
|
||||
.where(eq(schema.channels.id, ch.id))
|
||||
.run();
|
||||
}
|
||||
for (const cat of categoryUpdates) {
|
||||
tx.update(schema.channelCategories)
|
||||
.set({ position: cat.position })
|
||||
.where(eq(schema.channelCategories.id, cat.id))
|
||||
.run();
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast the updated layout to all space members with per-user channel filtering
|
||||
broadcastChannelLayout(id);
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast updated channel layout to all space members.
|
||||
* Each user gets only the channels they can view (VIEW_CHANNEL check).
|
||||
*/
|
||||
function broadcastChannelLayout(spaceId: string): void {
|
||||
const db = getDb();
|
||||
const allChannels = db.select().from(schema.channels)
|
||||
.where(eq(schema.channels.spaceId, spaceId)).all();
|
||||
const allCategories = db.select().from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.spaceId, spaceId)).all();
|
||||
|
||||
const categoryData = allCategories.map(rowToCategory);
|
||||
|
||||
for (const [userId, spaceIds] of connectionManager.getUserSpaceEntries()) {
|
||||
if (!spaceIds.has(spaceId)) continue;
|
||||
|
||||
const visibleChannels: Channel[] = [];
|
||||
for (const ch of allChannels) {
|
||||
const perms = computePermissions(userId, spaceId, ch.id);
|
||||
if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) {
|
||||
visibleChannels.push({
|
||||
...rowToChannel(ch),
|
||||
myPermissions: permissionsToString(perms),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
connectionManager.sendToUser(userId, {
|
||||
type: 'channel_layout_updated',
|
||||
spaceId,
|
||||
channels: visibleChannels,
|
||||
categories: categoryData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
UpdateMemberRequest,
|
||||
Space,
|
||||
Channel,
|
||||
ChannelCategory,
|
||||
MemberWithUser,
|
||||
SpaceWithChannelsAndMembers,
|
||||
Role,
|
||||
@@ -45,6 +46,7 @@ function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||
type: row.type as Channel['type'],
|
||||
topic: row.topic,
|
||||
position: row.position ?? 0,
|
||||
categoryId: row.categoryId ?? null,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -239,6 +241,19 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
.filter((m): m is MemberWithUser => m !== null);
|
||||
|
||||
// Fetch categories for this space
|
||||
const categoryRows = db.select()
|
||||
.from(schema.channelCategories)
|
||||
.where(eq(schema.channelCategories.spaceId, id))
|
||||
.all();
|
||||
const categories: ChannelCategory[] = categoryRows.map(c => ({
|
||||
id: c.id,
|
||||
spaceId: c.spaceId,
|
||||
name: c.name,
|
||||
position: c.position ?? 0,
|
||||
createdAt: c.createdAt,
|
||||
}));
|
||||
|
||||
// Compute space-level permissions for the requesting user
|
||||
const spacePerms = computePermissions(request.userId, id);
|
||||
|
||||
@@ -259,6 +274,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
const result: SpaceWithChannelsAndMembers = {
|
||||
...rowToSpace(server),
|
||||
channels: visibleChannels,
|
||||
categories,
|
||||
members,
|
||||
roles: roles.map(r => ({
|
||||
id: r.id,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SpaceWithChannelsAndMembers,
|
||||
MemberWithUser,
|
||||
Channel,
|
||||
ChannelCategory,
|
||||
DmChannel,
|
||||
ServerEvent,
|
||||
SpaceFolder,
|
||||
@@ -694,6 +695,24 @@ function buildReadyPayload(userId: string): {
|
||||
arr.push(ch);
|
||||
}
|
||||
|
||||
// Batch: all categories for all spaces (1 query instead of N)
|
||||
const allCategories = batchInArray(
|
||||
spaceIds,
|
||||
ids => db.select().from(schema.channelCategories).where(inArray(schema.channelCategories.spaceId, ids)).all(),
|
||||
);
|
||||
const categoriesBySpace = new Map<string, ChannelCategory[]>();
|
||||
for (const cat of allCategories) {
|
||||
let arr = categoriesBySpace.get(cat.spaceId);
|
||||
if (!arr) { arr = []; categoriesBySpace.set(cat.spaceId, arr); }
|
||||
arr.push({
|
||||
id: cat.id,
|
||||
spaceId: cat.spaceId,
|
||||
name: cat.name,
|
||||
position: cat.position ?? 0,
|
||||
createdAt: cat.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Batch: last message ID per channel (1 query instead of N×C)
|
||||
const allChannelIds = allChannels.map(ch => ch.id);
|
||||
const lastMsgMap = new Map<string, string>();
|
||||
@@ -782,6 +801,7 @@ function buildReadyPayload(userId: string): {
|
||||
type: ch.type as Channel['type'],
|
||||
topic: ch.topic,
|
||||
position: ch.position ?? 0,
|
||||
categoryId: ch.categoryId ?? null,
|
||||
createdAt: ch.createdAt,
|
||||
lastMessageId: lastMsgMap.get(ch.id) ?? null,
|
||||
myPermissions: permissionsToString(chPerms),
|
||||
@@ -801,6 +821,7 @@ function buildReadyPayload(userId: string): {
|
||||
description: spaceRow.description ?? null,
|
||||
createdAt: spaceRow.createdAt,
|
||||
channels: visibleChannels,
|
||||
categories: categoriesBySpace.get(spaceRow.id) ?? [],
|
||||
members,
|
||||
roles: roles.map(r => ({
|
||||
id: r.id,
|
||||
|
||||
Reference in New Issue
Block a user