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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Local stack. At scale, replace Redis pub/sub fan-out with NATS/Kafka (see apps/api/src/pubsub.provider.ts).
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: tlm
|
||||||
|
POSTGRES_PASSWORD: tlm
|
||||||
|
POSTGRES_DB: tlm
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- tlm_pg:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minio
|
||||||
|
MINIO_ROOT_PASSWORD: minio12345
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- tlm_minio:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tlm_pg:
|
||||||
|
tlm_minio:
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "telegram-like-messenger",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"schema:lint": "graphql-schema-linter packages/graphql-schema/schema.graphql",
|
||||||
|
"codegen": "graphql-codegen --config packages/graphql-schema/codegen.ts",
|
||||||
|
"codegen:check": "graphql-codegen --config packages/graphql-schema/codegen.ts",
|
||||||
|
"build": "npm run prisma:generate -w @tlm/api && npm run build -w @tlm/api && npm run build -w @tlm/web",
|
||||||
|
"dev:api": "npm run start:dev -w @tlm/api",
|
||||||
|
"dev:web": "npm run dev -w @tlm/web",
|
||||||
|
"db:generate": "npm run prisma:generate -w @tlm/api",
|
||||||
|
"db:migrate": "npm run prisma:migrate -w @tlm/api"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@graphql-codegen/cli": "^5.0.2",
|
||||||
|
"@graphql-codegen/typescript": "^4.0.9",
|
||||||
|
"graphql": "^16.9.0",
|
||||||
|
"graphql-schema-linter": "^3.0.1",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { CodegenConfig } from "@graphql-codegen/cli";
|
||||||
|
|
||||||
|
const config: CodegenConfig = {
|
||||||
|
schema: "packages/graphql-schema/schema.graphql",
|
||||||
|
generates: {
|
||||||
|
"apps/web/src/generated/schema-types.ts": {
|
||||||
|
plugins: ["typescript"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "@tlm/graphql-schema",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Shared GraphQL schema (SDL)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Single source of truth for Web (Apollo) and Android (Apollo Kotlin)."""
|
||||||
|
scalar DateTime
|
||||||
|
|
||||||
|
enum MessageKind {
|
||||||
|
TEXT
|
||||||
|
MEDIA
|
||||||
|
SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatType {
|
||||||
|
DIRECT
|
||||||
|
GROUP
|
||||||
|
CHANNEL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemberRole {
|
||||||
|
OWNER
|
||||||
|
ADMIN
|
||||||
|
MEMBER
|
||||||
|
SUBSCRIBER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatEventType {
|
||||||
|
MESSAGE_CREATED
|
||||||
|
MESSAGE_UPDATED
|
||||||
|
MESSAGE_DELETED
|
||||||
|
TYPING
|
||||||
|
PRESENCE
|
||||||
|
READ_RECEIPT
|
||||||
|
DELIVERED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountEventType {
|
||||||
|
DEVICE_ADDED
|
||||||
|
DEVICE_REVOKED
|
||||||
|
SESSION_REFRESHED
|
||||||
|
}
|
||||||
|
|
||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
displayName: String!
|
||||||
|
email: String
|
||||||
|
createdAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device {
|
||||||
|
id: ID!
|
||||||
|
userId: ID!
|
||||||
|
name: String
|
||||||
|
lastSeenAt: DateTime
|
||||||
|
createdAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthPayload {
|
||||||
|
accessToken: String!
|
||||||
|
refreshToken: String!
|
||||||
|
user: User!
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaRef {
|
||||||
|
id: ID!
|
||||||
|
mimeType: String!
|
||||||
|
url: String!
|
||||||
|
thumbnailUrl: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message {
|
||||||
|
id: ID!
|
||||||
|
chatId: ID!
|
||||||
|
senderId: ID
|
||||||
|
sender: User
|
||||||
|
kind: MessageKind!
|
||||||
|
body: String
|
||||||
|
mediaRefs: [MediaRef!]!
|
||||||
|
replyToId: ID
|
||||||
|
seq: Int!
|
||||||
|
createdAt: DateTime!
|
||||||
|
editedAt: DateTime
|
||||||
|
deletedAt: DateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageEdge {
|
||||||
|
cursor: String!
|
||||||
|
node: Message!
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageConnection {
|
||||||
|
edges: [MessageEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageInfo {
|
||||||
|
endCursor: String
|
||||||
|
hasNextPage: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatBase {
|
||||||
|
id: ID!
|
||||||
|
type: ChatType!
|
||||||
|
title: String
|
||||||
|
lastMessage: Message
|
||||||
|
unreadCount: Int!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DirectThread implements ChatBase {
|
||||||
|
id: ID!
|
||||||
|
type: ChatType!
|
||||||
|
title: String
|
||||||
|
peer: User!
|
||||||
|
lastMessage: Message
|
||||||
|
unreadCount: Int!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Group implements ChatBase {
|
||||||
|
id: ID!
|
||||||
|
type: ChatType!
|
||||||
|
title: String
|
||||||
|
lastMessage: Message
|
||||||
|
unreadCount: Int!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
memberCount: Int!
|
||||||
|
isBroadcast: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Channel implements ChatBase {
|
||||||
|
id: ID!
|
||||||
|
type: ChatType!
|
||||||
|
title: String
|
||||||
|
lastMessage: Message
|
||||||
|
unreadCount: Int!
|
||||||
|
updatedAt: DateTime!
|
||||||
|
subscriberCount: Int!
|
||||||
|
pinnedMessages: [Message!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
union Chat = DirectThread | Group | Channel
|
||||||
|
|
||||||
|
type PresignedUpload {
|
||||||
|
uploadUrl: String!
|
||||||
|
mediaId: ID!
|
||||||
|
headers: [HttpHeader!]
|
||||||
|
}
|
||||||
|
|
||||||
|
type HttpHeader {
|
||||||
|
name: String!
|
||||||
|
value: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input HttpHeaderInput {
|
||||||
|
name: String!
|
||||||
|
value: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallSession {
|
||||||
|
id: ID!
|
||||||
|
chatId: ID!
|
||||||
|
createdBy: ID!
|
||||||
|
status: CallStatus!
|
||||||
|
iceServers: [IceServer!]!
|
||||||
|
createdAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CallStatus {
|
||||||
|
RINGING
|
||||||
|
ACTIVE
|
||||||
|
ENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
type IceServer {
|
||||||
|
urls: [String!]!
|
||||||
|
username: String
|
||||||
|
credential: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bot {
|
||||||
|
id: ID!
|
||||||
|
displayName: String!
|
||||||
|
tokenHint: String!
|
||||||
|
webhookUrl: String
|
||||||
|
createdAt: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
me: User
|
||||||
|
myDevices: [Device!]!
|
||||||
|
chats: [Chat!]!
|
||||||
|
messages(chatId: ID!, after: String, first: Int = 30): MessageConnection!
|
||||||
|
bot(id: ID!): Bot
|
||||||
|
myBots: [Bot!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
register(email: String!, password: String!, displayName: String!): AuthPayload!
|
||||||
|
login(email: String!, password: String!): AuthPayload!
|
||||||
|
refresh(refreshToken: String!): AuthPayload!
|
||||||
|
registerDevice(name: String): Device!
|
||||||
|
revokeDevice(deviceId: ID!): Boolean!
|
||||||
|
createDirectThread(peerUserId: ID!): DirectThread!
|
||||||
|
createGroup(title: String!, memberIds: [ID!], isBroadcast: Boolean = false): Group!
|
||||||
|
createChannel(title: String!, isPublic: Boolean = false): Channel!
|
||||||
|
joinChannel(channelId: ID!): Boolean!
|
||||||
|
inviteToGroup(groupId: ID!, userIds: [ID!]!): Boolean!
|
||||||
|
setMemberRole(chatId: ID!, userId: ID!, role: MemberRole!): Boolean!
|
||||||
|
pinMessage(chatId: ID!, messageId: ID!): Boolean!
|
||||||
|
unpinMessage(chatId: ID!, messageId: ID!): Boolean!
|
||||||
|
sendMessage(chatId: ID!, body: String, replyToId: ID): Message!
|
||||||
|
editMessage(messageId: ID!, body: String!): Message!
|
||||||
|
deleteMessage(messageId: ID!, forEveryone: Boolean = false): Boolean!
|
||||||
|
markDelivered(chatId: ID!, upToSeq: Int!): Boolean!
|
||||||
|
markRead(chatId: ID!, upToSeq: Int!): Boolean!
|
||||||
|
setTyping(chatId: ID!, isTyping: Boolean!): Boolean!
|
||||||
|
requestMediaUpload(mimeType: String!, bytesHint: Int): PresignedUpload!
|
||||||
|
finalizeMediaUpload(mediaId: ID!, mimeType: String!, sizeBytes: Int!): MediaRef!
|
||||||
|
sendMediaMessage(chatId: ID!, mediaId: ID!, caption: String, replyToId: ID): Message!
|
||||||
|
startCall(chatId: ID!): CallSession!
|
||||||
|
endCall(callId: ID!): Boolean!
|
||||||
|
createBot(displayName: String!, webhookUrl: String): BotTokenPayload!
|
||||||
|
rotateBotToken(botId: ID!): BotTokenPayload!
|
||||||
|
setBotWebhook(botId: ID!, webhookUrl: String): Boolean!
|
||||||
|
addBotToChat(chatId: ID!, botId: ID!): Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type BotTokenPayload {
|
||||||
|
bot: Bot!
|
||||||
|
token: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatEvent {
|
||||||
|
type: ChatEventType!
|
||||||
|
chatId: ID!
|
||||||
|
message: Message
|
||||||
|
userId: ID
|
||||||
|
isTyping: Boolean
|
||||||
|
online: Boolean
|
||||||
|
upToSeq: Int
|
||||||
|
at: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountEvent {
|
||||||
|
type: AccountEventType!
|
||||||
|
deviceId: ID
|
||||||
|
at: DateTime!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscription {
|
||||||
|
onChatEvent(chatId: ID!): ChatEvent!
|
||||||
|
onAccountEvent: AccountEvent!
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user