init project
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("com.apollographql.apollo3")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.tlm.android"
|
||||
compileSdk = 34
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.tlm.android"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.0.1"
|
||||
val gqlUrl = project.findProperty("TLM_GRAPHQL_URL") as String? ?: "http://10.0.2.2:4000/graphql"
|
||||
buildConfigField("String", "GRAPHQL_URL", "\"$gqlUrl\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apollo {
|
||||
service("tlm") {
|
||||
packageName.set("com.tlm.graphql")
|
||||
schemaFile.set(file("../../../packages/graphql-schema/schema.graphql"))
|
||||
srcDir("src/main/graphql")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
|
||||
implementation("com.apollographql.apollo3:apollo-runtime:3.8.5")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.TLM"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,19 @@
|
||||
query Chats {
|
||||
chats {
|
||||
__typename
|
||||
id
|
||||
... on DirectThread {
|
||||
peer {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
... on Group {
|
||||
title
|
||||
}
|
||||
... on Channel {
|
||||
title
|
||||
}
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
accessToken
|
||||
refreshToken
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation SendMessage($chatId: ID!, $body: String) {
|
||||
sendMessage(chatId: $chatId, body: $body) {
|
||||
id
|
||||
seq
|
||||
body
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.tlm.android
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.apollographql.apollo3.ApolloClient
|
||||
import com.apollographql.apollo3.api.Optional
|
||||
import com.apollographql.apollo3.exception.ApolloException
|
||||
import com.tlm.android.data.OutboxRepository
|
||||
import com.tlm.graphql.ChatsQuery
|
||||
import com.tlm.graphql.LoginMutation
|
||||
import com.tlm.graphql.SendMessageMutation
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val prefs = getSharedPreferences("tlm", Context.MODE_PRIVATE)
|
||||
val ok =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor { chain ->
|
||||
val t = prefs.getString("accessToken", null)
|
||||
val b = chain.request().newBuilder()
|
||||
if (!t.isNullOrBlank()) b.addHeader("Authorization", "Bearer $t")
|
||||
chain.proceed(b.build())
|
||||
}
|
||||
.build()
|
||||
val apollo =
|
||||
ApolloClient.Builder()
|
||||
.serverUrl(BuildConfig.GRAPHQL_URL)
|
||||
.okHttpClient(ok)
|
||||
.build()
|
||||
val outbox = OutboxRepository()
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
val scope = rememberCoroutineScope()
|
||||
val email = remember { mutableStateOf("alice@example.com") }
|
||||
val password = remember { mutableStateOf("password123") }
|
||||
val tokenPresent = remember { mutableStateOf(!prefs.getString("accessToken", null).isNullOrBlank()) }
|
||||
val chats = remember { mutableStateOf<List<ChatsQuery.Chat>>(emptyList()) }
|
||||
val activeId = remember { mutableStateOf<String?>(null) }
|
||||
val draft = remember { mutableStateOf("") }
|
||||
val status = remember { mutableStateOf("") }
|
||||
|
||||
fun loadChats() {
|
||||
scope.launch {
|
||||
try {
|
||||
val res = apollo.query(ChatsQuery()).execute()
|
||||
if (res.data != null) {
|
||||
chats.value = res.data!!.chats.filterNotNull()
|
||||
} else {
|
||||
status.value = res.errors?.joinToString { it.message } ?: "chats error"
|
||||
}
|
||||
} catch (e: ApolloException) {
|
||||
status.value = e.message ?: "network"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(tokenPresent.value) {
|
||||
if (tokenPresent.value) loadChats()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
Text("TLM (dev)", style = MaterialTheme.typography.headlineSmall)
|
||||
if (!tokenPresent.value) {
|
||||
OutlinedTextField(
|
||||
value = email.value,
|
||||
onValueChange = { email.value = it },
|
||||
label = { Text("Email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password.value,
|
||||
onValueChange = { password.value = it },
|
||||
label = { Text("Password") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val res =
|
||||
apollo
|
||||
.mutation(
|
||||
LoginMutation(
|
||||
email = email.value,
|
||||
password = password.value,
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
val t = res.data?.login?.accessToken
|
||||
if (!t.isNullOrBlank()) {
|
||||
prefs.edit().putString("accessToken", t).apply()
|
||||
tokenPresent.value = true
|
||||
status.value = "ok"
|
||||
} else {
|
||||
status.value = res.errors?.joinToString { it.message } ?: "login failed"
|
||||
}
|
||||
} catch (e: ApolloException) {
|
||||
status.value = e.message ?: "network"
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Login")
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Button(onClick = { loadChats() }) { Text("Refresh chats") }
|
||||
Button(
|
||||
onClick = {
|
||||
prefs.edit().remove("accessToken").apply()
|
||||
tokenPresent.value = false
|
||||
},
|
||||
) {
|
||||
Text("Logout")
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(chats.value) { c ->
|
||||
Button(
|
||||
onClick = { activeId.value = c.id },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("${c.__typename} ${c.id} (${c.unreadCount} unread)")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = draft.value,
|
||||
onValueChange = { draft.value = it },
|
||||
label = { Text("Message") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val id = activeId.value
|
||||
if (id == null) {
|
||||
status.value = "pick a chat"
|
||||
return@Button
|
||||
}
|
||||
val body = draft.value
|
||||
outbox.enqueue("send:$id:${body.length}")
|
||||
scope.launch {
|
||||
try {
|
||||
apollo
|
||||
.mutation(
|
||||
SendMessageMutation(
|
||||
chatId = id,
|
||||
body =
|
||||
if (body.isBlank()) Optional.absent()
|
||||
else Optional.present(body),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
draft.value = ""
|
||||
loadChats()
|
||||
} catch (e: ApolloException) {
|
||||
status.value = e.message ?: "send failed"
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
Text(status.value, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.tlm.android.data
|
||||
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
/**
|
||||
* Minimal outbox for optimistic sends; drain with WorkManager in a real build.
|
||||
*/
|
||||
class OutboxRepository {
|
||||
private val q = ConcurrentLinkedQueue<String>()
|
||||
|
||||
fun enqueue(payload: String) {
|
||||
q.add(payload)
|
||||
}
|
||||
|
||||
fun drain(max: Int = 50): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
repeat(max) {
|
||||
val v = q.poll() ?: return out
|
||||
out.add(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.tlm.android.push
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
|
||||
/**
|
||||
* Placeholder for FCM token registration against your GraphQL `registerDevice` mutation.
|
||||
*/
|
||||
class FcmRegistrationWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
override suspend fun doWork(): Result = Result.success()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">TLM</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.TLM" parent="Theme.Material3.DayNight.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
id("com.apollographql.apollo3") version "3.8.5" apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "tlm-android"
|
||||
include(":app")
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@tlm/sample-bot",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import http from "node:http";
|
||||
|
||||
const port = Number(process.env.PORT ?? "9999");
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", (c) => {
|
||||
body += c;
|
||||
});
|
||||
req.on("end", () => {
|
||||
console.log(`[${new Date().toISOString()}] webhook ${body}`);
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("ok");
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "text/plain" });
|
||||
res.end("POST JSON payloads from TLM bot webhooks here.");
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`Sample bot webhook receiver listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TLM Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@tlm/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.11.8",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { gql, useMutation, useQuery, useSubscription } from "@apollo/client";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const LOGIN = gql`
|
||||
mutation Login($email: String!, $password: String!) {
|
||||
login(email: $email, password: $password) {
|
||||
accessToken
|
||||
refreshToken
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const REGISTER = gql`
|
||||
mutation Register(
|
||||
$email: String!
|
||||
$password: String!
|
||||
$displayName: String!
|
||||
) {
|
||||
register(email: $email, password: $password, displayName: $displayName) {
|
||||
accessToken
|
||||
refreshToken
|
||||
user {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ME = gql`
|
||||
query Me {
|
||||
me {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CHATS = gql`
|
||||
query Chats {
|
||||
chats {
|
||||
__typename
|
||||
id
|
||||
... on DirectThread {
|
||||
peer {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
... on Group {
|
||||
title
|
||||
memberCount
|
||||
}
|
||||
... on Channel {
|
||||
title
|
||||
subscriberCount
|
||||
}
|
||||
lastMessage {
|
||||
id
|
||||
body
|
||||
seq
|
||||
}
|
||||
unreadCount
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const MESSAGES = gql`
|
||||
query Messages($chatId: ID!, $first: Int) {
|
||||
messages(chatId: $chatId, first: $first) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
body
|
||||
seq
|
||||
sender {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const SEND = gql`
|
||||
mutation Send($chatId: ID!, $body: String) {
|
||||
sendMessage(chatId: $chatId, body: $body) {
|
||||
id
|
||||
seq
|
||||
body
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CREATE_DM = gql`
|
||||
mutation CreateDm($peerUserId: ID!) {
|
||||
createDirectThread(peerUserId: $peerUserId) {
|
||||
id
|
||||
peer {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ON_CHAT = gql`
|
||||
subscription OnChat($chatId: ID!) {
|
||||
onChatEvent(chatId: $chatId) {
|
||||
type
|
||||
chatId
|
||||
message {
|
||||
id
|
||||
body
|
||||
seq
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function chatLabel(c: {
|
||||
__typename: string;
|
||||
id: string;
|
||||
peer?: { displayName: string };
|
||||
title?: string | null;
|
||||
}) {
|
||||
if (c.__typename === "DirectThread") return c.peer?.displayName ?? c.id;
|
||||
return c.title ?? c.id;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [email, setEmail] = useState("alice@example.com");
|
||||
const [password, setPassword] = useState("password123");
|
||||
const [displayName, setDisplayName] = useState("Alice");
|
||||
const [peerId, setPeerId] = useState("");
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const token = localStorage.getItem("accessToken");
|
||||
const authed = Boolean(token);
|
||||
|
||||
const meQ = useQuery(ME, { skip: !authed });
|
||||
const chatsQ = useQuery(CHATS, { skip: !authed });
|
||||
const msgsQ = useQuery(MESSAGES, {
|
||||
skip: !authed || !activeChatId,
|
||||
variables: { chatId: activeChatId!, first: 50 },
|
||||
});
|
||||
|
||||
const [login, loginM] = useMutation(LOGIN);
|
||||
const [register, regM] = useMutation(REGISTER);
|
||||
const [send, sendM] = useMutation(SEND);
|
||||
const [createDm, dmM] = useMutation(CREATE_DM);
|
||||
|
||||
useSubscription(ON_CHAT, {
|
||||
skip: !authed || !activeChatId,
|
||||
variables: { chatId: activeChatId! },
|
||||
onData: () => {
|
||||
void msgsQ.refetch();
|
||||
void chatsQ.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const sortedEdges = useMemo(() => {
|
||||
const edges = [...(msgsQ.data?.messages.edges ?? [])];
|
||||
edges.sort((a, b) => a.node.seq - b.node.seq);
|
||||
return edges;
|
||||
}, [msgsQ.data]);
|
||||
|
||||
async function doLogin() {
|
||||
const r = await login({ variables: { email, password } });
|
||||
const t = r.data?.login.accessToken;
|
||||
if (t) {
|
||||
localStorage.setItem("accessToken", t);
|
||||
localStorage.setItem("refreshToken", r.data!.login.refreshToken);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
const r = await register({
|
||||
variables: { email, password, displayName },
|
||||
});
|
||||
const t = r.data?.register.accessToken;
|
||||
if (t) {
|
||||
localStorage.setItem("accessToken", t);
|
||||
localStorage.setItem("refreshToken", r.data!.register.refreshToken);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function doCreateDm() {
|
||||
if (!peerId) return;
|
||||
const r = await createDm({ variables: { peerUserId: peerId } });
|
||||
const id = r.data?.createDirectThread.id;
|
||||
if (id) {
|
||||
setActiveChatId(id);
|
||||
await chatsQ.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
if (!activeChatId || !draft.trim()) return;
|
||||
await send({ variables: { chatId: activeChatId, body: draft } });
|
||||
setDraft("");
|
||||
await msgsQ.refetch();
|
||||
await chatsQ.refetch();
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div style={{ maxWidth: 420, margin: "48px auto", fontFamily: "system-ui" }}>
|
||||
<h1>TLM</h1>
|
||||
<label>
|
||||
Email
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={{ width: "100%", display: "block", marginBottom: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={{ width: "100%", display: "block", marginBottom: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Display name (register)
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
style={{ width: "100%", display: "block", marginBottom: 8 }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" onClick={() => void doLogin()}>
|
||||
Login
|
||||
</button>
|
||||
<button type="button" onClick={() => void doRegister()}>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ color: "#666" }}>
|
||||
{(loginM.error ?? regM.error)?.message}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px 1fr",
|
||||
height: "100vh",
|
||||
fontFamily: "system-ui",
|
||||
}}
|
||||
>
|
||||
<aside style={{ borderRight: "1px solid #ddd", padding: 12 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<strong>{meQ.data?.me.displayName}</strong>
|
||||
<button type="button" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<hr />
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
|
||||
<input
|
||||
placeholder="Peer user id"
|
||||
value={peerId}
|
||||
onChange={(e) => setPeerId(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button type="button" onClick={() => void doCreateDm()}>
|
||||
DM
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#666", marginBottom: 8 }}>
|
||||
Your id: {meQ.data?.me.id}
|
||||
</div>
|
||||
<div style={{ overflow: "auto" }}>
|
||||
{(chatsQ.data?.chats ?? []).map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setActiveChatId(c.id)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: 8,
|
||||
marginBottom: 4,
|
||||
border:
|
||||
c.id === activeChatId ? "2px solid #2a6" : "1px solid #eee",
|
||||
background: c.id === activeChatId ? "#f6fff8" : "#fff",
|
||||
}}
|
||||
>
|
||||
<div>{chatLabel(c)}</div>
|
||||
<div style={{ fontSize: 12, color: "#666" }}>
|
||||
unread {c.unreadCount}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
<main style={{ display: "flex", flexDirection: "column" }}>
|
||||
<header style={{ padding: 12, borderBottom: "1px solid #ddd" }}>
|
||||
{activeChatId ? `Chat ${activeChatId}` : "Select a chat"}
|
||||
</header>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 12 }}>
|
||||
{sortedEdges.map((e) => (
|
||||
<div key={e.node.id} style={{ marginBottom: 8 }}>
|
||||
<strong>{e.node.sender?.displayName ?? "?"}</strong>:{" "}
|
||||
{e.node.body}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer style={{ padding: 12, borderTop: "1px solid #ddd" }}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Message"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void doSend();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!activeChatId || sendM.loading}
|
||||
onClick={() => void doSend()}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ApolloClient,
|
||||
HttpLink,
|
||||
InMemoryCache,
|
||||
split,
|
||||
} from "@apollo/client/core";
|
||||
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
|
||||
import { getMainDefinition } from "@apollo/client/utilities";
|
||||
import { createClient } from "graphql-ws";
|
||||
|
||||
const httpUri = import.meta.env.VITE_GRAPHQL_HTTP ?? "/graphql";
|
||||
const wsUri = import.meta.env.VITE_GRAPHQL_WS ?? `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/graphql`;
|
||||
|
||||
function getAuthHeader() {
|
||||
const t = localStorage.getItem("accessToken");
|
||||
return t ? { authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
const httpLink = new HttpLink({
|
||||
uri: httpUri,
|
||||
headers: getAuthHeader,
|
||||
});
|
||||
|
||||
const wsLink = new GraphQLWsLink(
|
||||
createClient({
|
||||
url: wsUri,
|
||||
connectionParams: () => getAuthHeader(),
|
||||
lazy: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const link = split(
|
||||
({ query }) => {
|
||||
const def = getMainDefinition(query);
|
||||
return def.kind === "OperationDefinition" && def.operation === "subscription";
|
||||
},
|
||||
wsLink,
|
||||
httpLink,
|
||||
);
|
||||
|
||||
export const apollo = new ApolloClient({
|
||||
link,
|
||||
cache: new InMemoryCache(),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import App from "./App";
|
||||
import { apollo } from "./apollo";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ApolloProvider client={apollo}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GRAPHQL_HTTP?: string;
|
||||
readonly VITE_GRAPHQL_WS?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/graphql": { target: "http://127.0.0.1:4000", changeOrigin: true, ws: true },
|
||||
"/signal": { target: "ws://127.0.0.1:4000", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user