feat: bootstrap from Fabric monorepo
This commit is contained in:
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Server
|
||||||
|
PORT=7002
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
DB_HOST=mysql-guild
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=fabric
|
||||||
|
DB_PASSWORD=fabric
|
||||||
|
DB_NAME=fabric_guild
|
||||||
|
DB_SYNC=true
|
||||||
|
DB_LOGGING=false
|
||||||
|
|
||||||
|
# Unified inbound API auth
|
||||||
|
FABRIC_API_KEY=change-me-api-key
|
||||||
|
|
||||||
|
# Guild identity
|
||||||
|
GUILD_NODE_ID=guild-node-1
|
||||||
|
GUILD_NODE_NAME=Guild Node 1
|
||||||
|
GUILD_PUBLIC_ENDPOINT=http://localhost:7002
|
||||||
|
|
||||||
|
# Center handshake
|
||||||
|
CENTER_BASE_URL=http://backend-center:7001
|
||||||
|
CENTER_SHARED_SECRET=change-me-center-secret
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
EXPOSE 7002
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Fabric.Backend.Guild
|
||||||
|
|
||||||
|
Guild Node service for Fabric.
|
||||||
|
|
||||||
|
## Scope (MVP)
|
||||||
|
- Workspace/Guild/Channel/DM
|
||||||
|
- Message create/edit/delete/reply/@mention
|
||||||
|
- Per-channel/DM seq ordering + gap backfill API
|
||||||
|
- Webhook/Bot integration surface
|
||||||
|
- Guild-level audit logs
|
||||||
|
|
||||||
|
## Next
|
||||||
|
- API skeleton (NestJS)
|
||||||
|
- Chat domain models
|
||||||
|
- Seq allocator and range query endpoints
|
||||||
28
eslint.config.mjs
Normal file
28
eslint.config.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
6267
package-lock.json
generated
Normal file
6267
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "fabric-backend-guild",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Fabric Guild Node service",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "ts-node src/main.ts",
|
||||||
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
|
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||||
|
"format": "prettier --write 'src/**/*.ts'",
|
||||||
|
"test:unit": "vitest run src/**/*.spec.ts --exclude src/*.integration.spec.ts --exclude dist/**",
|
||||||
|
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**",
|
||||||
|
"migration:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate src/migrations/AutoMigration -d src/data-source.ts",
|
||||||
|
"migration:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts",
|
||||||
|
"migration:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d src/data-source.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.4.8",
|
||||||
|
"@nestjs/core": "^10.4.8",
|
||||||
|
"@nestjs/platform-express": "^10.4.8",
|
||||||
|
"@nestjs/platform-socket.io": "^10.4.22",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/typeorm": "^11.0.1",
|
||||||
|
"@nestjs/websockets": "^10.4.22",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"mysql2": "^3.22.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"typeorm": "^0.3.29"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@nestjs/testing": "^10.4.22",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^22.10.1",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||||
|
"@typescript-eslint/parser": "^8.59.3",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app.module.ts
Normal file
33
src/app.module.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
|
import { HealthController } from './common/health.controller';
|
||||||
|
import { MetricsController } from './common/metrics.controller';
|
||||||
|
import { MetricsService } from './common/metrics.service';
|
||||||
|
import { ApiKeyGuard } from './common/api-key.guard';
|
||||||
|
import { GuildsModule } from './guilds/guilds.module';
|
||||||
|
import { ChannelsModule } from './channels/channels.module';
|
||||||
|
import { MessagingModule } from './messaging/messaging.module';
|
||||||
|
import { EventsModule } from './events/events.module';
|
||||||
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
||||||
|
EventsModule,
|
||||||
|
RealtimeModule,
|
||||||
|
GuildsModule,
|
||||||
|
ChannelsModule,
|
||||||
|
MessagingModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController, MetricsController],
|
||||||
|
providers: [
|
||||||
|
MetricsService,
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ApiKeyGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
18
src/channels/channels.controller.ts
Normal file
18
src/channels/channels.controller.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||||
|
import { ChannelsService } from './channels.service';
|
||||||
|
|
||||||
|
@Controller('channels')
|
||||||
|
export class ChannelsController {
|
||||||
|
constructor(private readonly channelsService: ChannelsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query('guildId') guildId?: string) {
|
||||||
|
if (!guildId) return [];
|
||||||
|
return this.channelsService.listByGuild(guildId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: Record<string, unknown>) {
|
||||||
|
return this.channelsService.create(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/channels/channels.module.ts
Normal file
13
src/channels/channels.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ChannelsController } from './channels.controller';
|
||||||
|
import { Channel } from '../entities/channel.entity';
|
||||||
|
import { ChannelsService } from './channels.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Channel])],
|
||||||
|
controllers: [ChannelsController],
|
||||||
|
providers: [ChannelsService],
|
||||||
|
exports: [ChannelsService],
|
||||||
|
})
|
||||||
|
export class ChannelsModule {}
|
||||||
31
src/channels/channels.service.ts
Normal file
31
src/channels/channels.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Channel } from '../entities/channel.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChannelsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Channel)
|
||||||
|
private readonly channelRepo: Repository<Channel>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listByGuild(guildId: string) {
|
||||||
|
return this.channelRepo.find({
|
||||||
|
where: { guildId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: Partial<Channel>) {
|
||||||
|
const channel = this.channelRepo.create({
|
||||||
|
guildId: String(input.guildId ?? ''),
|
||||||
|
name: String(input.name ?? ''),
|
||||||
|
kind: input.kind === 'announcement' ? 'announcement' : 'text',
|
||||||
|
isPrivate: Boolean(input.isPrivate),
|
||||||
|
lastSeq: 0,
|
||||||
|
});
|
||||||
|
return this.channelRepo.save(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/common/api-key.guard.ts
Normal file
34
src/common/api-key.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiKeyGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const req = context.switchToHttp().getRequest<{ path?: string; headers: Record<string, string | string[] | undefined> }>();
|
||||||
|
const path = req.path ?? '';
|
||||||
|
|
||||||
|
// allow health check without auth
|
||||||
|
if (path.endsWith('/healthz') || path === '/healthz') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = process.env.FABRIC_API_KEY;
|
||||||
|
if (!expected || expected.trim() === '') {
|
||||||
|
throw new ServiceUnavailableException('FABRIC_API_KEY is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const received = req.headers['x-api-key'];
|
||||||
|
const receivedValue = Array.isArray(received) ? received[0] : received;
|
||||||
|
|
||||||
|
if (!receivedValue || receivedValue !== expected) {
|
||||||
|
throw new UnauthorizedException('invalid api key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/common/health.controller.ts
Normal file
9
src/common/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('healthz')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
get() {
|
||||||
|
return { ok: true, service: 'guild' };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/common/metrics.controller.ts
Normal file
12
src/common/metrics.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
@Controller('metrics')
|
||||||
|
export class MetricsController {
|
||||||
|
constructor(private readonly metrics: MetricsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get() {
|
||||||
|
return this.metrics.snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/common/metrics.service.ts
Normal file
35
src/common/metrics.service.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
requests: number;
|
||||||
|
errors: number;
|
||||||
|
totalDurationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetricsService {
|
||||||
|
private readonly bucket: Bucket = { requests: 0, errors: 0, totalDurationMs: 0 };
|
||||||
|
private startedAt = Date.now();
|
||||||
|
|
||||||
|
record(statusCode: number, durationMs: number): void {
|
||||||
|
this.bucket.requests += 1;
|
||||||
|
if (statusCode >= 400) this.bucket.errors += 1;
|
||||||
|
this.bucket.totalDurationMs += durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
const uptimeSec = Math.max(1, Math.floor((Date.now() - this.startedAt) / 1000));
|
||||||
|
const qps = this.bucket.requests / uptimeSec;
|
||||||
|
const avgLatencyMs = this.bucket.requests > 0 ? this.bucket.totalDurationMs / this.bucket.requests : 0;
|
||||||
|
const errorRate = this.bucket.requests > 0 ? this.bucket.errors / this.bucket.requests : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
requests: this.bucket.requests,
|
||||||
|
errors: this.bucket.errors,
|
||||||
|
qps,
|
||||||
|
avgLatencyMs,
|
||||||
|
errorRate,
|
||||||
|
uptimeSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/common/request-context.middleware.ts
Normal file
36
src/common/request-context.middleware.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import { MetricsService } from './metrics.service';
|
||||||
|
|
||||||
|
type ReqWithId = Request & { requestId?: string };
|
||||||
|
|
||||||
|
export function createRequestContextMiddleware(service: 'center' | 'guild', metrics: MetricsService) {
|
||||||
|
return (req: ReqWithId, res: Response, next: NextFunction): void => {
|
||||||
|
const headerId = req.headers['x-request-id'];
|
||||||
|
const requestId =
|
||||||
|
(Array.isArray(headerId) ? headerId[0] : headerId) || randomUUID();
|
||||||
|
|
||||||
|
req.requestId = requestId;
|
||||||
|
res.setHeader('x-request-id', requestId);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const durationMs = Date.now() - startedAt;
|
||||||
|
metrics.record(res.statusCode, durationMs);
|
||||||
|
|
||||||
|
const log = {
|
||||||
|
level: 'info',
|
||||||
|
service,
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
durationMs,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(log));
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/data-source.ts
Normal file
16
src/data-source.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||||
|
import { buildTypeOrmConfig } from './database.config';
|
||||||
|
|
||||||
|
const cfg = buildTypeOrmConfig();
|
||||||
|
|
||||||
|
const options: DataSourceOptions = {
|
||||||
|
...(cfg as Record<string, unknown>),
|
||||||
|
type: 'mysql',
|
||||||
|
migrations: ['src/migrations/*.{ts,js}'],
|
||||||
|
synchronize: false,
|
||||||
|
} as DataSourceOptions;
|
||||||
|
|
||||||
|
export const AppDataSource = new DataSource(options);
|
||||||
|
|
||||||
|
export default AppDataSource;
|
||||||
32
src/database.config.ts
Normal file
32
src/database.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
import { Guild } from './entities/guild.entity';
|
||||||
|
import { Channel } from './entities/channel.entity';
|
||||||
|
import { Message } from './entities/message.entity';
|
||||||
|
import { DmConversation } from './entities/dm-conversation.entity';
|
||||||
|
import { DmParticipant } from './entities/dm-participant.entity';
|
||||||
|
import { GuildRole } from './entities/guild-role.entity';
|
||||||
|
import { GuildMember } from './entities/guild-member.entity';
|
||||||
|
import { GuildMemberRole } from './entities/guild-member-role.entity';
|
||||||
|
import { IdempotencyRecord } from './entities/idempotency-record.entity';
|
||||||
|
|
||||||
|
export const buildTypeOrmConfig = (): TypeOrmModuleOptions => ({
|
||||||
|
type: 'mysql',
|
||||||
|
host: process.env.DB_HOST ?? 'mysql-guild',
|
||||||
|
port: Number(process.env.DB_PORT ?? 3306),
|
||||||
|
username: process.env.DB_USER ?? 'fabric',
|
||||||
|
password: process.env.DB_PASSWORD ?? 'fabric',
|
||||||
|
database: process.env.DB_NAME ?? 'fabric_guild',
|
||||||
|
entities: [
|
||||||
|
Guild,
|
||||||
|
Channel,
|
||||||
|
Message,
|
||||||
|
DmConversation,
|
||||||
|
DmParticipant,
|
||||||
|
GuildRole,
|
||||||
|
GuildMember,
|
||||||
|
GuildMemberRole,
|
||||||
|
IdempotencyRecord,
|
||||||
|
],
|
||||||
|
synchronize: (process.env.DB_SYNC ?? 'true') === 'true',
|
||||||
|
logging: (process.env.DB_LOGGING ?? 'false') === 'true',
|
||||||
|
});
|
||||||
29
src/entities/channel.entity.ts
Normal file
29
src/entities/channel.entity.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('channels')
|
||||||
|
@Index(['guildId', 'createdAt'])
|
||||||
|
export class Channel {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
|
guildId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 16, default: 'text' })
|
||||||
|
kind!: 'text' | 'announcement';
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
isPrivate!: boolean;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ default: 0 })
|
||||||
|
lastSeq!: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
@Index()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
17
src/entities/dm-conversation.entity.ts
Normal file
17
src/entities/dm-conversation.entity.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('dm_conversations')
|
||||||
|
export class DmConversation {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, unique: true })
|
||||||
|
pairKey!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
topic!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
@Index()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
23
src/entities/dm-participant.entity.ts
Normal file
23
src/entities/dm-participant.entity.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('dm_participants')
|
||||||
|
@Index(['conversationId', 'userId'], { unique: true })
|
||||||
|
export class DmParticipant {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
|
conversationId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 64 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 16, default: 'member' })
|
||||||
|
role!: 'member';
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
@Index()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
19
src/entities/guild-member-role.entity.ts
Normal file
19
src/entities/guild-member-role.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('guild_member_roles')
|
||||||
|
export class GuildMemberRole {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
|
guildId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
|
memberId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
|
roleId!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
24
src/entities/guild-member.entity.ts
Normal file
24
src/entities/guild-member.entity.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('guild_members')
|
||||||
|
@Index(['guildId', 'userId'], { unique: true })
|
||||||
|
@Index(['guildId', 'status'])
|
||||||
|
export class GuildMember {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
|
guildId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'varchar', length: 64 })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 16, default: 'active' })
|
||||||
|
status!: 'active' | 'left' | 'blocked';
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
@Index()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
22
src/entities/guild-role.entity.ts
Normal file
22
src/entities/guild-role.entity.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('guild_roles')
|
||||||
|
export class GuildRole {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'char', length: 36 })
|
||||||
|
guildId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64 })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
isSystem!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
19
src/entities/guild.entity.ts
Normal file
19
src/entities/guild.entity.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('guilds')
|
||||||
|
export class Guild {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 120, unique: true })
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||||
|
ownerUserId!: string | null;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
26
src/entities/idempotency-record.entity.ts
Normal file
26
src/entities/idempotency-record.entity.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('idempotency_records')
|
||||||
|
@Index(['scope', 'idempotencyKey'], { unique: true })
|
||||||
|
export class IdempotencyRecord {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64 })
|
||||||
|
scope!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 128 })
|
||||||
|
idempotencyKey!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
responseBody!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
53
src/entities/message.entity.ts
Normal file
53
src/entities/message.entity.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('messages')
|
||||||
|
@Index(['channelId', 'seq'], { unique: true })
|
||||||
|
@Index(['conversationId', 'seq'], { unique: true })
|
||||||
|
@Index(['channelId', 'createdAt'])
|
||||||
|
@Index(['conversationId', 'createdAt'])
|
||||||
|
export class Message {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, unique: true })
|
||||||
|
messageId!: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'char', length: 36, nullable: true })
|
||||||
|
channelId!: string | null;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ type: 'char', length: 36, nullable: true })
|
||||||
|
conversationId!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64 })
|
||||||
|
authorUserId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
seq!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text' })
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||||
|
replyToMessageId!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
mentions!: string[] | null;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
attachments!: Array<{ url: string; name?: string; mimeType?: string }> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
editedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'datetime', nullable: true })
|
||||||
|
deletedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
isDeleted!: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
@Index()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
9
src/events/event-envelope.ts
Normal file
9
src/events/event-envelope.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type FabricEventEnvelope = {
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
occurred_at: string;
|
||||||
|
guild_id: string | null;
|
||||||
|
channel_id: string | null;
|
||||||
|
actor_id: string | null;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
9
src/events/events.module.ts
Normal file
9
src/events/events.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { EventsService } from './events.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [EventsService],
|
||||||
|
exports: [EventsService],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
168
src/events/events.service.ts
Normal file
168
src/events/events.service.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { createHmac, randomUUID } from 'crypto';
|
||||||
|
import { FabricEventEnvelope } from './event-envelope';
|
||||||
|
|
||||||
|
type RetryTask = {
|
||||||
|
envelope: FabricEventEnvelope;
|
||||||
|
attempts: number;
|
||||||
|
nextRunAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventsService {
|
||||||
|
private readonly logger = new Logger(EventsService.name);
|
||||||
|
private readonly sentEventIds = new Map<string, number>();
|
||||||
|
private readonly retryQueue: RetryTask[] = [];
|
||||||
|
private retryTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
private cleanupSentCache(now: number): void {
|
||||||
|
const ttlMs = 10 * 60 * 1000;
|
||||||
|
for (const [eventId, ts] of this.sentEventIds.entries()) {
|
||||||
|
if (now - ts > ttlMs) this.sentEventIds.delete(eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private signWebhook(payload: string, timestamp: string, nonce: string): string {
|
||||||
|
const secret = process.env.FABRIC_WEBHOOK_SECRET;
|
||||||
|
if (!secret) return '';
|
||||||
|
const canonical = ['POST', '/webhook/events', timestamp, nonce, payload].join('\n');
|
||||||
|
return createHmac('sha256', secret).update(canonical).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleRetryPump(): void {
|
||||||
|
if (this.retryTimer) return;
|
||||||
|
this.retryTimer = setInterval(() => {
|
||||||
|
void this.processRetryQueue();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueueRetry(envelope: FabricEventEnvelope, attempts: number): void {
|
||||||
|
if (attempts >= 5) {
|
||||||
|
this.logger.warn(`drop event after max retries: ${envelope.event_id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delayMs = Math.pow(2, attempts) * 1000; // 1s,2s,4s,8s,16s
|
||||||
|
this.retryQueue.push({
|
||||||
|
envelope,
|
||||||
|
attempts: attempts + 1,
|
||||||
|
nextRunAt: Date.now() + delayMs,
|
||||||
|
});
|
||||||
|
this.scheduleRetryPump();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverEnvelope(envelope: FabricEventEnvelope): Promise<boolean> {
|
||||||
|
const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
|
||||||
|
if (!webhookUrl) {
|
||||||
|
this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const nonce = randomUUID();
|
||||||
|
const payload = JSON.stringify(envelope);
|
||||||
|
const signature = this.signWebhook(payload, timestamp, nonce);
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-fabric-version': '1',
|
||||||
|
'x-fabric-timestamp': timestamp,
|
||||||
|
'x-fabric-nonce': nonce,
|
||||||
|
'x-fabric-signature': signature,
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 200 && response.status < 300) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// retry only transient statuses
|
||||||
|
if ([429, 500, 502, 503, 504].includes(response.status)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// permanent failure: don't retry
|
||||||
|
this.logger.warn(`event delivery permanent failure: ${response.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processRetryQueue(): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const due = this.retryQueue.filter((t) => t.nextRunAt <= now);
|
||||||
|
if (due.length === 0) return;
|
||||||
|
|
||||||
|
for (const task of due) {
|
||||||
|
const idx = this.retryQueue.indexOf(task);
|
||||||
|
if (idx >= 0) this.retryQueue.splice(idx, 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const delivered = await this.deliverEnvelope(task.envelope);
|
||||||
|
if (delivered) {
|
||||||
|
this.sentEventIds.set(task.envelope.event_id, Date.now());
|
||||||
|
} else {
|
||||||
|
this.enqueueRetry(task.envelope, task.attempts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`retry delivery failed: ${message}`);
|
||||||
|
this.enqueueRetry(task.envelope, task.attempts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildEnvelope(input: {
|
||||||
|
eventType: string;
|
||||||
|
guildId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
actorId?: string | null;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}): FabricEventEnvelope {
|
||||||
|
return {
|
||||||
|
event_id: randomUUID(),
|
||||||
|
event_type: input.eventType,
|
||||||
|
occurred_at: new Date().toISOString(),
|
||||||
|
guild_id: input.guildId ?? null,
|
||||||
|
channel_id: input.channelId ?? null,
|
||||||
|
actor_id: input.actorId ?? null,
|
||||||
|
data: input.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(input: {
|
||||||
|
eventType: string;
|
||||||
|
guildId?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
actorId?: string | null;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}): Promise<FabricEventEnvelope> {
|
||||||
|
const envelope = this.buildEnvelope(input);
|
||||||
|
const now = Date.now();
|
||||||
|
this.cleanupSentCache(now);
|
||||||
|
|
||||||
|
if (this.sentEventIds.has(envelope.event_id)) {
|
||||||
|
this.logger.warn(`skip duplicate event_id: ${envelope.event_id}`);
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
|
||||||
|
try {
|
||||||
|
const delivered = await this.deliverEnvelope(envelope);
|
||||||
|
if (delivered) {
|
||||||
|
this.sentEventIds.set(envelope.event_id, now);
|
||||||
|
} else {
|
||||||
|
this.enqueueRetry(envelope, 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`event delivery failed: ${message}`);
|
||||||
|
if (webhookUrl) {
|
||||||
|
this.enqueueRetry(envelope, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/guilds/guilds.controller.ts
Normal file
17
src/guilds/guilds.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||||
|
import { GuildsService } from './guilds.service';
|
||||||
|
|
||||||
|
@Controller('guilds')
|
||||||
|
export class GuildsController {
|
||||||
|
constructor(private readonly guildsService: GuildsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.guildsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: Record<string, unknown>) {
|
||||||
|
return this.guildsService.create(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/guilds/guilds.module.ts
Normal file
13
src/guilds/guilds.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { GuildsController } from './guilds.controller';
|
||||||
|
import { Guild } from '../entities/guild.entity';
|
||||||
|
import { GuildsService } from './guilds.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Guild])],
|
||||||
|
controllers: [GuildsController],
|
||||||
|
providers: [GuildsService],
|
||||||
|
exports: [GuildsService],
|
||||||
|
})
|
||||||
|
export class GuildsModule {}
|
||||||
27
src/guilds/guilds.service.ts
Normal file
27
src/guilds/guilds.service.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Guild } from '../entities/guild.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GuildsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Guild)
|
||||||
|
private readonly guildRepo: Repository<Guild>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.guildRepo.find({
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: Partial<Guild>) {
|
||||||
|
const slug = String(input.slug ?? '').trim();
|
||||||
|
const name = String(input.name ?? '').trim();
|
||||||
|
const ownerUserId = input.ownerUserId ? String(input.ownerUserId) : null;
|
||||||
|
const guild = this.guildRepo.create({ slug, name, ownerUserId });
|
||||||
|
return this.guildRepo.save(guild);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/health.integration.spec.ts
Normal file
77
src/health.integration.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Channel } from './entities/channel.entity';
|
||||||
|
|
||||||
|
process.env.DB_HOST = '127.0.0.1';
|
||||||
|
process.env.DB_PORT = '3308';
|
||||||
|
process.env.DB_USER = 'fabric';
|
||||||
|
process.env.DB_PASSWORD = 'fabric';
|
||||||
|
process.env.DB_NAME = 'fabric_guild';
|
||||||
|
process.env.DB_SYNC = 'false';
|
||||||
|
process.env.FABRIC_API_KEY = 'test-api-key';
|
||||||
|
|
||||||
|
describe('guild integration (mysql + api)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let dataSource: DataSource;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { AppModule } = await import('./app.module');
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
await app.init();
|
||||||
|
dataSource = app.get(DataSource);
|
||||||
|
await dataSource.dropDatabase();
|
||||||
|
await dataSource.synchronize();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/healthz returns db ready', async () => {
|
||||||
|
const res = await request(app.getHttpServer()).get('/api/healthz');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
expect(res.body.service).toBe('guild');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('protects non-health endpoints by x-api-key', async () => {
|
||||||
|
const res = await request(app.getHttpServer()).get('/api/channels/non-exist/messages');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports concurrent message writes with continuous seq', async () => {
|
||||||
|
const channelRepo = dataSource.getRepository(Channel);
|
||||||
|
const channel = await channelRepo.save(
|
||||||
|
channelRepo.create({
|
||||||
|
guildId: 'test-guild',
|
||||||
|
name: `concurrency-${Date.now()}`,
|
||||||
|
kind: 'text',
|
||||||
|
isPrivate: false,
|
||||||
|
lastSeq: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const concurrent = 10;
|
||||||
|
const tasks = Array.from({ length: concurrent }, (_, i) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`/api/channels/${channel.id}/messages`)
|
||||||
|
.set('x-api-key', 'test-api-key')
|
||||||
|
.send({ content: `hello-${i + 1}`, authorUserId: 'u1' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await Promise.all(tasks);
|
||||||
|
const seqs = responses.map((r) => r.body.seq).sort((a, b) => a - b);
|
||||||
|
|
||||||
|
expect(responses.every((r) => r.status === 201)).toBe(true);
|
||||||
|
expect(new Set(seqs).size).toBe(concurrent);
|
||||||
|
expect(seqs).toEqual(Array.from({ length: concurrent }, (_, i) => i + 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/main.ts
Normal file
35
src/main.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { createRequestContextMiddleware } from './common/request-context.middleware';
|
||||||
|
import { MetricsService } from './common/metrics.service';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
const metrics = app.get(MetricsService);
|
||||||
|
app.use(createRequestContextMiddleware('guild', metrics));
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('Fabric Backend Guild API')
|
||||||
|
.setDescription('Guild Node APIs for Fabric')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.build();
|
||||||
|
const swaggerDoc = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('docs', app, swaggerDoc);
|
||||||
|
|
||||||
|
const port = process.env.PORT ? Number(process.env.PORT) : 7002;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Fabric.Backend.Guild listening on :${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
59
src/messaging/dto.create-message.dto.ts
Normal file
59
src/messaging/dto.create-message.dto.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
class AttachmentDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
|
url!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateMessageDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
clientMessageId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
replyToMessageId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
mentions?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(10)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AttachmentDto)
|
||||||
|
attachments?: AttachmentDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
authorUserId?: string;
|
||||||
|
}
|
||||||
278
src/messaging/messaging.controller.ts
Normal file
278
src/messaging/messaging.controller.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
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';
|
||||||
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
||||||
|
import { EventsService } from '../events/events.service';
|
||||||
|
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
|
||||||
|
import { RealtimeGateway } from '../realtime/realtime.gateway';
|
||||||
|
|
||||||
|
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>,
|
||||||
|
@InjectRepository(IdempotencyRecord)
|
||||||
|
private readonly idemRepo: Repository<IdempotencyRecord>,
|
||||||
|
private readonly events: EventsService,
|
||||||
|
private readonly realtime: RealtimeGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async getIdempotentResponse(
|
||||||
|
scope: string,
|
||||||
|
idempotencyKey?: string,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
if (!idempotencyKey) return null;
|
||||||
|
const row = await this.idemRepo.findOne({ where: { scope, idempotencyKey } });
|
||||||
|
return row?.responseBody ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveIdempotentResponse(
|
||||||
|
scope: string,
|
||||||
|
idempotencyKey: string | undefined,
|
||||||
|
responseBody: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!idempotencyKey) return;
|
||||||
|
const row = this.idemRepo.create({
|
||||||
|
scope,
|
||||||
|
idempotencyKey,
|
||||||
|
responseBody,
|
||||||
|
});
|
||||||
|
await this.idemRepo.save(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
|
) {
|
||||||
|
const scope = `POST:/channels/${channelId}/messages`;
|
||||||
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
|
if (existed) return existed;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBody = this.toView(message) as Record<string, unknown>;
|
||||||
|
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
||||||
|
|
||||||
|
await this.events.emit({
|
||||||
|
eventType: 'message.created',
|
||||||
|
channelId,
|
||||||
|
actorId: body.authorUserId ?? 'anonymous',
|
||||||
|
data: responseBody,
|
||||||
|
});
|
||||||
|
this.realtime.emitChannelEvent(channelId, 'message.created', responseBody);
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':messageId')
|
||||||
|
async edit(
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
@Body() body: { content?: string },
|
||||||
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
|
) {
|
||||||
|
const scope = `PATCH:/channels/${channelId}/messages/${messageId}`;
|
||||||
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
|
if (existed) return existed;
|
||||||
|
|
||||||
|
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);
|
||||||
|
const responseBody = this.toView(saved) as Record<string, unknown>;
|
||||||
|
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
||||||
|
|
||||||
|
await this.events.emit({
|
||||||
|
eventType: 'message.updated',
|
||||||
|
channelId,
|
||||||
|
actorId: saved.authorUserId,
|
||||||
|
data: responseBody,
|
||||||
|
});
|
||||||
|
this.realtime.emitChannelEvent(channelId, 'message.updated', responseBody);
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':messageId')
|
||||||
|
async remove(
|
||||||
|
@Param('id') channelId: string,
|
||||||
|
@Param('messageId') messageId: string,
|
||||||
|
@Headers('idempotency-key') idempotencyKey?: string,
|
||||||
|
) {
|
||||||
|
const scope = `DELETE:/channels/${channelId}/messages/${messageId}`;
|
||||||
|
const existed = await this.getIdempotentResponse(scope, idempotencyKey);
|
||||||
|
if (existed) return existed;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const responseBody = {
|
||||||
|
status: 'deleted',
|
||||||
|
mode: 'soft',
|
||||||
|
messageId,
|
||||||
|
} as Record<string, unknown>;
|
||||||
|
await this.saveIdempotentResponse(scope, idempotencyKey, responseBody);
|
||||||
|
|
||||||
|
await this.events.emit({
|
||||||
|
eventType: 'message.deleted',
|
||||||
|
channelId,
|
||||||
|
actorId: item.authorUserId,
|
||||||
|
data: {
|
||||||
|
messageId,
|
||||||
|
seq: item.seq,
|
||||||
|
deletedAt: item.deletedAt?.toISOString() ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.realtime.emitChannelEvent(channelId, 'message.deleted', {
|
||||||
|
messageId,
|
||||||
|
seq: item.seq,
|
||||||
|
deletedAt: item.deletedAt?.toISOString() ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 safeLimit = clampLimit(limit, DEFAULT_PAGE_LIMIT, MAX_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));
|
||||||
|
|
||||||
|
const nextExpectedSeq = computeNextExpectedSeq(
|
||||||
|
from,
|
||||||
|
rows.map((row) => row.seq),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
page: {
|
||||||
|
seqFrom: from,
|
||||||
|
seqTo: to,
|
||||||
|
limit: safeLimit,
|
||||||
|
returned: items.length,
|
||||||
|
hasMore: total > items.length,
|
||||||
|
nextExpectedSeq,
|
||||||
|
highestCommittedSeq: channel.lastSeq,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/messaging/messaging.module.ts
Normal file
12
src/messaging/messaging.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { MessagingController } from './messaging.controller';
|
||||||
|
import { Channel } from '../entities/channel.entity';
|
||||||
|
import { Message } from '../entities/message.entity';
|
||||||
|
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Channel, Message, IdempotencyRecord])],
|
||||||
|
controllers: [MessagingController],
|
||||||
|
})
|
||||||
|
export class MessagingModule {}
|
||||||
15
src/messaging/pagination.util.spec.ts
Normal file
15
src/messaging/pagination.util.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
|
||||||
|
|
||||||
|
describe('pagination utils', () => {
|
||||||
|
it('clamps limit safely', () => {
|
||||||
|
expect(clampLimit(undefined, 50, 200)).toBe(50);
|
||||||
|
expect(clampLimit('500', 50, 200)).toBe(200);
|
||||||
|
expect(clampLimit('-1', 50, 200)).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes next expected seq', () => {
|
||||||
|
expect(computeNextExpectedSeq(1, [1, 2, 3])).toBe(4);
|
||||||
|
expect(computeNextExpectedSeq(1, [1, 3, 4])).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/messaging/pagination.util.ts
Normal file
14
src/messaging/pagination.util.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export function clampLimit(input: string | undefined, defaultLimit: number, maxLimit: number): number {
|
||||||
|
const requested = input ? Number(input) : defaultLimit;
|
||||||
|
if (!Number.isFinite(requested) || requested <= 0) return defaultLimit;
|
||||||
|
return Math.min(requested, maxLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeNextExpectedSeq(from: number, seqs: number[]): number {
|
||||||
|
let next = from;
|
||||||
|
for (const seq of seqs) {
|
||||||
|
if (seq > next) break;
|
||||||
|
if (seq === next) next += 1;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
0
src/migrations/.gitkeep
Normal file
0
src/migrations/.gitkeep
Normal file
129
src/realtime/realtime.gateway.ts
Normal file
129
src/realtime/realtime.gateway.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: '/realtime',
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server!: Server;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(RealtimeGateway.name);
|
||||||
|
private readonly onlineUsers = new Set<string>();
|
||||||
|
|
||||||
|
private userIdFromClient(client: Socket): string {
|
||||||
|
const authUser = client.handshake.auth?.userId;
|
||||||
|
const headerUser = client.handshake.headers['x-user-id'];
|
||||||
|
const userId = typeof authUser === 'string' ? authUser : Array.isArray(headerUser) ? headerUser[0] : headerUser;
|
||||||
|
return userId && typeof userId === 'string' && userId.trim() !== '' ? userId : `anon:${client.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnection(client: Socket): void {
|
||||||
|
const expected = process.env.FABRIC_API_KEY;
|
||||||
|
if (!expected) {
|
||||||
|
client.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authKey = client.handshake.auth?.apiKey;
|
||||||
|
const headerKey = client.handshake.headers['x-api-key'];
|
||||||
|
const apiKey = typeof authKey === 'string' ? authKey : Array.isArray(headerKey) ? headerKey[0] : headerKey;
|
||||||
|
|
||||||
|
if (apiKey !== expected) {
|
||||||
|
this.logger.warn(`socket rejected: ${client.id}`);
|
||||||
|
client.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`socket connected: ${client.id}`);
|
||||||
|
|
||||||
|
const userId = this.userIdFromClient(client);
|
||||||
|
client.data.userId = userId;
|
||||||
|
this.onlineUsers.add(userId);
|
||||||
|
this.server.emit('presence.online', {
|
||||||
|
userId,
|
||||||
|
onlineCount: this.onlineUsers.size,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket): void {
|
||||||
|
this.logger.log(`socket disconnected: ${client.id}`);
|
||||||
|
|
||||||
|
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
||||||
|
this.onlineUsers.delete(userId);
|
||||||
|
this.server.emit('presence.offline', {
|
||||||
|
userId,
|
||||||
|
onlineCount: this.onlineUsers.size,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('join_channel')
|
||||||
|
joinChannel(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() body: { channelId?: string },
|
||||||
|
): { ok: boolean } {
|
||||||
|
if (!body?.channelId) return { ok: false };
|
||||||
|
client.join(`channel:${body.channelId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('leave_channel')
|
||||||
|
leaveChannel(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() body: { channelId?: string },
|
||||||
|
): { ok: boolean } {
|
||||||
|
if (!body?.channelId) return { ok: false };
|
||||||
|
client.leave(`channel:${body.channelId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('typing.start')
|
||||||
|
typingStart(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() body: { channelId?: string },
|
||||||
|
): { ok: boolean } {
|
||||||
|
if (!body?.channelId) return { ok: false };
|
||||||
|
|
||||||
|
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
||||||
|
this.server.to(`channel:${body.channelId}`).emit('typing.start', {
|
||||||
|
channelId: body.channelId,
|
||||||
|
userId,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('typing.stop')
|
||||||
|
typingStop(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() body: { channelId?: string },
|
||||||
|
): { ok: boolean } {
|
||||||
|
if (!body?.channelId) return { ok: false };
|
||||||
|
|
||||||
|
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
||||||
|
this.server.to(`channel:${body.channelId}`).emit('typing.stop', {
|
||||||
|
channelId: body.channelId,
|
||||||
|
userId,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
emitChannelEvent(channelId: string, event: string, data: Record<string, unknown>): void {
|
||||||
|
this.server.to(`channel:${channelId}`).emit(event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/realtime/realtime.module.ts
Normal file
9
src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { RealtimeGateway } from './realtime.gateway';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [RealtimeGateway],
|
||||||
|
exports: [RealtimeGateway],
|
||||||
|
})
|
||||||
|
export class RealtimeModule {}
|
||||||
7
tsconfig.build.json
Normal file
7
tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2020",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.spec.ts', 'src/*.integration.spec.ts'],
|
||||||
|
exclude: ['dist/**', 'node_modules/**'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user