refactor chat delivery to graphql + hatchet services
This commit is contained in:
11
backend/Dockerfile
Normal file
11
backend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
35
backend/README.md
Normal file
35
backend/README.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# backend
|
||||
|
||||
Core CRM/omni-домен с единственной Prisma-базой.
|
||||
|
||||
## Назначение
|
||||
|
||||
- принимает входящие telegram-события через GraphQL mutation `ingestTelegramInbound`;
|
||||
- создает исходящую задачу через GraphQL mutation `requestTelegramOutbound` (в `telegram_backend`, далее в Hatchet);
|
||||
- принимает отчет о доставке через GraphQL mutation `reportTelegramOutbound`.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health`
|
||||
- `POST /graphql`
|
||||
|
||||
## GraphQL auth
|
||||
|
||||
Если задан `BACKEND_GRAPHQL_SHARED_SECRET`, запросы на `/graphql` должны содержать заголовок:
|
||||
|
||||
- `x-graphql-secret: <BACKEND_GRAPHQL_SHARED_SECRET>`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
- `PORT` (default: `8090`)
|
||||
- `MAX_BODY_SIZE_BYTES` (default: `2097152`)
|
||||
- `BACKEND_GRAPHQL_SHARED_SECRET` (optional)
|
||||
- `TELEGRAM_BACKEND_GRAPHQL_URL` (required для `requestTelegramOutbound`)
|
||||
- `TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET` (optional)
|
||||
- `DEFAULT_TEAM_ID` (optional fallback для inbound маршрутизации)
|
||||
|
||||
## Prisma policy
|
||||
|
||||
- Источник схемы: `Frontend/prisma/schema.prisma`.
|
||||
- Локальная копия в `backend/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
|
||||
- Миграции/`db push` выполняются только в `Frontend`.
|
||||
1025
backend/package-lock.json
generated
Normal file
1025
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
backend/package.json
Normal file
19
backend/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "crm-backend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.9",
|
||||
"prisma": "^6.16.1",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.1",
|
||||
"graphql": "^16.13.1"
|
||||
}
|
||||
}
|
||||
483
backend/prisma/schema.prisma
Normal file
483
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,483 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../server/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum TeamRole {
|
||||
OWNER
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum MessageDirection {
|
||||
IN
|
||||
OUT
|
||||
}
|
||||
|
||||
enum MessageChannel {
|
||||
TELEGRAM
|
||||
WHATSAPP
|
||||
INSTAGRAM
|
||||
PHONE
|
||||
EMAIL
|
||||
INTERNAL
|
||||
}
|
||||
|
||||
enum ContactMessageKind {
|
||||
MESSAGE
|
||||
CALL
|
||||
}
|
||||
|
||||
enum ChatRole {
|
||||
USER
|
||||
ASSISTANT
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum OmniMessageStatus {
|
||||
PENDING
|
||||
SENT
|
||||
FAILED
|
||||
DELIVERED
|
||||
READ
|
||||
}
|
||||
|
||||
enum FeedCardDecision {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum WorkspaceDocumentType {
|
||||
Regulation
|
||||
Playbook
|
||||
Policy
|
||||
Template
|
||||
}
|
||||
|
||||
enum ClientTimelineContentType {
|
||||
CALENDAR_EVENT
|
||||
DOCUMENT
|
||||
RECOMMENDATION
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members TeamMember[]
|
||||
contacts Contact[]
|
||||
calendarEvents CalendarEvent[]
|
||||
deals Deal[]
|
||||
aiConversations AiConversation[]
|
||||
aiMessages AiMessage[]
|
||||
|
||||
omniThreads OmniThread[]
|
||||
omniMessages OmniMessage[]
|
||||
omniIdentities OmniContactIdentity[]
|
||||
telegramBusinessConnections TelegramBusinessConnection[]
|
||||
|
||||
feedCards FeedCard[]
|
||||
contactPins ContactPin[]
|
||||
documents WorkspaceDocument[]
|
||||
clientTimelineEntries ClientTimelineEntry[]
|
||||
contactInboxes ContactInbox[]
|
||||
contactInboxPreferences ContactInboxPreference[]
|
||||
contactThreadReads ContactThreadRead[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
phone String @unique
|
||||
passwordHash String
|
||||
email String? @unique
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships TeamMember[]
|
||||
aiConversations AiConversation[] @relation("ConversationCreator")
|
||||
aiMessages AiMessage[] @relation("ChatAuthor")
|
||||
contactInboxPreferences ContactInboxPreference[]
|
||||
contactThreadReads ContactThreadRead[]
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
userId String
|
||||
role TeamRole @default(MEMBER)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Contact {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
name String
|
||||
avatarUrl String?
|
||||
email String?
|
||||
phone String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
note ContactNote?
|
||||
messages ContactMessage[]
|
||||
events CalendarEvent[]
|
||||
deals Deal[]
|
||||
feedCards FeedCard[]
|
||||
pins ContactPin[]
|
||||
|
||||
omniThreads OmniThread[]
|
||||
omniMessages OmniMessage[]
|
||||
omniIdentities OmniContactIdentity[]
|
||||
contactInboxes ContactInbox[]
|
||||
clientTimelineEntries ClientTimelineEntry[]
|
||||
contactThreadReads ContactThreadRead[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model ContactThreadRead {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
userId String
|
||||
contactId String
|
||||
readAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, contactId])
|
||||
@@index([teamId, userId])
|
||||
}
|
||||
|
||||
model ContactNote {
|
||||
id String @id @default(cuid())
|
||||
contactId String @unique
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ContactMessage {
|
||||
id String @id @default(cuid())
|
||||
contactId String
|
||||
contactInboxId String?
|
||||
kind ContactMessageKind @default(MESSAGE)
|
||||
direction MessageDirection
|
||||
channel MessageChannel
|
||||
content String
|
||||
audioUrl String?
|
||||
durationSec Int?
|
||||
waveformJson Json?
|
||||
transcriptJson Json?
|
||||
occurredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
contactInbox ContactInbox? @relation(fields: [contactInboxId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([contactId, occurredAt])
|
||||
@@index([contactInboxId, occurredAt])
|
||||
}
|
||||
|
||||
model ContactInbox {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
channel MessageChannel
|
||||
sourceExternalId String
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
messages ContactMessage[]
|
||||
preferences ContactInboxPreference[]
|
||||
|
||||
@@unique([teamId, channel, sourceExternalId])
|
||||
@@index([contactId, updatedAt])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model ContactInboxPreference {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
userId String
|
||||
contactInboxId String
|
||||
isHidden Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
contactInbox ContactInbox @relation(fields: [contactInboxId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, contactInboxId])
|
||||
@@index([teamId, userId, isHidden])
|
||||
}
|
||||
|
||||
model OmniContactIdentity {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
channel MessageChannel
|
||||
externalId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, channel, externalId])
|
||||
@@index([contactId])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model OmniThread {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
channel MessageChannel
|
||||
externalChatId String
|
||||
businessConnectionId String?
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
messages OmniMessage[]
|
||||
|
||||
@@unique([teamId, channel, externalChatId, businessConnectionId])
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
}
|
||||
|
||||
model OmniMessage {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
threadId String
|
||||
direction MessageDirection
|
||||
channel MessageChannel
|
||||
status OmniMessageStatus @default(PENDING)
|
||||
text String
|
||||
providerMessageId String?
|
||||
providerUpdateId String?
|
||||
rawJson Json?
|
||||
occurredAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([threadId, providerMessageId])
|
||||
@@index([teamId, occurredAt])
|
||||
@@index([threadId, occurredAt])
|
||||
}
|
||||
|
||||
model TelegramBusinessConnection {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
businessConnectionId String
|
||||
isEnabled Boolean?
|
||||
canReply Boolean?
|
||||
rawJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, businessConnectionId])
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model CalendarEvent {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String?
|
||||
title String
|
||||
startsAt DateTime
|
||||
endsAt DateTime?
|
||||
note String?
|
||||
isArchived Boolean @default(false)
|
||||
archiveNote String?
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([startsAt])
|
||||
@@index([contactId, startsAt])
|
||||
@@index([teamId, startsAt])
|
||||
}
|
||||
|
||||
model Deal {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
title String
|
||||
stage String
|
||||
amount Int?
|
||||
paidAmount Int?
|
||||
nextStep String?
|
||||
summary String?
|
||||
currentStepId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
steps DealStep[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
@@index([currentStepId])
|
||||
}
|
||||
|
||||
model DealStep {
|
||||
id String @id @default(cuid())
|
||||
dealId String
|
||||
title String
|
||||
description String?
|
||||
status String @default("todo")
|
||||
dueAt DateTime?
|
||||
order Int @default(0)
|
||||
completedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([dealId, order])
|
||||
@@index([status, dueAt])
|
||||
}
|
||||
|
||||
model AiConversation {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
createdByUserId String
|
||||
title String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
|
||||
messages AiMessage[]
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([createdByUserId])
|
||||
@@map("ChatConversation")
|
||||
}
|
||||
|
||||
model AiMessage {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
conversationId String
|
||||
authorUserId String?
|
||||
role ChatRole
|
||||
text String
|
||||
planJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([teamId, createdAt])
|
||||
@@index([conversationId, createdAt])
|
||||
@@map("ChatMessage")
|
||||
}
|
||||
|
||||
model FeedCard {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String?
|
||||
happenedAt DateTime
|
||||
text String
|
||||
proposalJson Json
|
||||
decision FeedCardDecision @default(PENDING)
|
||||
decisionNote String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([teamId, happenedAt])
|
||||
@@index([contactId, happenedAt])
|
||||
}
|
||||
|
||||
model ContactPin {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
text String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
@@index([contactId, updatedAt])
|
||||
}
|
||||
|
||||
model WorkspaceDocument {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
title String
|
||||
type WorkspaceDocumentType
|
||||
owner String
|
||||
scope String
|
||||
summary String
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([teamId, updatedAt])
|
||||
}
|
||||
|
||||
model ClientTimelineEntry {
|
||||
id String @id @default(cuid())
|
||||
teamId String
|
||||
contactId String
|
||||
contentType ClientTimelineContentType
|
||||
contentId String
|
||||
datetime DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, contentType, contentId])
|
||||
@@index([teamId, contactId, datetime])
|
||||
@@index([contactId, datetime])
|
||||
}
|
||||
38
backend/src/index.ts
Normal file
38
backend/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { startServer } from "./server";
|
||||
import { prisma } from "./utils/prisma";
|
||||
|
||||
const server = startServer();
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[backend] shutting down by ${signal}`);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
232
backend/src/server.ts
Normal file
232
backend/src/server.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { buildSchema, graphql } from "graphql";
|
||||
import {
|
||||
ingestTelegramInbound,
|
||||
reportTelegramOutbound,
|
||||
requestTelegramOutbound,
|
||||
type TelegramInboundEnvelope,
|
||||
type TelegramOutboundReport,
|
||||
type TelegramOutboundRequest,
|
||||
} from "./service";
|
||||
|
||||
const PORT = Number(process.env.PORT || 8090);
|
||||
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 2 * 1024 * 1024);
|
||||
const GRAPHQL_SHARED_SECRET = String(process.env.BACKEND_GRAPHQL_SHARED_SECRET || "").trim();
|
||||
|
||||
const schema = buildSchema(`
|
||||
type Query {
|
||||
health: Health!
|
||||
}
|
||||
|
||||
type Health {
|
||||
ok: Boolean!
|
||||
service: String!
|
||||
now: String!
|
||||
}
|
||||
|
||||
type MutationResult {
|
||||
ok: Boolean!
|
||||
message: String!
|
||||
runId: String
|
||||
omniMessageId: String
|
||||
}
|
||||
|
||||
input TelegramInboundInput {
|
||||
version: Int!
|
||||
idempotencyKey: String!
|
||||
provider: String!
|
||||
channel: String!
|
||||
direction: String!
|
||||
providerEventId: String!
|
||||
providerMessageId: String
|
||||
eventType: String!
|
||||
occurredAt: String!
|
||||
receivedAt: String!
|
||||
payloadRawJson: String!
|
||||
payloadNormalizedJson: String!
|
||||
}
|
||||
|
||||
input TelegramOutboundReportInput {
|
||||
omniMessageId: String!
|
||||
status: String!
|
||||
providerMessageId: String
|
||||
error: String
|
||||
responseJson: String
|
||||
}
|
||||
|
||||
input TelegramOutboundTaskInput {
|
||||
omniMessageId: String!
|
||||
chatId: String!
|
||||
text: String!
|
||||
businessConnectionId: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
ingestTelegramInbound(input: TelegramInboundInput!): MutationResult!
|
||||
reportTelegramOutbound(input: TelegramOutboundReportInput!): MutationResult!
|
||||
requestTelegramOutbound(input: TelegramOutboundTaskInput!): MutationResult!
|
||||
}
|
||||
`);
|
||||
|
||||
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function isGraphqlAuthorized(req: IncomingMessage) {
|
||||
if (!GRAPHQL_SHARED_SECRET) return true;
|
||||
const incoming = String(req.headers["x-graphql-secret"] || "").trim();
|
||||
return incoming !== "" && incoming === GRAPHQL_SHARED_SECRET;
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buf.length;
|
||||
if (total > MAX_BODY_SIZE_BYTES) {
|
||||
throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`);
|
||||
}
|
||||
chunks.push(buf);
|
||||
}
|
||||
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function parseJsonField<T>(raw: string, fieldName: string): T {
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
throw new Error(`${fieldName} must be valid JSON string`);
|
||||
}
|
||||
}
|
||||
|
||||
const root = {
|
||||
health: () => ({
|
||||
ok: true,
|
||||
service: "backend",
|
||||
now: new Date().toISOString(),
|
||||
}),
|
||||
|
||||
ingestTelegramInbound: async ({ input }: { input: any }) => {
|
||||
const envelope: TelegramInboundEnvelope = {
|
||||
version: Number(input.version ?? 1),
|
||||
idempotencyKey: String(input.idempotencyKey ?? ""),
|
||||
provider: String(input.provider ?? ""),
|
||||
channel: String(input.channel ?? ""),
|
||||
direction: String(input.direction ?? "IN") === "OUT" ? "OUT" : "IN",
|
||||
providerEventId: String(input.providerEventId ?? ""),
|
||||
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
|
||||
eventType: String(input.eventType ?? ""),
|
||||
occurredAt: String(input.occurredAt ?? new Date().toISOString()),
|
||||
receivedAt: String(input.receivedAt ?? new Date().toISOString()),
|
||||
payloadRaw: parseJsonField(input.payloadRawJson, "payloadRawJson"),
|
||||
payloadNormalized: parseJsonField(input.payloadNormalizedJson, "payloadNormalizedJson"),
|
||||
};
|
||||
|
||||
const result = await ingestTelegramInbound(envelope);
|
||||
return {
|
||||
ok: result.ok,
|
||||
message: result.message,
|
||||
omniMessageId: (result as any).omniMessageId ?? null,
|
||||
runId: null,
|
||||
};
|
||||
},
|
||||
|
||||
reportTelegramOutbound: async ({ input }: { input: any }) => {
|
||||
const payload: TelegramOutboundReport = {
|
||||
omniMessageId: String(input.omniMessageId ?? ""),
|
||||
status: String(input.status ?? "FAILED"),
|
||||
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
|
||||
error: input.error != null ? String(input.error) : null,
|
||||
responseJson: input.responseJson != null ? String(input.responseJson) : null,
|
||||
};
|
||||
|
||||
const result = await reportTelegramOutbound(payload);
|
||||
return {
|
||||
ok: result.ok,
|
||||
message: result.message,
|
||||
runId: null,
|
||||
omniMessageId: null,
|
||||
};
|
||||
},
|
||||
|
||||
requestTelegramOutbound: async ({ input }: { input: any }) => {
|
||||
const payload: TelegramOutboundRequest = {
|
||||
omniMessageId: String(input.omniMessageId ?? ""),
|
||||
chatId: String(input.chatId ?? ""),
|
||||
text: String(input.text ?? ""),
|
||||
businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null,
|
||||
};
|
||||
|
||||
const result = await requestTelegramOutbound(payload);
|
||||
return {
|
||||
ok: result.ok,
|
||||
message: result.message,
|
||||
runId: result.runId ?? null,
|
||||
omniMessageId: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function startServer() {
|
||||
const server = createServer(async (req, res) => {
|
||||
if (!req.url || !req.method) {
|
||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/health" && req.method === "GET") {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: "backend",
|
||||
now: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/graphql" && req.method === "POST") {
|
||||
if (!isGraphqlAuthorized(req)) {
|
||||
writeJson(res, 401, { errors: [{ message: "unauthorized" }] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await readJsonBody(req)) as {
|
||||
query?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
operationName?: string;
|
||||
};
|
||||
|
||||
const result = await graphql({
|
||||
schema,
|
||||
source: String(body.query || ""),
|
||||
rootValue: root,
|
||||
variableValues: body.variables || {},
|
||||
operationName: body.operationName,
|
||||
});
|
||||
|
||||
writeJson(res, 200, result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const statusCode = message.startsWith("payload_too_large:") ? 413 : 400;
|
||||
writeJson(res, statusCode, { errors: [{ message }] });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { ok: false, error: "not_found" });
|
||||
});
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`[backend] listening on :${PORT}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
512
backend/src/service.ts
Normal file
512
backend/src/service.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
type MessageDirection = "IN" | "OUT";
|
||||
type OmniMessageStatus = "PENDING" | "SENT" | "FAILED" | "DELIVERED" | "READ";
|
||||
import { prisma } from "./utils/prisma";
|
||||
|
||||
export type TelegramInboundEnvelope = {
|
||||
version: number;
|
||||
idempotencyKey: string;
|
||||
provider: string;
|
||||
channel: string;
|
||||
direction: "IN" | "OUT";
|
||||
providerEventId: string;
|
||||
providerMessageId: string | null;
|
||||
eventType: string;
|
||||
occurredAt: string;
|
||||
receivedAt: string;
|
||||
payloadRaw: unknown;
|
||||
payloadNormalized: {
|
||||
threadExternalId: string | null;
|
||||
contactExternalId: string | null;
|
||||
text: string | null;
|
||||
businessConnectionId: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type TelegramOutboundReport = {
|
||||
omniMessageId: string;
|
||||
status: string;
|
||||
providerMessageId?: string | null;
|
||||
error?: string | null;
|
||||
responseJson?: string | null;
|
||||
};
|
||||
|
||||
export type TelegramOutboundRequest = {
|
||||
omniMessageId: string;
|
||||
chatId: string;
|
||||
text: string;
|
||||
businessConnectionId?: string | null;
|
||||
};
|
||||
|
||||
function asString(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const v = value.trim();
|
||||
return v || null;
|
||||
}
|
||||
|
||||
function parseDate(value: string) {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return new Date();
|
||||
return d;
|
||||
}
|
||||
|
||||
function normalizeDirection(value: string): MessageDirection {
|
||||
return value === "OUT" ? "OUT" : "IN";
|
||||
}
|
||||
|
||||
async function resolveTeamId(envelope: TelegramInboundEnvelope) {
|
||||
const n = envelope.payloadNormalized;
|
||||
const bcId = asString(n.businessConnectionId);
|
||||
|
||||
if (bcId) {
|
||||
const linked = await prisma.telegramBusinessConnection.findFirst({
|
||||
where: { businessConnectionId: bcId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { teamId: true },
|
||||
});
|
||||
if (linked?.teamId) return linked.teamId;
|
||||
}
|
||||
|
||||
const externalContactId = asString(n.contactExternalId) ?? asString(n.threadExternalId);
|
||||
if (externalContactId) {
|
||||
const linked = await prisma.telegramBusinessConnection.findFirst({
|
||||
where: { businessConnectionId: `link:${externalContactId}` },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
select: { teamId: true },
|
||||
});
|
||||
if (linked?.teamId) return linked.teamId;
|
||||
}
|
||||
|
||||
const fallback = asString(process.env.DEFAULT_TEAM_ID);
|
||||
if (fallback) return fallback;
|
||||
|
||||
const firstTeam = await prisma.team.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return firstTeam?.id ?? null;
|
||||
}
|
||||
|
||||
async function resolveContact(input: {
|
||||
teamId: string;
|
||||
externalContactId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}) {
|
||||
const existing = await prisma.omniContactIdentity.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalId: input.externalContactId,
|
||||
},
|
||||
select: { contactId: true },
|
||||
});
|
||||
|
||||
if (existing?.contactId) {
|
||||
return existing.contactId;
|
||||
}
|
||||
|
||||
const contact = await prisma.contact.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
name: input.displayName,
|
||||
avatarUrl: input.avatarUrl,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
try {
|
||||
await prisma.omniContactIdentity.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
contactId: contact.id,
|
||||
channel: "TELEGRAM",
|
||||
externalId: input.externalContactId,
|
||||
},
|
||||
});
|
||||
return contact.id;
|
||||
} catch {
|
||||
const concurrent = await prisma.omniContactIdentity.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalId: input.externalContactId,
|
||||
},
|
||||
select: { contactId: true },
|
||||
});
|
||||
if (concurrent?.contactId) {
|
||||
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
|
||||
return concurrent.contactId;
|
||||
}
|
||||
|
||||
throw new Error("failed to create telegram contact identity");
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertThread(input: {
|
||||
teamId: string;
|
||||
contactId: string;
|
||||
externalChatId: string;
|
||||
businessConnectionId: string | null;
|
||||
title: string | null;
|
||||
}) {
|
||||
const existing = await prisma.omniThread.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalChatId: input.externalChatId,
|
||||
businessConnectionId: input.businessConnectionId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await prisma.omniThread.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
contactId: input.contactId,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await prisma.omniThread.create({
|
||||
data: {
|
||||
teamId: input.teamId,
|
||||
contactId: input.contactId,
|
||||
channel: "TELEGRAM",
|
||||
externalChatId: input.externalChatId,
|
||||
businessConnectionId: input.businessConnectionId,
|
||||
title: input.title,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return created.id;
|
||||
} catch {
|
||||
const concurrent = await prisma.omniThread.findFirst({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalChatId: input.externalChatId,
|
||||
businessConnectionId: input.businessConnectionId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (concurrent?.id) return concurrent.id;
|
||||
throw new Error("failed to upsert telegram thread");
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertContactInbox(input: {
|
||||
teamId: string;
|
||||
contactId: string;
|
||||
sourceExternalId: string;
|
||||
title: string | null;
|
||||
}) {
|
||||
const inbox = await prisma.contactInbox.upsert({
|
||||
where: {
|
||||
teamId_channel_sourceExternalId: {
|
||||
teamId: input.teamId,
|
||||
channel: "TELEGRAM",
|
||||
sourceExternalId: input.sourceExternalId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId: input.teamId,
|
||||
contactId: input.contactId,
|
||||
channel: "TELEGRAM",
|
||||
sourceExternalId: input.sourceExternalId,
|
||||
title: input.title,
|
||||
},
|
||||
update: {
|
||||
contactId: input.contactId,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return inbox.id;
|
||||
}
|
||||
|
||||
async function markRead(teamId: string, externalChatId: string) {
|
||||
const thread = await prisma.omniThread.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
channel: "TELEGRAM",
|
||||
externalChatId,
|
||||
},
|
||||
select: { contactId: true },
|
||||
});
|
||||
if (!thread) return;
|
||||
|
||||
const members = await prisma.teamMember.findMany({
|
||||
where: { teamId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
const readAt = new Date();
|
||||
await Promise.all(
|
||||
members.map((member: { userId: string }) =>
|
||||
prisma.contactThreadRead.upsert({
|
||||
where: {
|
||||
userId_contactId: {
|
||||
userId: member.userId,
|
||||
contactId: thread.contactId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
userId: member.userId,
|
||||
contactId: thread.contactId,
|
||||
readAt,
|
||||
},
|
||||
update: { readAt },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function ingestTelegramInbound(envelope: TelegramInboundEnvelope) {
|
||||
if (envelope.channel !== "TELEGRAM") {
|
||||
return { ok: true, message: "skip_non_telegram" };
|
||||
}
|
||||
|
||||
const teamId = await resolveTeamId(envelope);
|
||||
if (!teamId) {
|
||||
throw new Error("team_not_resolved");
|
||||
}
|
||||
|
||||
const n = envelope.payloadNormalized;
|
||||
const externalChatId = asString(n.threadExternalId) ?? asString(n.contactExternalId);
|
||||
if (!externalChatId) {
|
||||
throw new Error("thread_external_id_required");
|
||||
}
|
||||
|
||||
if (envelope.eventType === "read_business_message") {
|
||||
await markRead(teamId, externalChatId);
|
||||
return { ok: true, message: "read_marked" };
|
||||
}
|
||||
|
||||
const externalContactId = asString(n.contactExternalId) ?? externalChatId;
|
||||
const businessConnectionId = asString(n.businessConnectionId);
|
||||
const text = asString(n.text) ?? "[no text]";
|
||||
const occurredAt = parseDate(envelope.occurredAt);
|
||||
const direction = normalizeDirection(envelope.direction);
|
||||
|
||||
const contactFirstName = asString(n.contactFirstName);
|
||||
const contactLastName = asString(n.contactLastName);
|
||||
const contactUsername = asString(n.contactUsername);
|
||||
const fallbackName = `Telegram ${externalContactId}`;
|
||||
const displayName =
|
||||
[contactFirstName, contactLastName].filter(Boolean).join(" ") ||
|
||||
(contactUsername ? `@${contactUsername.replace(/^@/, "")}` : null) ||
|
||||
fallbackName;
|
||||
|
||||
const contactId = await resolveContact({
|
||||
teamId,
|
||||
externalContactId,
|
||||
displayName,
|
||||
avatarUrl: asString(n.contactAvatarUrl),
|
||||
});
|
||||
|
||||
const threadId = await upsertThread({
|
||||
teamId,
|
||||
contactId,
|
||||
externalChatId,
|
||||
businessConnectionId,
|
||||
title: asString(n.chatTitle),
|
||||
});
|
||||
|
||||
const contactInboxId = await upsertContactInbox({
|
||||
teamId,
|
||||
contactId,
|
||||
sourceExternalId: externalChatId,
|
||||
title: asString(n.chatTitle),
|
||||
});
|
||||
|
||||
const rawEnvelope: Record<string, unknown> = {
|
||||
version: envelope.version,
|
||||
source: "backend.graphql.ingestTelegramInbound",
|
||||
provider: envelope.provider,
|
||||
channel: envelope.channel,
|
||||
direction,
|
||||
providerEventId: envelope.providerEventId,
|
||||
receivedAt: envelope.receivedAt,
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
payloadNormalized: n,
|
||||
payloadRaw: envelope.payloadRaw ?? null,
|
||||
};
|
||||
|
||||
let omniMessageId: string;
|
||||
if (envelope.providerMessageId) {
|
||||
const message = await prisma.omniMessage.upsert({
|
||||
where: {
|
||||
threadId_providerMessageId: {
|
||||
threadId,
|
||||
providerMessageId: envelope.providerMessageId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
teamId,
|
||||
contactId,
|
||||
threadId,
|
||||
direction,
|
||||
channel: "TELEGRAM",
|
||||
status: "DELIVERED",
|
||||
text,
|
||||
providerMessageId: envelope.providerMessageId,
|
||||
providerUpdateId: envelope.providerEventId,
|
||||
rawJson: rawEnvelope,
|
||||
occurredAt,
|
||||
},
|
||||
update: {
|
||||
text,
|
||||
providerUpdateId: envelope.providerEventId,
|
||||
rawJson: rawEnvelope,
|
||||
occurredAt,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
omniMessageId = message.id;
|
||||
} else {
|
||||
const message = await prisma.omniMessage.create({
|
||||
data: {
|
||||
teamId,
|
||||
contactId,
|
||||
threadId,
|
||||
direction,
|
||||
channel: "TELEGRAM",
|
||||
status: "DELIVERED",
|
||||
text,
|
||||
providerMessageId: null,
|
||||
providerUpdateId: envelope.providerEventId,
|
||||
rawJson: rawEnvelope,
|
||||
occurredAt,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
omniMessageId = message.id;
|
||||
}
|
||||
|
||||
await prisma.contactMessage.create({
|
||||
data: {
|
||||
contactId,
|
||||
contactInboxId,
|
||||
kind: "MESSAGE",
|
||||
direction,
|
||||
channel: "TELEGRAM",
|
||||
content: text,
|
||||
occurredAt,
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, message: "inbound_ingested", omniMessageId };
|
||||
}
|
||||
|
||||
export async function reportTelegramOutbound(input: TelegramOutboundReport) {
|
||||
const statusRaw = input.status.trim().toUpperCase();
|
||||
const status: OmniMessageStatus =
|
||||
statusRaw === "SENT" ||
|
||||
statusRaw === "FAILED" ||
|
||||
statusRaw === "DELIVERED" ||
|
||||
statusRaw === "READ" ||
|
||||
statusRaw === "PENDING"
|
||||
? (statusRaw as OmniMessageStatus)
|
||||
: "FAILED";
|
||||
|
||||
const existing = await prisma.omniMessage.findUnique({
|
||||
where: { id: input.omniMessageId },
|
||||
select: { rawJson: true },
|
||||
});
|
||||
|
||||
const raw = (existing?.rawJson && typeof existing.rawJson === "object" && !Array.isArray(existing.rawJson)
|
||||
? (existing.rawJson as Record<string, unknown>)
|
||||
: {}) as Record<string, unknown>;
|
||||
|
||||
await prisma.omniMessage.update({
|
||||
where: { id: input.omniMessageId },
|
||||
data: {
|
||||
status,
|
||||
...(input.providerMessageId ? { providerMessageId: input.providerMessageId } : {}),
|
||||
rawJson: {
|
||||
...raw,
|
||||
telegramWorker: {
|
||||
reportedAt: new Date().toISOString(),
|
||||
status,
|
||||
error: input.error ?? null,
|
||||
response: (() => {
|
||||
if (!input.responseJson) return null;
|
||||
try {
|
||||
return JSON.parse(input.responseJson);
|
||||
} catch {
|
||||
return input.responseJson;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, message: "outbound_reported" };
|
||||
}
|
||||
|
||||
async function callTelegramBackendGraphql<T>(query: string, variables: Record<string, unknown>) {
|
||||
const url = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_URL);
|
||||
if (!url) {
|
||||
throw new Error("TELEGRAM_BACKEND_GRAPHQL_URL is required");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
|
||||
const secret = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET);
|
||||
if (secret) {
|
||||
headers["x-graphql-secret"] = secret;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> };
|
||||
if (!response.ok || payload.errors?.length) {
|
||||
const errorMessage = payload.errors?.map((e) => e.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
export async function requestTelegramOutbound(input: TelegramOutboundRequest) {
|
||||
type Out = {
|
||||
enqueueTelegramOutbound: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
runId?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
const query = `mutation Enqueue($input: TelegramOutboundTaskInput!) {
|
||||
enqueueTelegramOutbound(input: $input) {
|
||||
ok
|
||||
message
|
||||
runId
|
||||
}
|
||||
}`;
|
||||
|
||||
const data = await callTelegramBackendGraphql<Out>(query, { input });
|
||||
const result = data.enqueueTelegramOutbound;
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.message || "enqueue failed");
|
||||
}
|
||||
|
||||
return { ok: true, message: "outbound_enqueued", runId: result.runId ?? null };
|
||||
}
|
||||
16
backend/src/utils/prisma.ts
Normal file
16
backend/src/utils/prisma.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __omniChatPrisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalThis.__omniChatPrisma ??
|
||||
new PrismaClient({
|
||||
log: ["error", "warn"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__omniChatPrisma = prisma;
|
||||
}
|
||||
14
backend/tsconfig.json
Normal file
14
backend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user