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) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-15 09:09:41 +01:00
parent 78d2179e8c
commit 774dff11ba
6 changed files with 101 additions and 28 deletions

View File

@@ -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'; import { ChannelsService } from './channels.service';
// ApiKeyGuard attaches the introspected Center user id onto the request.
type AuthedRequest = { userId?: string };
@Controller('channels') @Controller('channels')
export class ChannelsController { export class ChannelsController {
constructor(private readonly channelsService: ChannelsService) {} constructor(private readonly channelsService: ChannelsService) {}
@Get() @Get()
list(@Query('guildId') guildId?: string) { list(@Req() req: AuthedRequest, @Query('guildId') guildId?: string) {
if (!guildId) return this.channelsService.listAll(); const userId = req.userId ?? '';
return this.channelsService.listByGuild(guildId); if (!userId) throw new UnauthorizedException('missing user');
return this.channelsService.listForUser(String(guildId ?? ''), userId);
} }
@Post() @Post()
create(@Body() body: Record<string, unknown>) { create(@Req() req: AuthedRequest, @Body() body: Record<string, unknown>) {
return this.channelsService.create(body); 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,
);
} }
} }

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ChannelsController } from './channels.controller'; import { ChannelsController } from './channels.controller';
import { Channel } from '../entities/channel.entity'; import { Channel } from '../entities/channel.entity';
import { ChannelMember } from '../entities/channel-member.entity';
import { ChannelsService } from './channels.service'; import { ChannelsService } from './channels.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Channel])], imports: [TypeOrmModule.forFeature([Channel, ChannelMember])],
controllers: [ChannelsController], controllers: [ChannelsController],
providers: [ChannelsService], providers: [ChannelsService],
exports: [ChannelsService], exports: [ChannelsService],

View File

@@ -1,43 +1,74 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { Channel } from '../entities/channel.entity'; 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() @Injectable()
export class ChannelsService { export class ChannelsService {
constructor( constructor(
@InjectRepository(Channel) @InjectRepository(Channel)
private readonly channelRepo: Repository<Channel>, private readonly channelRepo: Repository<Channel>,
@InjectRepository(ChannelMember)
private readonly memberRepo: Repository<ChannelMember>,
) {} ) {}
listByGuild(guildId: string) { // Channels visible to a user within a guild:
return this.channelRepo.find({ // - every public channel of the guild (incl. ones created before the user
where: { guildId }, // 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' }, 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() { async create(input: CreateChannelInput, creatorUserId: string) {
return this.channelRepo.find({
order: { createdAt: 'ASC' },
take: 200,
});
}
create(input: Partial<Channel>) {
const guildId = String(input.guildId ?? '').trim(); const guildId = String(input.guildId ?? '').trim();
const name = String(input.name ?? '').trim(); const name = String(input.name ?? '').trim();
if (!guildId) throw new BadRequestException('guildId is required'); if (!guildId) throw new BadRequestException('guildId is required');
if (!name) throw new BadRequestException('name is required'); if (!name) throw new BadRequestException('name is required');
if (!creatorUserId) throw new BadRequestException('creator is required');
const channel = this.channelRepo.create({ const channel = await this.channelRepo.save(
guildId, this.channelRepo.create({
name, guildId,
kind: input.kind === 'announcement' ? 'announcement' : 'text', name,
isPrivate: Boolean(input.isPrivate), kind: input.kind === 'announcement' ? 'announcement' : 'text',
lastSeq: 0, isPrivate: !input.isPublic,
}); isPublic: Boolean(input.isPublic),
return this.channelRepo.save(channel); lastSeq: 0,
}),
);
// creator is always a member; merge in any explicitly selected members
const memberIds = new Set<string>([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;
} }
} }

View File

@@ -1,6 +1,7 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { Guild } from './entities/guild.entity'; import { Guild } from './entities/guild.entity';
import { Channel } from './entities/channel.entity'; import { Channel } from './entities/channel.entity';
import { ChannelMember } from './entities/channel-member.entity';
import { Message } from './entities/message.entity'; import { Message } from './entities/message.entity';
import { DmConversation } from './entities/dm-conversation.entity'; import { DmConversation } from './entities/dm-conversation.entity';
import { DmParticipant } from './entities/dm-participant.entity'; import { DmParticipant } from './entities/dm-participant.entity';
@@ -19,6 +20,7 @@ export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
entities: [ entities: [
Guild, Guild,
Channel, Channel,
ChannelMember,
Message, Message,
DmConversation, DmConversation,
DmParticipant, DmParticipant,

View File

@@ -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;
}

View File

@@ -19,6 +19,11 @@ export class Channel {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isPrivate!: boolean; 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() @Index()
@Column({ default: 0 }) @Column({ default: 0 })
lastSeq!: number; lastSeq!: number;