Files
Fabric/Fabric.Backend.Guild/src/messaging/messaging.controller.ts

190 lines
5.3 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
NotFoundException,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { CreateMessageDto } from './dto.create-message.dto';
import { Channel } from '../entities/channel.entity';
import { Message } from '../entities/message.entity';
const EDIT_WINDOW_MS = 15 * 60 * 1000;
const DEFAULT_PAGE_LIMIT = 50;
const MAX_PAGE_LIMIT = 200;
@Controller('channels/:id/messages')
export class MessagingController {
constructor(
private readonly dataSource: DataSource,
@InjectRepository(Channel)
private readonly channelRepo: Repository<Channel>,
@InjectRepository(Message)
private readonly messageRepo: Repository<Message>,
) {}
private toView(m: Message) {
return {
messageId: m.messageId,
seq: m.seq,
content: m.content,
authorUserId: m.authorUserId,
replyToMessageId: m.replyToMessageId,
mentions: m.mentions ?? [],
attachments: m.attachments ?? [],
createdAt: m.createdAt.toISOString(),
editedAt: m.editedAt ? m.editedAt.toISOString() : null,
deletedAt: m.deletedAt ? m.deletedAt.toISOString() : null,
isDeleted: m.isDeleted,
};
}
@Post()
async create(@Param('id') channelId: string, @Body() body: CreateMessageDto) {
const message = await this.dataSource.transaction(async (manager) => {
const channel = await manager.findOne(Channel, {
where: { id: channelId },
lock: { mode: 'pessimistic_write' },
});
if (!channel) {
throw new NotFoundException('channel not found');
}
const nextSeq = channel.lastSeq + 1;
channel.lastSeq = nextSeq;
await manager.save(Channel, channel);
const messageId = body.clientMessageId ?? `m-${channelId}-${nextSeq}`;
const row = manager.create(Message, {
messageId,
channelId,
conversationId: null,
authorUserId: body.authorUserId ?? 'anonymous',
seq: nextSeq,
content: body.content,
replyToMessageId: body.replyToMessageId ?? null,
mentions: body.mentions ?? [],
attachments: body.attachments ?? [],
editedAt: null,
deletedAt: null,
isDeleted: false,
});
return manager.save(Message, row);
});
return this.toView(message);
}
@Patch(':messageId')
async edit(
@Param('id') channelId: string,
@Param('messageId') messageId: string,
@Body() body: { content?: string },
) {
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
if (!item) return { status: 'not_found' };
const now = Date.now();
const createdAt = new Date(item.createdAt).getTime();
if (now - createdAt > EDIT_WINDOW_MS) {
return { status: 'edit_window_expired', messageId };
}
item.content = body.content ?? item.content;
item.editedAt = new Date();
const saved = await this.messageRepo.save(item);
return this.toView(saved);
}
@Delete(':messageId')
async remove(@Param('id') channelId: string, @Param('messageId') messageId: string) {
const item = await this.messageRepo.findOne({ where: { channelId, messageId } });
if (!item) return { status: 'not_found' };
item.isDeleted = true;
item.deletedAt = new Date();
item.content = '[deleted]';
item.mentions = [];
item.attachments = [];
await this.messageRepo.save(item);
return { status: 'deleted', mode: 'soft', messageId };
}
@Get()
async listBySeq(
@Param('id') channelId: string,
@Query('seq_from') seqFrom?: string,
@Query('seq_to') seqTo?: string,
@Query('limit') limit?: string,
) {
const from = seqFrom ? Number(seqFrom) : 1;
const to = seqTo ? Number(seqTo) : Number.MAX_SAFE_INTEGER;
const requestedLimit = limit ? Number(limit) : DEFAULT_PAGE_LIMIT;
const safeLimit =
Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.min(requestedLimit, MAX_PAGE_LIMIT)
: DEFAULT_PAGE_LIMIT;
if (from > to) {
return {
items: [],
page: {
seqFrom: from,
seqTo: to,
limit: safeLimit,
returned: 0,
hasMore: false,
nextExpectedSeq: from,
highestCommittedSeq: 0,
},
};
}
const channel = await this.channelRepo.findOne({ where: { id: channelId } });
if (!channel) {
throw new NotFoundException('channel not found');
}
const qb = this.messageRepo
.createQueryBuilder('m')
.where('m.channelId = :channelId', { channelId })
.andWhere('m.seq >= :from', { from })
.andWhere('m.seq <= :to', { to })
.orderBy('m.seq', 'ASC');
const total = await qb.getCount();
const rows = await qb.limit(safeLimit).getMany();
const items = rows.map((m) => this.toView(m));
let nextExpectedSeq = from;
for (const row of rows) {
if (row.seq > nextExpectedSeq) {
break;
}
if (row.seq === nextExpectedSeq) {
nextExpectedSeq += 1;
}
}
return {
items,
page: {
seqFrom: from,
seqTo: to,
limit: safeLimit,
returned: items.length,
hasMore: total > items.length,
nextExpectedSeq,
highestCommittedSeq: channel.lastSeq,
},
};
}
}