feat: Allow admin to create new text channels
This commit is contained in:
@@ -16,6 +16,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
tags: JSON.stringify(server.tags),
|
||||
channels: JSON.stringify(server.channels ?? []),
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
|
||||
@@ -3,10 +3,63 @@ import { ServerEntity } from '../entities/ServerEntity';
|
||||
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
|
||||
import {
|
||||
AuthUserPayload,
|
||||
ServerChannelPayload,
|
||||
ServerPayload,
|
||||
JoinRequestPayload
|
||||
} from './types';
|
||||
|
||||
function parseStringArray(raw: string | null | undefined): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return parsed
|
||||
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
|
||||
.map((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = name.toLocaleLowerCase();
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
} satisfies ServerChannelPayload;
|
||||
})
|
||||
.filter((channel): channel is ServerChannelPayload => !!channel);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -29,7 +82,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
tags: JSON.parse(row.tags || '[]'),
|
||||
tags: parseStringArray(row.tags),
|
||||
channels: parseServerChannels(row.channels),
|
||||
createdAt: row.createdAt,
|
||||
lastSeen: row.lastSeen
|
||||
};
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface AuthUserPayload {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type ServerChannelType = 'text' | 'voice';
|
||||
|
||||
export interface ServerChannelPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServerChannelType;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface ServerPayload {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -40,6 +49,7 @@ export interface ServerPayload {
|
||||
maxUsers: number;
|
||||
currentUsers: number;
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
createdAt: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ export class ServerEntity {
|
||||
@Column('text', { default: '[]' })
|
||||
tags!: string;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
channels!: string;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"tags" TEXT NOT NULL DEFAULT '[]',
|
||||
"channels" TEXT NOT NULL DEFAULT '[]',
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL
|
||||
)
|
||||
|
||||
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
13
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ServerChannels1000000000002 implements MigrationInterface {
|
||||
name = 'ServerChannels1000000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001
|
||||
ServerAccessControl1000000000001,
|
||||
ServerChannels1000000000002
|
||||
];
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Response, Router } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import {
|
||||
ServerChannelPayload,
|
||||
ServerPayload
|
||||
} from '../cqrs/types';
|
||||
import {
|
||||
getAllPublicServers,
|
||||
getServerById,
|
||||
@@ -38,6 +41,43 @@ function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
|
||||
return !!role && allowedRoles.includes(role);
|
||||
}
|
||||
|
||||
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: ServerChannelPayload[] = [];
|
||||
|
||||
for (const [index, channel] of value.entries()) {
|
||||
if (!channel || typeof channel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = name.toLocaleLowerCase();
|
||||
|
||||
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
const owner = await getUserById(server.ownerId);
|
||||
const { passwordHash, ...publicServer } = server;
|
||||
@@ -124,7 +164,8 @@ router.post('/', async (req, res) => {
|
||||
isPrivate,
|
||||
maxUsers,
|
||||
password,
|
||||
tags
|
||||
tags,
|
||||
channels
|
||||
} = req.body;
|
||||
|
||||
if (!name || !ownerId || !ownerPublicKey)
|
||||
@@ -143,6 +184,7 @@ router.post('/', async (req, res) => {
|
||||
maxUsers: maxUsers ?? 0,
|
||||
currentUsers: 0,
|
||||
tags: tags ?? [],
|
||||
channels: normalizeServerChannels(channels),
|
||||
createdAt: Date.now(),
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
@@ -161,6 +203,7 @@ router.put('/:id', async (req, res) => {
|
||||
password,
|
||||
hasPassword: _ignoredHasPassword,
|
||||
passwordHash: _ignoredPasswordHash,
|
||||
channels,
|
||||
...updates
|
||||
} = req.body;
|
||||
const existing = await getServerById(id);
|
||||
@@ -178,10 +221,12 @@ router.put('/:id', async (req, res) => {
|
||||
}
|
||||
|
||||
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
|
||||
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
|
||||
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
|
||||
const server: ServerPayload = {
|
||||
...existing,
|
||||
...updates,
|
||||
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
|
||||
hasPassword: !!nextPasswordHash,
|
||||
passwordHash: nextPasswordHash,
|
||||
lastSeen: Date.now()
|
||||
|
||||
@@ -134,11 +134,15 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
|
||||
? message['channelId'].trim()
|
||||
: 'general';
|
||||
|
||||
if (typingSid && user.serverIds.has(typingSid)) {
|
||||
broadcastToServer(typingSid, {
|
||||
type: 'user_typing',
|
||||
serverId: typingSid,
|
||||
channelId,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
}, user.oderId);
|
||||
|
||||
Reference in New Issue
Block a user