diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts new file mode 100644 index 0000000..fe5f214 --- /dev/null +++ b/apps/android/app/build.gradle.kts @@ -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") +} diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2498026 --- /dev/null +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/apps/android/app/src/main/graphql/Chats.graphql b/apps/android/app/src/main/graphql/Chats.graphql new file mode 100644 index 0000000..5f6153e --- /dev/null +++ b/apps/android/app/src/main/graphql/Chats.graphql @@ -0,0 +1,19 @@ +query Chats { + chats { + __typename + id + ... on DirectThread { + peer { + id + displayName + } + } + ... on Group { + title + } + ... on Channel { + title + } + unreadCount + } +} diff --git a/apps/android/app/src/main/graphql/Login.graphql b/apps/android/app/src/main/graphql/Login.graphql new file mode 100644 index 0000000..8cc366b --- /dev/null +++ b/apps/android/app/src/main/graphql/Login.graphql @@ -0,0 +1,10 @@ +mutation Login($email: String!, $password: String!) { + login(email: $email, password: $password) { + accessToken + refreshToken + user { + id + displayName + } + } +} diff --git a/apps/android/app/src/main/graphql/SendMessage.graphql b/apps/android/app/src/main/graphql/SendMessage.graphql new file mode 100644 index 0000000..128dc2c --- /dev/null +++ b/apps/android/app/src/main/graphql/SendMessage.graphql @@ -0,0 +1,7 @@ +mutation SendMessage($chatId: ID!, $body: String) { + sendMessage(chatId: $chatId, body: $body) { + id + seq + body + } +} diff --git a/apps/android/app/src/main/java/com/tlm/android/MainActivity.kt b/apps/android/app/src/main/java/com/tlm/android/MainActivity.kt new file mode 100644 index 0000000..d4cb0ec --- /dev/null +++ b/apps/android/app/src/main/java/com/tlm/android/MainActivity.kt @@ -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>(emptyList()) } + val activeId = remember { mutableStateOf(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)) + } + } + } + } +} diff --git a/apps/android/app/src/main/java/com/tlm/android/data/OutboxRepository.kt b/apps/android/app/src/main/java/com/tlm/android/data/OutboxRepository.kt new file mode 100644 index 0000000..6f70be3 --- /dev/null +++ b/apps/android/app/src/main/java/com/tlm/android/data/OutboxRepository.kt @@ -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() + + fun enqueue(payload: String) { + q.add(payload) + } + + fun drain(max: Int = 50): List { + val out = ArrayList() + repeat(max) { + val v = q.poll() ?: return out + out.add(v) + } + return out + } +} diff --git a/apps/android/app/src/main/java/com/tlm/android/push/FcmRegistrationWorker.kt b/apps/android/app/src/main/java/com/tlm/android/push/FcmRegistrationWorker.kt new file mode 100644 index 0000000..6b87b69 --- /dev/null +++ b/apps/android/app/src/main/java/com/tlm/android/push/FcmRegistrationWorker.kt @@ -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() +} diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ce823c7 --- /dev/null +++ b/apps/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + TLM + diff --git a/apps/android/app/src/main/res/values/themes.xml b/apps/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1e00874 --- /dev/null +++ b/apps/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/android/app/src/main/res/xml/network_security_config.xml b/apps/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..add26de --- /dev/null +++ b/apps/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 10.0.2.2 + localhost + 127.0.0.1 + + diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts new file mode 100644 index 0000000..f3c6797 --- /dev/null +++ b/apps/android/build.gradle.kts @@ -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 +} diff --git a/apps/android/gradle.properties b/apps/android/gradle.properties new file mode 100644 index 0000000..2c9f545 --- /dev/null +++ b/apps/android/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/apps/android/settings.gradle.kts b/apps/android/settings.gradle.kts new file mode 100644 index 0000000..7f89161 --- /dev/null +++ b/apps/android/settings.gradle.kts @@ -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") diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..65dc974 --- /dev/null +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/load/k6-graphql-smoke.js b/apps/api/load/k6-graphql-smoke.js new file mode 100644 index 0000000..9fcf8db --- /dev/null +++ b/apps/api/load/k6-graphql-smoke.js @@ -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 }); +} diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..2f39079 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/prisma/migrations/20260413000000_init/migration.sql b/apps/api/prisma/migrations/20260413000000_init/migration.sql new file mode 100644 index 0000000..c483e50 --- /dev/null +++ b/apps/api/prisma/migrations/20260413000000_init/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2fe25d8 --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -0,0 +1 @@ +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..d08bbc0 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..3d50e05 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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({ + 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 }; + connectionParams?: Record; + extra?: unknown; + }) => { + const authHeader = + req?.headers?.authorization ?? + connectionParams?.authorization ?? + connectionParams?.Authorization; + const base = req + ? { ...req, headers: { ...req.headers } } + : { headers: {} as Record }; + 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 {} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..fbaa23d --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..8c44058 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -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 } }); + } +} diff --git a/apps/api/src/auth/current-user.decorator.ts b/apps/api/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..b58c5e2 --- /dev/null +++ b/apps/api/src/auth/current-user.decorator.ts @@ -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; + }, +); diff --git a/apps/api/src/auth/gql-auth.guard.ts b/apps/api/src/auth/gql-auth.guard.ts new file mode 100644 index 0000000..7e465fb --- /dev/null +++ b/apps/api/src/auth/gql-auth.guard.ts @@ -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(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) return true; + return super.canActivate(context); + } + + getRequest(context: ExecutionContext) { + if (context.getType() === "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; + } +} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..44de951 --- /dev/null +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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, + }; + } +} diff --git a/apps/api/src/auth/public.decorator.ts b/apps/api/src/auth/public.decorator.ts new file mode 100644 index 0000000..7beff6f --- /dev/null +++ b/apps/api/src/auth/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_PUBLIC_KEY = "isPublic"; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts new file mode 100644 index 0000000..43315db --- /dev/null +++ b/apps/api/src/health.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..010de85 --- /dev/null +++ b/apps/api/src/main.ts @@ -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(); diff --git a/apps/api/src/messenger/messenger.resolver.ts b/apps/api/src/messenger/messenger.resolver.ts new file mode 100644 index 0000000..52a4aa5 --- /dev/null +++ b/apps/api/src/messenger/messenger.resolver.ts @@ -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)); + } +} diff --git a/apps/api/src/messenger/messenger.service.ts b/apps/api/src/messenger/messenger.service.ts new file mode 100644 index 0000000..ed2fca8 --- /dev/null +++ b/apps/api/src/messenger/messenger.service.ts @@ -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) { + 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) { + await this.pubsub.publish(chatTopic(chatId), { + onChatEvent: { ...payload, at: new Date().toISOString() }, + }); + } + + async publishAccount(userId: string, payload: Record) { + 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 } }); + } +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..1edbf95 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service"; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..9c2483a --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -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(); + } +} diff --git a/apps/api/src/pubsub.provider.ts b/apps/api/src/pubsub.provider.ts new file mode 100644 index 0000000..0144ffc --- /dev/null +++ b/apps/api/src/pubsub.provider.ts @@ -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), + }); + }, +}; diff --git a/apps/api/src/signaling/signal.gateway.ts b/apps/api/src/signaling/signal.gateway.ts new file mode 100644 index 0000000..492c67d --- /dev/null +++ b/apps/api/src/signaling/signal.gateway.ts @@ -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>(); + + @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; + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..436c5dc --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/apps/sample-bot/package.json b/apps/sample-bot/package.json new file mode 100644 index 0000000..822cb63 --- /dev/null +++ b/apps/sample-bot/package.json @@ -0,0 +1,8 @@ +{ + "name": "@tlm/sample-bot", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/apps/sample-bot/server.mjs b/apps/sample-bot/server.mjs new file mode 100644 index 0000000..b9793f4 --- /dev/null +++ b/apps/sample-bot/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}`); +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d5f5117 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + TLM Web + + + + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..c5019d2 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..68350b8 --- /dev/null +++ b/apps/web/src/App.tsx @@ -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(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 ( + + TLM + + Email + setEmail(e.target.value)} + style={{ width: "100%", display: "block", marginBottom: 8 }} + /> + + + Password + setPassword(e.target.value)} + style={{ width: "100%", display: "block", marginBottom: 8 }} + /> + + + Display name (register) + setDisplayName(e.target.value)} + style={{ width: "100%", display: "block", marginBottom: 8 }} + /> + + + void doLogin()}> + Login + + void doRegister()}> + Register + + + + {(loginM.error ?? regM.error)?.message} + + + ); + } + + return ( + + + + + {activeChatId ? `Chat ${activeChatId}` : "Select a chat"} + + + {sortedEdges.map((e) => ( + + {e.node.sender?.displayName ?? "?"}:{" "} + {e.node.body} + + ))} + + + + + ); +} diff --git a/apps/web/src/apollo.ts b/apps/web/src/apollo.ts new file mode 100644 index 0000000..2e1af75 --- /dev/null +++ b/apps/web/src/apollo.ts @@ -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(), +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..dcbcc47 --- /dev/null +++ b/apps/web/src/main.tsx @@ -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( + + + + + , +); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..ca66fdd --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_GRAPHQL_HTTP?: string; + readonly VITE_GRAPHQL_WS?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..67fa696 --- /dev/null +++ b/apps/web/tsconfig.json @@ -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"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..c15be9c --- /dev/null +++ b/apps/web/vite.config.ts @@ -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 }, + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7ce7803 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/package.json b/package.json new file mode 100644 index 0000000..605f78c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/graphql-schema/codegen.ts b/packages/graphql-schema/codegen.ts new file mode 100644 index 0000000..db7c27d --- /dev/null +++ b/packages/graphql-schema/codegen.ts @@ -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; diff --git a/packages/graphql-schema/package.json b/packages/graphql-schema/package.json new file mode 100644 index 0000000..554758d --- /dev/null +++ b/packages/graphql-schema/package.json @@ -0,0 +1,6 @@ +{ + "name": "@tlm/graphql-schema", + "version": "0.0.1", + "private": true, + "description": "Shared GraphQL schema (SDL)" +} diff --git a/packages/graphql-schema/schema.graphql b/packages/graphql-schema/schema.graphql new file mode 100644 index 0000000..b033e50 --- /dev/null +++ b/packages/graphql-schema/schema.graphql @@ -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! +}
+ {(loginM.error ?? regM.error)?.message} +