From 774dff11baa41dcab7b06a730c6790a13e6b2694 Mon Sep 17 00:00:00 2001 From: hzhang Date: Fri, 15 May 2026 09:09:41 +0100 Subject: [PATCH] feat(guild): channel membership + public visibility - new channel_members table; creator always added, plus selected members - Channel.isPublic (default false): public channels visible to all guild members; non-public only to explicit members - GET /channels filters to channels visible to the requesting user Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/channels.controller.ts | 27 +++++++--- src/channels/channels.module.ts | 3 +- src/channels/channels.service.ts | 73 +++++++++++++++++++-------- src/database.config.ts | 2 + src/entities/channel-member.entity.ts | 19 +++++++ src/entities/channel.entity.ts | 5 ++ 6 files changed, 101 insertions(+), 28 deletions(-) create mode 100644 src/entities/channel-member.entity.ts diff --git a/src/channels/channels.controller.ts b/src/channels/channels.controller.ts index 2508cf8..c45adcc 100644 --- a/src/channels/channels.controller.ts +++ b/src/channels/channels.controller.ts @@ -1,18 +1,33 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, Req, UnauthorizedException } from '@nestjs/common'; import { ChannelsService } from './channels.service'; +// ApiKeyGuard attaches the introspected Center user id onto the request. +type AuthedRequest = { userId?: string }; + @Controller('channels') export class ChannelsController { constructor(private readonly channelsService: ChannelsService) {} @Get() - list(@Query('guildId') guildId?: string) { - if (!guildId) return this.channelsService.listAll(); - return this.channelsService.listByGuild(guildId); + list(@Req() req: AuthedRequest, @Query('guildId') guildId?: string) { + const userId = req.userId ?? ''; + if (!userId) throw new UnauthorizedException('missing user'); + return this.channelsService.listForUser(String(guildId ?? ''), userId); } @Post() - create(@Body() body: Record) { - return this.channelsService.create(body); + create(@Req() req: AuthedRequest, @Body() body: Record) { + const userId = req.userId ?? ''; + if (!userId) throw new UnauthorizedException('missing user'); + return this.channelsService.create( + { + guildId: body.guildId as string | undefined, + name: body.name as string | undefined, + kind: body.kind as string | undefined, + isPublic: Boolean(body.isPublic), + memberUserIds: Array.isArray(body.memberUserIds) ? (body.memberUserIds as string[]) : [], + }, + userId, + ); } } diff --git a/src/channels/channels.module.ts b/src/channels/channels.module.ts index 48b0624..922d050 100644 --- a/src/channels/channels.module.ts +++ b/src/channels/channels.module.ts @@ -2,10 +2,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ChannelsController } from './channels.controller'; import { Channel } from '../entities/channel.entity'; +import { ChannelMember } from '../entities/channel-member.entity'; import { ChannelsService } from './channels.service'; @Module({ - imports: [TypeOrmModule.forFeature([Channel])], + imports: [TypeOrmModule.forFeature([Channel, ChannelMember])], controllers: [ChannelsController], providers: [ChannelsService], exports: [ChannelsService], diff --git a/src/channels/channels.service.ts b/src/channels/channels.service.ts index 48d2456..0051306 100644 --- a/src/channels/channels.service.ts +++ b/src/channels/channels.service.ts @@ -1,43 +1,74 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { Channel } from '../entities/channel.entity'; +import { ChannelMember } from '../entities/channel-member.entity'; + +type CreateChannelInput = { + guildId?: string; + name?: string; + kind?: string; + isPublic?: boolean; + memberUserIds?: string[]; +}; @Injectable() export class ChannelsService { constructor( @InjectRepository(Channel) private readonly channelRepo: Repository, + @InjectRepository(ChannelMember) + private readonly memberRepo: Repository, ) {} - listByGuild(guildId: string) { - return this.channelRepo.find({ - where: { guildId }, + // Channels visible to a user within a guild: + // - every public channel of the guild (incl. ones created before the user + // joined the guild), OR + // - a non-public channel the user is an explicit member of. + async listForUser(guildId: string, userId: string) { + const all = await this.channelRepo.find({ + where: guildId ? { guildId } : {}, order: { createdAt: 'ASC' }, - take: 200, + take: 500, }); + if (!all.length) return []; + + const memberRows = await this.memberRepo.find({ + where: { userId, channelId: In(all.map((c) => c.id)) }, + }); + const memberChannelIds = new Set(memberRows.map((m) => m.channelId)); + + return all.filter((c) => c.isPublic || memberChannelIds.has(c.id)); } - listAll() { - return this.channelRepo.find({ - order: { createdAt: 'ASC' }, - take: 200, - }); - } - - create(input: Partial) { + async create(input: CreateChannelInput, creatorUserId: string) { const guildId = String(input.guildId ?? '').trim(); const name = String(input.name ?? '').trim(); if (!guildId) throw new BadRequestException('guildId is required'); if (!name) throw new BadRequestException('name is required'); + if (!creatorUserId) throw new BadRequestException('creator is required'); - const channel = this.channelRepo.create({ - guildId, - name, - kind: input.kind === 'announcement' ? 'announcement' : 'text', - isPrivate: Boolean(input.isPrivate), - lastSeq: 0, - }); - return this.channelRepo.save(channel); + const channel = await this.channelRepo.save( + this.channelRepo.create({ + guildId, + name, + kind: input.kind === 'announcement' ? 'announcement' : 'text', + isPrivate: !input.isPublic, + isPublic: Boolean(input.isPublic), + lastSeq: 0, + }), + ); + + // creator is always a member; merge in any explicitly selected members + const memberIds = new Set([creatorUserId]); + for (const id of input.memberUserIds ?? []) { + const trimmed = String(id ?? '').trim(); + if (trimmed) memberIds.add(trimmed); + } + await this.memberRepo.save( + [...memberIds].map((userId) => this.memberRepo.create({ channelId: channel.id, userId })), + ); + + return channel; } } diff --git a/src/database.config.ts b/src/database.config.ts index 5dccfb5..6d8b917 100644 --- a/src/database.config.ts +++ b/src/database.config.ts @@ -1,6 +1,7 @@ import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { Guild } from './entities/guild.entity'; import { Channel } from './entities/channel.entity'; +import { ChannelMember } from './entities/channel-member.entity'; import { Message } from './entities/message.entity'; import { DmConversation } from './entities/dm-conversation.entity'; import { DmParticipant } from './entities/dm-participant.entity'; @@ -19,6 +20,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({ entities: [ Guild, Channel, + ChannelMember, Message, DmConversation, DmParticipant, diff --git a/src/entities/channel-member.entity.ts b/src/entities/channel-member.entity.ts new file mode 100644 index 0000000..7459aa7 --- /dev/null +++ b/src/entities/channel-member.entity.ts @@ -0,0 +1,19 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('channel_members') +@Index(['channelId', 'userId'], { unique: true }) +@Index(['userId']) +export class ChannelMember { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index() + @Column({ type: 'char', length: 36 }) + channelId!: string; + + @Column({ type: 'varchar', length: 64 }) + userId!: string; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/src/entities/channel.entity.ts b/src/entities/channel.entity.ts index 873e9ef..2bc0b2a 100644 --- a/src/entities/channel.entity.ts +++ b/src/entities/channel.entity.ts @@ -19,6 +19,11 @@ export class Channel { @Column({ type: 'boolean', default: false }) isPrivate!: boolean; + // public channels are visible to every guild member (including those who + // join after the channel was created); default off (unchecked) + @Column({ type: 'boolean', default: false }) + isPublic!: boolean; + @Index() @Column({ default: 0 }) lastSeq!: number;