1 Commits

Author SHA1 Message Date
774dff11ba 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>
2026-05-15 09:09:41 +01:00
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';
// 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<string, unknown>) {
return this.channelsService.create(body);
create(@Req() req: AuthedRequest, @Body() body: Record<string, unknown>) {
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 { 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],

View File

@@ -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<Channel>,
@InjectRepository(ChannelMember)
private readonly memberRepo: Repository<ChannelMember>,
) {}
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<Channel>) {
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<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 { 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,

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