init project
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
DATABASE_URL=postgresql://tlm:tlm@localhost:5432/tlm
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
JWT_SECRET=change-me-in-production
|
||||
PORT=4000
|
||||
S3_ENDPOINT=http://127.0.0.1:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=tlm-media
|
||||
S3_ACCESS_KEY=minio
|
||||
S3_SECRET_KEY=minio12345
|
||||
PUBLIC_MEDIA_BASE_URL=http://127.0.0.1:9000/tlm-media
|
||||
ICE_URLS=stun:stun.l.google.com:19302
|
||||
@@ -0,0 +1,15 @@
|
||||
import http from "k6/http";
|
||||
import { check } from "k6";
|
||||
|
||||
export const options = {
|
||||
vus: 5,
|
||||
duration: "20s",
|
||||
};
|
||||
|
||||
export default function () {
|
||||
const payload = JSON.stringify({ query: "{ __typename }" });
|
||||
const res = http.post("http://127.0.0.1:4000/graphql", payload, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
check(res, { "status 200": (r) => r.status === 200 });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@tlm/api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:push": "prisma db push"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.11.2",
|
||||
"@nestjs/apollo": "^12.2.1",
|
||||
"@nestjs/common": "^10.4.8",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.8",
|
||||
"@nestjs/graphql": "^12.2.1",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.8",
|
||||
"@nestjs/websockets": "^10.4.8",
|
||||
"@nestjs/platform-ws": "^10.4.8",
|
||||
"ws": "^8.18.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-redis-subscriptions": "^2.6.1",
|
||||
"graphql-subscriptions": "^2.0.0",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"@aws-sdk/client-s3": "^3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.693.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.8",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/ws": "^8.5.13",
|
||||
"prisma": "^5.22.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChatTypeEnum" AS ENUM ('DIRECT', 'GROUP', 'CHANNEL');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Device" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastSeenAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"deviceId" TEXT,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Chat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "ChatTypeEnum" NOT NULL,
|
||||
"title" TEXT,
|
||||
"isBroadcast" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatMember" (
|
||||
"chatId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"lastReadSeq" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastDeliveredSeq" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "ChatMember_pkey" PRIMARY KEY ("chatId","userId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Message" (
|
||||
"id" TEXT NOT NULL,
|
||||
"chatId" TEXT NOT NULL,
|
||||
"senderId" TEXT,
|
||||
"kind" TEXT NOT NULL,
|
||||
"body" TEXT,
|
||||
"replyToId" TEXT,
|
||||
"seq" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"editedAt" TIMESTAMP(3),
|
||||
"deletedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MediaObject" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"s3Key" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER,
|
||||
"thumbnailKey" TEXT,
|
||||
"finalized" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MediaObject_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MessageMedia" (
|
||||
"messageId" TEXT NOT NULL,
|
||||
"mediaId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "MessageMedia_pkey" PRIMARY KEY ("messageId","mediaId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PinnedMessage" (
|
||||
"chatId" TEXT NOT NULL,
|
||||
"messageId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "PinnedMessage_pkey" PRIMARY KEY ("chatId","messageId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CallSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"chatId" TEXT NOT NULL,
|
||||
"createdBy" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "CallSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Bot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ownerUserId" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"webhookUrl" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Bot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BotMember" (
|
||||
"botId" TEXT NOT NULL,
|
||||
"chatId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "BotMember_pkey" PRIMARY KEY ("botId","chatId")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
CREATE UNIQUE INDEX "Message_chatId_seq_key" ON "Message"("chatId", "seq");
|
||||
|
||||
CREATE INDEX "Message_chatId_seq_idx" ON "Message"("chatId", "seq");
|
||||
|
||||
ALTER TABLE "Device" ADD CONSTRAINT "Device_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ChatMember" ADD CONSTRAINT "ChatMember_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ChatMember" ADD CONSTRAINT "ChatMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "MessageMedia" ADD CONSTRAINT "MessageMedia_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "MessageMedia" ADD CONSTRAINT "MessageMedia_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "MediaObject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PinnedMessage" ADD CONSTRAINT "PinnedMessage_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "PinnedMessage" ADD CONSTRAINT "PinnedMessage_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "CallSession" ADD CONSTRAINT "CallSession_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Bot" ADD CONSTRAINT "Bot_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "BotMember" ADD CONSTRAINT "BotMember_botId_fkey" FOREIGN KEY ("botId") REFERENCES "Bot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "BotMember" ADD CONSTRAINT "BotMember_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1 @@
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,152 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
displayName String
|
||||
createdAt DateTime @default(now())
|
||||
devices Device[]
|
||||
refreshTokens RefreshToken[]
|
||||
messages Message[] @relation("MessageSender")
|
||||
memberships ChatMember[]
|
||||
bots Bot[]
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime?
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
deviceId String?
|
||||
tokenHash String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
enum ChatTypeEnum {
|
||||
DIRECT
|
||||
GROUP
|
||||
CHANNEL
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id String @id @default(cuid())
|
||||
type ChatTypeEnum
|
||||
title String?
|
||||
isBroadcast Boolean @default(false)
|
||||
isPublic Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
members ChatMember[]
|
||||
messages Message[]
|
||||
pins PinnedMessage[]
|
||||
calls CallSession[]
|
||||
botMembers BotMember[]
|
||||
}
|
||||
|
||||
model ChatMember {
|
||||
chatId String
|
||||
userId String
|
||||
role String
|
||||
lastReadSeq Int @default(0)
|
||||
lastDeliveredSeq Int @default(0)
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([chatId, userId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
chatId String
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
senderId String?
|
||||
sender User? @relation("MessageSender", fields: [senderId], references: [id])
|
||||
kind String
|
||||
body String?
|
||||
replyToId String?
|
||||
seq Int
|
||||
createdAt DateTime @default(now())
|
||||
editedAt DateTime?
|
||||
deletedAt DateTime?
|
||||
media MessageMedia[]
|
||||
pins PinnedMessage[]
|
||||
|
||||
@@unique([chatId, seq])
|
||||
@@index([chatId, seq])
|
||||
}
|
||||
|
||||
model MediaObject {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
mimeType String
|
||||
s3Key String
|
||||
sizeBytes Int?
|
||||
thumbnailKey String?
|
||||
finalized Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
messageLinks MessageMedia[]
|
||||
}
|
||||
|
||||
model MessageMedia {
|
||||
messageId String
|
||||
mediaId String
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
media MediaObject @relation(fields: [mediaId], references: [id])
|
||||
|
||||
@@id([messageId, mediaId])
|
||||
}
|
||||
|
||||
model PinnedMessage {
|
||||
chatId String
|
||||
messageId String
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([chatId, messageId])
|
||||
}
|
||||
|
||||
model CallSession {
|
||||
id String @id @default(cuid())
|
||||
chatId String
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
createdBy String
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Bot {
|
||||
id String @id @default(cuid())
|
||||
ownerUserId String
|
||||
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||
displayName String
|
||||
tokenHash String
|
||||
webhookUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
memberships BotMember[]
|
||||
}
|
||||
|
||||
model BotMember {
|
||||
botId String
|
||||
chatId String
|
||||
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([botId, chatId])
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { ApolloDriver, ApolloDriverConfig } from "@nestjs/apollo";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { APP_GUARD } from "@nestjs/core";
|
||||
import { GraphQLISODateTime, GraphQLModule } from "@nestjs/graphql";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { join } from "path";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { GqlAuthGuard } from "./auth/gql-auth.guard";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { MessengerResolver } from "./messenger/messenger.resolver";
|
||||
import { MessengerService } from "./messenger/messenger.service";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { PrismaService } from "./prisma/prisma.service";
|
||||
import { pubSubProvider } from "./pubsub.provider";
|
||||
import { SignalGateway } from "./signaling/signal.gateway";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
imports: [AuthModule, PrismaModule],
|
||||
inject: [JwtService, PrismaService],
|
||||
useFactory: async (jwt: JwtService, prisma: PrismaService) => ({
|
||||
typePaths: [
|
||||
join(__dirname, "..", "..", "..", "packages", "graphql-schema", "schema.graphql"),
|
||||
],
|
||||
sortSchema: true,
|
||||
playground: true,
|
||||
introspection: true,
|
||||
subscriptions: {
|
||||
"graphql-ws": true,
|
||||
},
|
||||
resolvers: {
|
||||
DateTime: GraphQLISODateTime,
|
||||
Chat: {
|
||||
__resolveType(value: {
|
||||
__typename?: string;
|
||||
type?: string;
|
||||
}) {
|
||||
if (value.__typename) return value.__typename;
|
||||
if (value.type === "DIRECT") return "DirectThread";
|
||||
if (value.type === "GROUP") return "Group";
|
||||
if (value.type === "CHANNEL") return "Channel";
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
context: async ({ req, connectionParams, extra }: {
|
||||
req?: { headers?: Record<string, string> };
|
||||
connectionParams?: Record<string, string>;
|
||||
extra?: unknown;
|
||||
}) => {
|
||||
const authHeader =
|
||||
req?.headers?.authorization ??
|
||||
connectionParams?.authorization ??
|
||||
connectionParams?.Authorization;
|
||||
const base = req
|
||||
? { ...req, headers: { ...req.headers } }
|
||||
: { headers: {} as Record<string, string> };
|
||||
if (authHeader) base.headers.authorization = authHeader;
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const payload = (await jwt.verifyAsync(
|
||||
authHeader.slice(7),
|
||||
)) as { sub: string; deviceId?: string | null };
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
if (user) {
|
||||
(base as { user?: unknown }).user = {
|
||||
userId: user.id,
|
||||
deviceId: payload.deviceId ?? null,
|
||||
user,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* invalid token */
|
||||
}
|
||||
}
|
||||
return { req: base, connectionParams, extra };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
pubSubProvider,
|
||||
MessengerService,
|
||||
MessengerResolver,
|
||||
SignalGateway,
|
||||
{ provide: APP_GUARD, useClass: GqlAuthGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: "jwt" }),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET ?? "dev-secret-change-me",
|
||||
signOptions: { expiresIn: "15m" },
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import * as bcrypt from "bcryptjs";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
private hashToken(t: string) {
|
||||
return createHash("sha256").update(t).digest("hex");
|
||||
}
|
||||
|
||||
async register(email: string, password: string, displayName: string) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, displayName },
|
||||
});
|
||||
return this.issueTokens(user.id, null);
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
return this.issueTokens(user.id, null);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const hash = this.hashToken(refreshToken);
|
||||
const row = await this.prisma.refreshToken.findFirst({
|
||||
where: { tokenHash: hash, expiresAt: { gt: new Date() } },
|
||||
});
|
||||
if (!row) throw new UnauthorizedException("Invalid refresh");
|
||||
await this.prisma.refreshToken.delete({ where: { id: row.id } });
|
||||
return this.issueTokens(row.userId, row.deviceId);
|
||||
}
|
||||
|
||||
private async issueTokens(userId: string, deviceId: string | null) {
|
||||
const refresh = randomBytes(48).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId,
|
||||
deviceId,
|
||||
tokenHash: this.hashToken(refresh),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
const accessToken = await this.jwt.signAsync(
|
||||
{ sub: userId, deviceId },
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
});
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refresh,
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async validateUser(userId: string) {
|
||||
return this.prisma.user.findUnique({ where: { id: userId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
import { GqlExecutionContext } from "@nestjs/graphql";
|
||||
|
||||
export type AuthUserCtx = {
|
||||
userId: string;
|
||||
deviceId: string | null;
|
||||
user: { id: string; email: string; displayName: string; createdAt: Date };
|
||||
};
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthUserCtx => {
|
||||
const ctx = GqlExecutionContext.create(context).getContext();
|
||||
return ctx.req.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ExecutionContext, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { GqlExecutionContext } from "@nestjs/graphql";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { IS_PUBLIC_KEY } from "./public.decorator";
|
||||
|
||||
@Injectable()
|
||||
export class GqlAuthGuard extends AuthGuard("jwt") {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
getRequest(context: ExecutionContext) {
|
||||
if (context.getType<string>() === "http") {
|
||||
return context.switchToHttp().getRequest();
|
||||
}
|
||||
const ctx = GqlExecutionContext.create(context);
|
||||
const { req, connectionParams, extra } = ctx.getContext();
|
||||
if (req?.headers?.authorization) return req;
|
||||
const auth =
|
||||
connectionParams?.authorization ??
|
||||
connectionParams?.Authorization ??
|
||||
extra?.request?.headers?.authorization;
|
||||
if (auth && req) {
|
||||
req.headers.authorization = auth;
|
||||
}
|
||||
if (auth && !req?.headers?.authorization && extra?.request) {
|
||||
extra.request.headers.authorization = auth;
|
||||
return extra.request;
|
||||
}
|
||||
return req;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
export type JwtPayload = { sub: string; deviceId?: string | null };
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
|
||||
constructor(private readonly auth: AuthService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET ?? "dev-secret-change-me",
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.auth.validateUser(payload.sub);
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return {
|
||||
userId: user.id,
|
||||
deviceId: payload.deviceId ?? null,
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const IS_PUBLIC_KEY = "isPublic";
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Public } from "./auth/public.decorator";
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Public()
|
||||
@Get("health")
|
||||
health() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { WsAdapter } from "@nestjs/platform-ws";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 4000;
|
||||
await app.listen(port);
|
||||
console.log(`API http://localhost:${port}/graphql ws subscriptions graphql-ws`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,412 @@
|
||||
import { Inject, UseGuards } from "@nestjs/common";
|
||||
import { Args, Mutation, Query, Resolver, Subscription } from "@nestjs/graphql";
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { CurrentUser, AuthUserCtx } from "../auth/current-user.decorator";
|
||||
import { GqlAuthGuard } from "../auth/gql-auth.guard";
|
||||
import { Public } from "../auth/public.decorator";
|
||||
import { PUBSUB } from "../pubsub.provider";
|
||||
import { MessengerService } from "./messenger.service";
|
||||
|
||||
const chatTopic = (id: string) => `CHAT_${id}`;
|
||||
const accountTopic = (userId: string) => `ACCOUNT_${userId}`;
|
||||
|
||||
@Resolver()
|
||||
export class MessengerResolver {
|
||||
constructor(
|
||||
private readonly messenger: MessengerService,
|
||||
private readonly auth: AuthService,
|
||||
@Inject(PUBSUB) private readonly pubsub: RedisPubSub,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Mutation("register")
|
||||
async register(
|
||||
@Args("email") email: string,
|
||||
@Args("password") password: string,
|
||||
@Args("displayName") displayName: string,
|
||||
) {
|
||||
return this.auth.register(email, password, displayName);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Mutation("login")
|
||||
async login(@Args("email") email: string, @Args("password") password: string) {
|
||||
return this.auth.login(email, password);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Mutation("refresh")
|
||||
async refresh(@Args("refreshToken") refreshToken: string) {
|
||||
return this.auth.refresh(refreshToken);
|
||||
}
|
||||
|
||||
@Query("me")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async me(@CurrentUser() u: AuthUserCtx) {
|
||||
return {
|
||||
id: u.user.id,
|
||||
displayName: u.user.displayName,
|
||||
email: u.user.email,
|
||||
createdAt: u.user.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Query("myDevices")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async myDevices(@CurrentUser() u: AuthUserCtx) {
|
||||
const rows = await this.messenger.listDevices(u.userId);
|
||||
return rows.map((d) => ({
|
||||
id: d.id,
|
||||
userId: d.userId,
|
||||
name: d.name,
|
||||
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
@Query("chats")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async chats(@CurrentUser() u: AuthUserCtx) {
|
||||
return this.messenger.listChats(u.userId);
|
||||
}
|
||||
|
||||
@Query("messages")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async messages(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("after", { nullable: true }) after?: string,
|
||||
@Args("first", { nullable: true }) first?: number,
|
||||
) {
|
||||
return this.messenger.messagesConnection(chatId, u.userId, after, first ?? 30);
|
||||
}
|
||||
|
||||
@Query("bot")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async bot(@CurrentUser() u: AuthUserCtx, @Args("id") id: string) {
|
||||
const b = await this.messenger.findBotForOwner(u.userId, id);
|
||||
if (!b) return null;
|
||||
return {
|
||||
id: b.id,
|
||||
displayName: b.displayName,
|
||||
tokenHint: this.messenger.botTokenHint(b.id),
|
||||
webhookUrl: b.webhookUrl,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Query("myBots")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async myBots(@CurrentUser() u: AuthUserCtx) {
|
||||
const rows = await this.messenger.listBots(u.userId);
|
||||
return rows.map((b) => ({
|
||||
id: b.id,
|
||||
displayName: b.displayName,
|
||||
tokenHint: this.messenger.botTokenHint(b.id),
|
||||
webhookUrl: b.webhookUrl,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
@Mutation("registerDevice")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async registerDevice(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("name", { nullable: true }) name?: string,
|
||||
) {
|
||||
const d = await this.messenger.registerDevice(u.userId, name ?? null);
|
||||
await this.messenger.publishAccount(u.userId, {
|
||||
type: "DEVICE_ADDED",
|
||||
deviceId: d.id,
|
||||
});
|
||||
return {
|
||||
id: d.id,
|
||||
userId: d.userId,
|
||||
name: d.name,
|
||||
lastSeenAt: d.lastSeenAt?.toISOString() ?? null,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation("revokeDevice")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async revokeDevice(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("deviceId") deviceId: string,
|
||||
) {
|
||||
return this.messenger.revokeDevice(u.userId, deviceId);
|
||||
}
|
||||
|
||||
@Mutation("createDirectThread")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async createDirectThread(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("peerUserId") peerUserId: string,
|
||||
) {
|
||||
return this.messenger.findOrCreateDirect(u.userId, peerUserId);
|
||||
}
|
||||
|
||||
@Mutation("createGroup")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async createGroup(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("title") title: string,
|
||||
@Args("memberIds", { type: () => [String], nullable: true }) memberIds?: string[],
|
||||
@Args("isBroadcast", { nullable: true }) isBroadcast?: boolean,
|
||||
) {
|
||||
return this.messenger.createGroup(u.userId, title, memberIds ?? [], isBroadcast ?? false);
|
||||
}
|
||||
|
||||
@Mutation("createChannel")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async createChannel(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("title") title: string,
|
||||
@Args("isPublic", { nullable: true }) isPublic?: boolean,
|
||||
) {
|
||||
return this.messenger.createChannel(u.userId, title, isPublic ?? false);
|
||||
}
|
||||
|
||||
@Mutation("joinChannel")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async joinChannel(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("channelId") channelId: string,
|
||||
) {
|
||||
return this.messenger.joinChannel(u.userId, channelId);
|
||||
}
|
||||
|
||||
@Mutation("inviteToGroup")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async inviteToGroup(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("groupId") groupId: string,
|
||||
@Args("userIds", { type: () => [String] }) userIds: string[],
|
||||
) {
|
||||
return this.messenger.inviteToGroup(u.userId, groupId, userIds);
|
||||
}
|
||||
|
||||
@Mutation("setMemberRole")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async setMemberRole(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("userId") userId: string,
|
||||
@Args("role") role: string,
|
||||
) {
|
||||
return this.messenger.setMemberRole(u.userId, chatId, userId, role);
|
||||
}
|
||||
|
||||
@Mutation("pinMessage")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async pinMessage(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("messageId") messageId: string,
|
||||
) {
|
||||
return this.messenger.pinMessage(u.userId, chatId, messageId);
|
||||
}
|
||||
|
||||
@Mutation("unpinMessage")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async unpinMessage(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("messageId") messageId: string,
|
||||
) {
|
||||
return this.messenger.unpinMessage(u.userId, chatId, messageId);
|
||||
}
|
||||
|
||||
@Mutation("sendMessage")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async sendMessage(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("body", { nullable: true }) body?: string,
|
||||
@Args("replyToId", { nullable: true }) replyToId?: string,
|
||||
) {
|
||||
return this.messenger.sendText(u.userId, chatId, body, replyToId ?? null);
|
||||
}
|
||||
|
||||
@Mutation("editMessage")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async editMessage(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("messageId") messageId: string,
|
||||
@Args("body") body: string,
|
||||
) {
|
||||
return this.messenger.editMessage(u.userId, messageId, body);
|
||||
}
|
||||
|
||||
@Mutation("deleteMessage")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async deleteMessage(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("messageId") messageId: string,
|
||||
@Args("forEveryone", { nullable: true }) forEveryone?: boolean,
|
||||
) {
|
||||
return this.messenger.deleteMessage(u.userId, messageId, forEveryone ?? false);
|
||||
}
|
||||
|
||||
@Mutation("markDelivered")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async markDelivered(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("upToSeq") upToSeq: number,
|
||||
) {
|
||||
return this.messenger.markDelivered(u.userId, chatId, upToSeq);
|
||||
}
|
||||
|
||||
@Mutation("markRead")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async markRead(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("upToSeq") upToSeq: number,
|
||||
) {
|
||||
return this.messenger.markRead(u.userId, chatId, upToSeq);
|
||||
}
|
||||
|
||||
@Mutation("setTyping")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async setTyping(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("isTyping") isTyping: boolean,
|
||||
) {
|
||||
return this.messenger.setTyping(u.userId, chatId, isTyping);
|
||||
}
|
||||
|
||||
@Mutation("requestMediaUpload")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async requestMediaUpload(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("mimeType") mimeType: string,
|
||||
@Args("bytesHint", { nullable: true }) _bytesHint?: number,
|
||||
) {
|
||||
return this.messenger.requestUpload(u.userId, mimeType);
|
||||
}
|
||||
|
||||
@Mutation("finalizeMediaUpload")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async finalizeMediaUpload(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("mediaId") mediaId: string,
|
||||
@Args("mimeType") mimeType: string,
|
||||
@Args("sizeBytes") sizeBytes: number,
|
||||
) {
|
||||
return this.messenger.finalizeUpload(u.userId, mediaId, mimeType, sizeBytes);
|
||||
}
|
||||
|
||||
@Mutation("sendMediaMessage")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async sendMediaMessage(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("mediaId") mediaId: string,
|
||||
@Args("caption", { nullable: true }) caption?: string,
|
||||
@Args("replyToId", { nullable: true }) replyToId?: string,
|
||||
) {
|
||||
return this.messenger.sendMediaMessage(
|
||||
u.userId,
|
||||
chatId,
|
||||
mediaId,
|
||||
caption ?? null,
|
||||
replyToId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation("startCall")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async startCall(@CurrentUser() u: AuthUserCtx, @Args("chatId") chatId: string) {
|
||||
return this.messenger.startCall(u.userId, chatId);
|
||||
}
|
||||
|
||||
@Mutation("endCall")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async endCall(@CurrentUser() u: AuthUserCtx, @Args("callId") callId: string) {
|
||||
return this.messenger.endCall(u.userId, callId);
|
||||
}
|
||||
|
||||
@Mutation("createBot")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async createBot(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("displayName") displayName: string,
|
||||
@Args("webhookUrl", { nullable: true }) webhookUrl?: string,
|
||||
) {
|
||||
const { bot, token } = await this.messenger.createBot(
|
||||
u.userId,
|
||||
displayName,
|
||||
webhookUrl ?? null,
|
||||
);
|
||||
return {
|
||||
bot: {
|
||||
id: bot.id,
|
||||
displayName: bot.displayName,
|
||||
tokenHint: this.messenger.botTokenHint(bot.id),
|
||||
webhookUrl: bot.webhookUrl,
|
||||
createdAt: bot.createdAt.toISOString(),
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation("rotateBotToken")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async rotateBotToken(@CurrentUser() u: AuthUserCtx, @Args("botId") botId: string) {
|
||||
const { bot, token } = await this.messenger.rotateBotToken(u.userId, botId);
|
||||
return {
|
||||
bot: {
|
||||
id: bot.id,
|
||||
displayName: bot.displayName,
|
||||
tokenHint: this.messenger.botTokenHint(bot.id),
|
||||
webhookUrl: bot.webhookUrl,
|
||||
createdAt: bot.createdAt.toISOString(),
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation("setBotWebhook")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async setBotWebhook(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("botId") botId: string,
|
||||
@Args("webhookUrl") webhookUrl: string,
|
||||
) {
|
||||
return this.messenger.setBotWebhook(u.userId, botId, webhookUrl);
|
||||
}
|
||||
|
||||
@Mutation("addBotToChat")
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async addBotToChat(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
@Args("botId") botId: string,
|
||||
) {
|
||||
return this.messenger.addBotToChat(u.userId, chatId, botId);
|
||||
}
|
||||
|
||||
@Subscription("onChatEvent", {
|
||||
resolve: (payload: { onChatEvent: unknown }) => payload.onChatEvent,
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async onChatEvent(
|
||||
@CurrentUser() u: AuthUserCtx,
|
||||
@Args("chatId") chatId: string,
|
||||
) {
|
||||
await this.messenger.assertMember(chatId, u.userId);
|
||||
return this.pubsub.asyncIterator(chatTopic(chatId));
|
||||
}
|
||||
|
||||
@Subscription("onAccountEvent", {
|
||||
resolve: (payload: { onAccountEvent: unknown }) => payload.onAccountEvent,
|
||||
})
|
||||
@UseGuards(GqlAuthGuard)
|
||||
async onAccountEvent(@CurrentUser() u: AuthUserCtx) {
|
||||
return this.pubsub.asyncIterator(accountTopic(u.userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ChatTypeEnum, Prisma } from "@prisma/client";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PUBSUB } from "../pubsub.provider";
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
|
||||
const chatTopic = (id: string) => `CHAT_${id}`;
|
||||
const accountTopic = (userId: string) => `ACCOUNT_${userId}`;
|
||||
|
||||
@Injectable()
|
||||
export class MessengerService {
|
||||
private s3: S3Client | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(PUBSUB) private readonly pubsub: RedisPubSub,
|
||||
) {
|
||||
const endpoint = process.env.S3_ENDPOINT;
|
||||
if (endpoint) {
|
||||
this.s3 = new S3Client({
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
endpoint,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY ?? "minio",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY ?? "minio12345",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async notifyBots(chatId: string, messagePayload: Record<string, unknown>) {
|
||||
const members = await this.prisma.botMember.findMany({
|
||||
where: { chatId },
|
||||
include: { bot: true },
|
||||
});
|
||||
for (const m of members) {
|
||||
const url = m.bot.webhookUrl;
|
||||
if (!url) continue;
|
||||
const body = JSON.stringify({
|
||||
type: "message",
|
||||
chatId,
|
||||
message: messagePayload,
|
||||
});
|
||||
void fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async member(chatId: string, userId: string) {
|
||||
return this.prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId } },
|
||||
});
|
||||
}
|
||||
|
||||
async assertMember(chatId: string, userId: string) {
|
||||
const m = await this.member(chatId, userId);
|
||||
if (!m) throw new ForbiddenException("Not a member");
|
||||
return m;
|
||||
}
|
||||
|
||||
private roleRank(role: string) {
|
||||
const order = ["SUBSCRIBER", "MEMBER", "ADMIN", "OWNER"];
|
||||
return order.indexOf(role);
|
||||
}
|
||||
|
||||
private canPost(
|
||||
chat: { type: ChatTypeEnum; isBroadcast: boolean },
|
||||
role: string,
|
||||
) {
|
||||
if (chat.type === ChatTypeEnum.CHANNEL) {
|
||||
if (chat.isBroadcast) return this.roleRank(role) >= this.roleRank("ADMIN");
|
||||
return this.roleRank(role) >= this.roleRank("MEMBER");
|
||||
}
|
||||
if (chat.type === ChatTypeEnum.GROUP && chat.isBroadcast) {
|
||||
return this.roleRank(role) >= this.roleRank("ADMIN");
|
||||
}
|
||||
return this.roleRank(role) >= this.roleRank("MEMBER");
|
||||
}
|
||||
|
||||
async publishChat(chatId: string, payload: Record<string, unknown>) {
|
||||
await this.pubsub.publish(chatTopic(chatId), {
|
||||
onChatEvent: { ...payload, at: new Date().toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
async publishAccount(userId: string, payload: Record<string, unknown>) {
|
||||
await this.pubsub.publish(accountTopic(userId), {
|
||||
onAccountEvent: { ...payload, at: new Date().toISOString() },
|
||||
});
|
||||
}
|
||||
|
||||
async listDevices(userId: string) {
|
||||
return this.prisma.device.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async registerDevice(userId: string, name?: string | null) {
|
||||
return this.prisma.device.create({
|
||||
data: { userId, name: name ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeDevice(userId: string, deviceId: string) {
|
||||
const d = await this.prisma.device.findFirst({
|
||||
where: { id: deviceId, userId },
|
||||
});
|
||||
if (!d) throw new NotFoundException();
|
||||
await this.prisma.device.delete({ where: { id: deviceId } });
|
||||
await this.publishAccount(userId, {
|
||||
type: "DEVICE_REVOKED",
|
||||
deviceId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async findOrCreateDirect(userId: string, peerUserId: string) {
|
||||
if (peerUserId === userId) throw new ForbiddenException("No self DM");
|
||||
const peer = await this.prisma.user.findUnique({ where: { id: peerUserId } });
|
||||
if (!peer) throw new NotFoundException("Peer not found");
|
||||
|
||||
const match = await this.prisma.chat.findFirst({
|
||||
where: {
|
||||
type: ChatTypeEnum.DIRECT,
|
||||
AND: [
|
||||
{ members: { some: { userId } } },
|
||||
{ members: { some: { userId: peerUserId } } },
|
||||
],
|
||||
},
|
||||
include: { members: true },
|
||||
});
|
||||
if (match && match.members.length === 2) {
|
||||
return this.toDirectGql(match.id, userId);
|
||||
}
|
||||
|
||||
const chat = await this.prisma.chat.create({
|
||||
data: {
|
||||
type: ChatTypeEnum.DIRECT,
|
||||
members: {
|
||||
create: [
|
||||
{ userId, role: "MEMBER" },
|
||||
{ userId: peerUserId, role: "MEMBER" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.toDirectGql(chat.id, userId);
|
||||
}
|
||||
|
||||
async createGroup(userId: string, title: string, memberIds: string[], isBroadcast: boolean) {
|
||||
const ids = [...new Set([userId, ...memberIds])];
|
||||
const chat = await this.prisma.chat.create({
|
||||
data: {
|
||||
type: ChatTypeEnum.GROUP,
|
||||
title,
|
||||
isBroadcast,
|
||||
members: {
|
||||
create: ids.map((uid) => ({
|
||||
userId: uid,
|
||||
role: uid === userId ? "OWNER" : "MEMBER",
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.toGroupGql(chat.id, userId);
|
||||
}
|
||||
|
||||
async createChannel(userId: string, title: string, isPublic: boolean) {
|
||||
const chat = await this.prisma.chat.create({
|
||||
data: {
|
||||
type: ChatTypeEnum.CHANNEL,
|
||||
title,
|
||||
isPublic,
|
||||
isBroadcast: true,
|
||||
members: {
|
||||
create: [{ userId, role: "OWNER" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.toChannelGql(chat.id, userId);
|
||||
}
|
||||
|
||||
async joinChannel(userId: string, channelId: string) {
|
||||
const chat = await this.prisma.chat.findUnique({ where: { id: channelId } });
|
||||
if (!chat || chat.type !== ChatTypeEnum.CHANNEL) throw new NotFoundException();
|
||||
if (!chat.isPublic) throw new ForbiddenException("Private channel");
|
||||
await this.prisma.chatMember.upsert({
|
||||
where: { chatId_userId: { chatId: channelId, userId } },
|
||||
create: { chatId: channelId, userId, role: "SUBSCRIBER" },
|
||||
update: {},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async inviteToGroup(actorId: string, groupId: string, userIds: string[]) {
|
||||
const chat = await this.prisma.chat.findUnique({ where: { id: groupId } });
|
||||
if (!chat || chat.type !== ChatTypeEnum.GROUP) throw new NotFoundException();
|
||||
const actor = await this.assertMember(groupId, actorId);
|
||||
if (this.roleRank(actor.role) < this.roleRank("ADMIN")) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
for (const uid of userIds) {
|
||||
await this.prisma.chatMember.upsert({
|
||||
where: { chatId_userId: { chatId: groupId, userId: uid } },
|
||||
create: { chatId: groupId, userId: uid, role: "MEMBER" },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async setMemberRole(
|
||||
actorId: string,
|
||||
chatId: string,
|
||||
targetUserId: string,
|
||||
role: string,
|
||||
) {
|
||||
const chat = await this.prisma.chat.findUnique({ where: { id: chatId } });
|
||||
if (!chat) throw new NotFoundException();
|
||||
const actor = await this.assertMember(chatId, actorId);
|
||||
if (this.roleRank(actor.role) < this.roleRank("OWNER")) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.prisma.chatMember.update({
|
||||
where: { chatId_userId: { chatId, userId: targetUserId } },
|
||||
data: { role },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async pinMessage(actorId: string, chatId: string, messageId: string) {
|
||||
const actor = await this.assertMember(chatId, actorId);
|
||||
if (this.roleRank(actor.role) < this.roleRank("ADMIN")) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.prisma.pinnedMessage.create({
|
||||
data: { chatId, messageId },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async unpinMessage(actorId: string, chatId: string, messageId: string) {
|
||||
const actor = await this.assertMember(chatId, actorId);
|
||||
if (this.roleRank(actor.role) < this.roleRank("ADMIN")) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
await this.prisma.pinnedMessage.deleteMany({
|
||||
where: { chatId, messageId },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async listChats(userId: string) {
|
||||
const memberships = await this.prisma.chatMember.findMany({
|
||||
where: { userId },
|
||||
include: { chat: true },
|
||||
});
|
||||
const out: unknown[] = [];
|
||||
for (const m of memberships) {
|
||||
if (m.chat.type === ChatTypeEnum.DIRECT) {
|
||||
out.push(await this.toDirectGql(m.chatId, userId));
|
||||
} else if (m.chat.type === ChatTypeEnum.GROUP) {
|
||||
out.push(await this.toGroupGql(m.chatId, userId));
|
||||
} else {
|
||||
out.push(await this.toChannelGql(m.chatId, userId));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private async lastMessage(chatId: string) {
|
||||
const msg = await this.prisma.message.findFirst({
|
||||
where: { chatId, deletedAt: null },
|
||||
orderBy: { seq: "desc" },
|
||||
include: { sender: true, media: { include: { media: true } } },
|
||||
});
|
||||
if (!msg) return null;
|
||||
return this.toMessageGql(msg);
|
||||
}
|
||||
|
||||
private unreadCount(chatId: string, userId: string, lastReadSeq: number) {
|
||||
return this.prisma.message.count({
|
||||
where: {
|
||||
chatId,
|
||||
deletedAt: null,
|
||||
seq: { gt: lastReadSeq },
|
||||
NOT: { senderId: userId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async toDirectGql(chatId: string, viewerId: string) {
|
||||
const chat = await this.prisma.chat.findUniqueOrThrow({
|
||||
where: { id: chatId },
|
||||
include: { members: { include: { user: true } } },
|
||||
});
|
||||
const other = chat.members.find((m) => m.userId !== viewerId)?.user;
|
||||
if (!other) throw new Error("Invalid DM");
|
||||
const mem = await this.member(chatId, viewerId);
|
||||
const ur = mem ? await this.unreadCount(chatId, viewerId, mem.lastReadSeq) : 0;
|
||||
return {
|
||||
__typename: "DirectThread" as const,
|
||||
id: chat.id,
|
||||
type: "DIRECT",
|
||||
title: null,
|
||||
peer: {
|
||||
id: other.id,
|
||||
displayName: other.displayName,
|
||||
email: other.email,
|
||||
createdAt: other.createdAt.toISOString(),
|
||||
},
|
||||
lastMessage: await this.lastMessage(chatId),
|
||||
unreadCount: ur,
|
||||
updatedAt: chat.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async toGroupGql(chatId: string, viewerId: string) {
|
||||
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
|
||||
const mem = await this.member(chatId, viewerId);
|
||||
const ur = mem ? await this.unreadCount(chatId, viewerId, mem.lastReadSeq) : 0;
|
||||
const memberCount = await this.prisma.chatMember.count({ where: { chatId } });
|
||||
return {
|
||||
__typename: "Group" as const,
|
||||
id: chat.id,
|
||||
type: "GROUP",
|
||||
title: chat.title,
|
||||
lastMessage: await this.lastMessage(chatId),
|
||||
unreadCount: ur,
|
||||
updatedAt: chat.updatedAt.toISOString(),
|
||||
memberCount,
|
||||
isBroadcast: chat.isBroadcast,
|
||||
};
|
||||
}
|
||||
|
||||
async toChannelGql(chatId: string, viewerId: string) {
|
||||
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
|
||||
const mem = await this.member(chatId, viewerId);
|
||||
const ur = mem ? await this.unreadCount(chatId, viewerId, mem.lastReadSeq) : 0;
|
||||
const subscriberCount = await this.prisma.chatMember.count({ where: { chatId } });
|
||||
const pins = await this.prisma.pinnedMessage.findMany({
|
||||
where: { chatId },
|
||||
include: { message: { include: { sender: true, media: { include: { media: true } } } } },
|
||||
});
|
||||
const pinnedMessages = await Promise.all(
|
||||
pins.map((p) => this.toMessageGql(p.message)),
|
||||
);
|
||||
return {
|
||||
__typename: "Channel" as const,
|
||||
id: chat.id,
|
||||
type: "CHANNEL",
|
||||
title: chat.title,
|
||||
lastMessage: await this.lastMessage(chatId),
|
||||
unreadCount: ur,
|
||||
updatedAt: chat.updatedAt.toISOString(),
|
||||
subscriberCount,
|
||||
pinnedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
async toMessageGql(
|
||||
msg: Prisma.MessageGetPayload<{
|
||||
include: { sender: true; media: { include: { media: true } } };
|
||||
}>,
|
||||
) {
|
||||
const mediaRefs = await Promise.all(
|
||||
msg.media.map(async (mm) => {
|
||||
const url = await this.publicUrlForMedia(mm.media);
|
||||
const thumb = mm.media.thumbnailKey
|
||||
? await this.publicUrlForKey(mm.media.thumbnailKey)
|
||||
: null;
|
||||
return {
|
||||
id: mm.media.id,
|
||||
mimeType: mm.media.mimeType,
|
||||
url,
|
||||
thumbnailUrl: thumb,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: msg.id,
|
||||
chatId: msg.chatId,
|
||||
senderId: msg.senderId,
|
||||
sender: msg.sender
|
||||
? {
|
||||
id: msg.sender.id,
|
||||
displayName: msg.sender.displayName,
|
||||
email: msg.sender.email,
|
||||
createdAt: msg.sender.createdAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
kind: msg.kind,
|
||||
body: msg.body,
|
||||
mediaRefs,
|
||||
replyToId: msg.replyToId,
|
||||
seq: msg.seq,
|
||||
createdAt: msg.createdAt.toISOString(),
|
||||
editedAt: msg.editedAt?.toISOString() ?? null,
|
||||
deletedAt: msg.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async publicUrlForMedia(m: { s3Key: string }) {
|
||||
return this.publicUrlForKey(m.s3Key);
|
||||
}
|
||||
|
||||
private async publicUrlForKey(key: string) {
|
||||
const base = process.env.PUBLIC_MEDIA_BASE_URL;
|
||||
const bucket = process.env.S3_BUCKET ?? "tlm-media";
|
||||
if (base) return `${base.replace(/\/$/, "")}/${key}`;
|
||||
if (this.s3) {
|
||||
const endpoint = process.env.S3_ENDPOINT ?? "";
|
||||
return `${endpoint.replace(/\/$/, "")}/${bucket}/${key}`;
|
||||
}
|
||||
return `media://${key}`;
|
||||
}
|
||||
|
||||
async messagesConnection(chatId: string, userId: string, after?: string | null, first = 30) {
|
||||
await this.assertMember(chatId, userId);
|
||||
const take = Math.min(first, 100);
|
||||
const cursorSeq = after ? Number(Buffer.from(after, "base64").toString("utf8")) : null;
|
||||
const where: Prisma.MessageWhereInput = {
|
||||
chatId,
|
||||
deletedAt: null,
|
||||
...(cursorSeq !== null && !Number.isNaN(cursorSeq) ? { seq: { lt: cursorSeq } } : {}),
|
||||
};
|
||||
const rows = await this.prisma.message.findMany({
|
||||
where,
|
||||
orderBy: { seq: "desc" },
|
||||
take: take + 1,
|
||||
include: { sender: true, media: { include: { media: true } } },
|
||||
});
|
||||
const hasNextPage = rows.length > take;
|
||||
const slice = hasNextPage ? rows.slice(0, take) : rows;
|
||||
const edges = await Promise.all(
|
||||
slice.map(async (m) => ({
|
||||
cursor: Buffer.from(String(m.seq), "utf8").toString("base64"),
|
||||
node: await this.toMessageGql(m),
|
||||
})),
|
||||
);
|
||||
const endCursor = edges.length ? edges[edges.length - 1].cursor : null;
|
||||
return {
|
||||
edges,
|
||||
pageInfo: { endCursor, hasNextPage },
|
||||
};
|
||||
}
|
||||
|
||||
async sendText(userId: string, chatId: string, body?: string | null, replyToId?: string | null) {
|
||||
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
|
||||
const mem = await this.assertMember(chatId, userId);
|
||||
if (!this.canPost(chat, mem.role)) throw new ForbiddenException("Cannot post");
|
||||
const msg = await this.prisma.$transaction(async (tx) => {
|
||||
const agg = await tx.message.aggregate({
|
||||
where: { chatId },
|
||||
_max: { seq: true },
|
||||
});
|
||||
const seq = (agg._max.seq ?? 0) + 1;
|
||||
return tx.message.create({
|
||||
data: {
|
||||
chatId,
|
||||
senderId: userId,
|
||||
kind: "TEXT",
|
||||
body: body ?? "",
|
||||
replyToId: replyToId ?? undefined,
|
||||
seq,
|
||||
},
|
||||
include: { sender: true, media: { include: { media: true } } },
|
||||
});
|
||||
});
|
||||
const gql = await this.toMessageGql(msg);
|
||||
await this.publishChat(chatId, {
|
||||
type: "MESSAGE_CREATED",
|
||||
chatId,
|
||||
message: gql,
|
||||
});
|
||||
void this.notifyBots(chatId, gql);
|
||||
return gql;
|
||||
}
|
||||
|
||||
async sendMediaMessage(
|
||||
userId: string,
|
||||
chatId: string,
|
||||
mediaId: string,
|
||||
caption?: string | null,
|
||||
replyToId?: string | null,
|
||||
) {
|
||||
const chat = await this.prisma.chat.findUniqueOrThrow({ where: { id: chatId } });
|
||||
const mem = await this.assertMember(chatId, userId);
|
||||
if (!this.canPost(chat, mem.role)) throw new ForbiddenException();
|
||||
const media = await this.prisma.mediaObject.findFirst({
|
||||
where: { id: mediaId, userId, finalized: true },
|
||||
});
|
||||
if (!media) throw new NotFoundException("Media not ready");
|
||||
const msg = await this.prisma.$transaction(async (tx) => {
|
||||
const agg = await tx.message.aggregate({
|
||||
where: { chatId },
|
||||
_max: { seq: true },
|
||||
});
|
||||
const seq = (agg._max.seq ?? 0) + 1;
|
||||
const m = await tx.message.create({
|
||||
data: {
|
||||
chatId,
|
||||
senderId: userId,
|
||||
kind: "MEDIA",
|
||||
body: caption ?? "",
|
||||
replyToId: replyToId ?? undefined,
|
||||
seq,
|
||||
media: { create: [{ mediaId: media.id }] },
|
||||
},
|
||||
include: { sender: true, media: { include: { media: true } } },
|
||||
});
|
||||
return m;
|
||||
});
|
||||
const gql = await this.toMessageGql(msg);
|
||||
await this.publishChat(chatId, {
|
||||
type: "MESSAGE_CREATED",
|
||||
chatId,
|
||||
message: gql,
|
||||
});
|
||||
void this.notifyBots(chatId, gql);
|
||||
return gql;
|
||||
}
|
||||
|
||||
async editMessage(userId: string, messageId: string, body: string) {
|
||||
const msg = await this.prisma.message.findUniqueOrThrow({ where: { id: messageId } });
|
||||
if (msg.senderId !== userId) throw new ForbiddenException();
|
||||
const updated = await this.prisma.message.update({
|
||||
where: { id: messageId },
|
||||
data: { body, editedAt: new Date() },
|
||||
include: { sender: true, media: { include: { media: true } } },
|
||||
});
|
||||
const gql = await this.toMessageGql(updated);
|
||||
await this.publishChat(msg.chatId, {
|
||||
type: "MESSAGE_UPDATED",
|
||||
chatId: msg.chatId,
|
||||
message: gql,
|
||||
});
|
||||
return gql;
|
||||
}
|
||||
|
||||
async deleteMessage(userId: string, messageId: string, forEveryone: boolean) {
|
||||
const msg = await this.prisma.message.findUniqueOrThrow({ where: { id: messageId } });
|
||||
const mem = await this.assertMember(msg.chatId, userId);
|
||||
const isOwner = msg.senderId === userId;
|
||||
if (!forEveryone && !isOwner) throw new ForbiddenException();
|
||||
if (forEveryone && this.roleRank(mem.role) < this.roleRank("ADMIN")) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
const deleted = await this.prisma.message.update({
|
||||
where: { id: messageId },
|
||||
data: { deletedAt: new Date(), body: null },
|
||||
include: { sender: true, media: { include: { media: true } } },
|
||||
});
|
||||
const gql = await this.toMessageGql(deleted);
|
||||
await this.publishChat(msg.chatId, {
|
||||
type: "MESSAGE_DELETED",
|
||||
chatId: msg.chatId,
|
||||
message: gql,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async markDelivered(userId: string, chatId: string, upToSeq: number) {
|
||||
await this.assertMember(chatId, userId);
|
||||
await this.prisma.chatMember.update({
|
||||
where: { chatId_userId: { chatId, userId } },
|
||||
data: { lastDeliveredSeq: upToSeq },
|
||||
});
|
||||
await this.publishChat(chatId, {
|
||||
type: "DELIVERED",
|
||||
chatId,
|
||||
userId,
|
||||
upToSeq,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async markRead(userId: string, chatId: string, upToSeq: number) {
|
||||
await this.assertMember(chatId, userId);
|
||||
await this.prisma.chatMember.update({
|
||||
where: { chatId_userId: { chatId, userId } },
|
||||
data: { lastReadSeq: upToSeq },
|
||||
});
|
||||
await this.publishChat(chatId, {
|
||||
type: "READ_RECEIPT",
|
||||
chatId,
|
||||
userId,
|
||||
upToSeq,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async setTyping(userId: string, chatId: string, isTyping: boolean) {
|
||||
await this.assertMember(chatId, userId);
|
||||
await this.publishChat(chatId, {
|
||||
type: "TYPING",
|
||||
chatId,
|
||||
userId,
|
||||
isTyping,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async requestUpload(userId: string, mimeType: string) {
|
||||
if (!this.s3) throw new ForbiddenException("S3 not configured");
|
||||
const id = randomBytes(8).toString("hex");
|
||||
const key = `uploads/${userId}/${id}`;
|
||||
await this.prisma.mediaObject.create({
|
||||
data: {
|
||||
id,
|
||||
userId,
|
||||
mimeType,
|
||||
s3Key: key,
|
||||
finalized: false,
|
||||
},
|
||||
});
|
||||
const bucket = process.env.S3_BUCKET ?? "tlm-media";
|
||||
const cmd = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: mimeType,
|
||||
});
|
||||
const uploadUrl = await getSignedUrl(this.s3, cmd, { expiresIn: 3600 });
|
||||
return { uploadUrl, mediaId: id, headers: [] as { name: string; value: string }[] };
|
||||
}
|
||||
|
||||
async finalizeUpload(userId: string, mediaId: string, mimeType: string, sizeBytes: number) {
|
||||
const m = await this.prisma.mediaObject.updateMany({
|
||||
where: { id: mediaId, userId },
|
||||
data: { finalized: true, mimeType, sizeBytes },
|
||||
});
|
||||
if (m.count === 0) throw new NotFoundException();
|
||||
const row = await this.prisma.mediaObject.findUniqueOrThrow({ where: { id: mediaId } });
|
||||
return {
|
||||
id: row.id,
|
||||
mimeType: row.mimeType,
|
||||
url: await this.publicUrlForMedia(row),
|
||||
thumbnailUrl: row.thumbnailKey
|
||||
? await this.publicUrlForKey(row.thumbnailKey)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async startCall(userId: string, chatId: string) {
|
||||
await this.assertMember(chatId, userId);
|
||||
const session = await this.prisma.callSession.create({
|
||||
data: { chatId, createdBy: userId, status: "RINGING" },
|
||||
});
|
||||
return this.formatCall(session);
|
||||
}
|
||||
|
||||
async endCall(userId: string, callId: string) {
|
||||
const c = await this.prisma.callSession.findUnique({ where: { id: callId } });
|
||||
if (!c) throw new NotFoundException();
|
||||
await this.assertMember(c.chatId, userId);
|
||||
await this.prisma.callSession.update({
|
||||
where: { id: callId },
|
||||
data: { status: "ENDED" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
formatCall(session: { id: string; chatId: string; createdBy: string; status: string; createdAt: Date }) {
|
||||
const urls = (process.env.ICE_URLS ?? "stun:stun.l.google.com:19302").split(",");
|
||||
return {
|
||||
id: session.id,
|
||||
chatId: session.chatId,
|
||||
createdBy: session.createdBy,
|
||||
status: session.status,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
iceServers: urls.map((u) => ({ urls: [u.trim()] })),
|
||||
};
|
||||
}
|
||||
|
||||
async createBot(ownerId: string, displayName: string, webhookUrl?: string | null) {
|
||||
const token = `bot_${randomBytes(24).toString("hex")}`;
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const bot = await this.prisma.bot.create({
|
||||
data: {
|
||||
ownerUserId: ownerId,
|
||||
displayName,
|
||||
tokenHash,
|
||||
webhookUrl: webhookUrl ?? null,
|
||||
},
|
||||
});
|
||||
return { bot, token };
|
||||
}
|
||||
|
||||
async rotateBotToken(ownerId: string, botId: string) {
|
||||
const bot = await this.prisma.bot.findFirst({
|
||||
where: { id: botId, ownerUserId: ownerId },
|
||||
});
|
||||
if (!bot) throw new NotFoundException();
|
||||
const token = `bot_${randomBytes(24).toString("hex")}`;
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const updated = await this.prisma.bot.update({
|
||||
where: { id: botId },
|
||||
data: { tokenHash },
|
||||
});
|
||||
return { bot: updated, token };
|
||||
}
|
||||
|
||||
async setBotWebhook(ownerId: string, botId: string, webhookUrl: string) {
|
||||
const bot = await this.prisma.bot.findFirst({
|
||||
where: { id: botId, ownerUserId: ownerId },
|
||||
});
|
||||
if (!bot) throw new NotFoundException();
|
||||
await this.prisma.bot.update({
|
||||
where: { id: botId },
|
||||
data: { webhookUrl },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async addBotToChat(ownerId: string, chatId: string, botId: string) {
|
||||
const bot = await this.prisma.bot.findFirst({
|
||||
where: { id: botId, ownerUserId: ownerId },
|
||||
});
|
||||
if (!bot) throw new ForbiddenException();
|
||||
await this.assertMember(chatId, ownerId);
|
||||
await this.prisma.botMember.upsert({
|
||||
where: { botId_chatId: { botId, chatId } },
|
||||
create: { botId, chatId },
|
||||
update: {},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
botTokenHint(botId: string) {
|
||||
return `${botId.slice(0, 4)}…`;
|
||||
}
|
||||
|
||||
async findBotForOwner(ownerId: string, botId: string) {
|
||||
return this.prisma.bot.findFirst({ where: { id: botId, ownerUserId: ownerId } });
|
||||
}
|
||||
|
||||
async listBots(ownerId: string) {
|
||||
return this.prisma.bot.findMany({ where: { ownerUserId: ownerId } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { PrismaService } from "./prisma.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Provider } from "@nestjs/common";
|
||||
import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import Redis from "ioredis";
|
||||
|
||||
export const PUBSUB = "PUBSUB";
|
||||
|
||||
/**
|
||||
* For higher fan-out, swap `RedisPubSub` for NATS/Kafka-backed fan-out and keep the
|
||||
* same topic naming (`CHAT_${chatId}`, `ACCOUNT_${userId}`) so resolvers stay stable.
|
||||
*/
|
||||
export const pubSubProvider: Provider = {
|
||||
provide: PUBSUB,
|
||||
useFactory: () => {
|
||||
const host = process.env.REDIS_HOST ?? "127.0.0.1";
|
||||
const port = Number(process.env.REDIS_PORT ?? "6379");
|
||||
const options = { host, port };
|
||||
return new RedisPubSub({
|
||||
publisher: new Redis(options),
|
||||
subscriber: new Redis(options),
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from "@nestjs/websockets";
|
||||
import { IncomingMessage } from "http";
|
||||
import { WebSocket, Server } from "ws";
|
||||
|
||||
type Client = WebSocket & { callId?: string };
|
||||
|
||||
@WebSocketGateway({ path: "/signal" })
|
||||
export class SignalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(SignalGateway.name);
|
||||
private readonly rooms = new Map<string, Set<Client>>();
|
||||
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
handleConnection(client: Client, req: IncomingMessage) {
|
||||
client.on("message", (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw)) as {
|
||||
type?: string;
|
||||
callId?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
if (msg.type === "join" && msg.callId) {
|
||||
this.leave(client);
|
||||
client.callId = msg.callId;
|
||||
if (!this.rooms.has(msg.callId)) this.rooms.set(msg.callId, new Set());
|
||||
this.rooms.get(msg.callId)!.add(client);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "signal" && client.callId) {
|
||||
const room = this.rooms.get(client.callId);
|
||||
if (!room) return;
|
||||
const data = JSON.stringify({
|
||||
type: "signal",
|
||||
payload: msg.payload,
|
||||
});
|
||||
for (const peer of room) {
|
||||
if (peer !== client && peer.readyState === WebSocket.OPEN) {
|
||||
peer.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Bad WS message: ${e}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(client: Client) {
|
||||
this.leave(client);
|
||||
}
|
||||
|
||||
private leave(client: Client) {
|
||||
if (!client.callId) return;
|
||||
const room = this.rooms.get(client.callId);
|
||||
if (!room) return;
|
||||
room.delete(client);
|
||||
if (room.size === 0) this.rooms.delete(client.callId);
|
||||
client.callId = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user