chore: rename service folders to lowercase

This commit is contained in:
Ruslan Bakiev
2026-02-20 12:10:25 +07:00
parent 0fdf5cf021
commit 46cca064df
71 changed files with 10 additions and 10 deletions

9
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.gitignore
node_modules
.nuxt
.output
.data
npm-debug.log*
dist
coverage

35
frontend/.env.example Normal file
View File

@@ -0,0 +1,35 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/clientsflow?schema=public"
REDIS_URL="redis://localhost:6379"
# Agent (LangGraph + OpenRouter)
OPENROUTER_API_KEY=""
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
OPENROUTER_MODEL="openai/gpt-4o-mini"
# Optional headers for OpenRouter ranking/analytics
OPENROUTER_HTTP_REFERER=""
OPENROUTER_X_TITLE="clientsflow"
# Enable reasoning payload for models that support it: 1 or 0
OPENROUTER_REASONING_ENABLED="0"
# Langfuse local tracing (optional)
LANGFUSE_ENABLED="true"
LANGFUSE_BASE_URL="http://localhost:3001"
LANGFUSE_PUBLIC_KEY="pk-lf-local"
LANGFUSE_SECRET_KEY="sk-lf-local"
# Optional fallback (OpenAI-compatible)
OPENAI_API_KEY=""
OPENAI_MODEL="gpt-4o-mini"
# "langgraph" (default) or "rule"
CF_AGENT_MODE="langgraph"
CF_WHISPER_MODEL="Xenova/whisper-small"
CF_WHISPER_LANGUAGE="ru"
TELEGRAM_BOT_TOKEN=""
TELEGRAM_WEBHOOK_SECRET=""
TELEGRAM_DEFAULT_TEAM_ID="demo-team"
# Frontend GraphQL endpoint for Apollo client runtime
GRAPHQL_HTTP_ENDPOINT="http://localhost:3000/api/graphql"
# Remote GraphQL schema URL for codegen (used by `pnpm codegen` / `npm run codegen`)
GRAPHQL_SCHEMA_URL=""

View File

@@ -0,0 +1,12 @@
import type { StorybookConfig } from "@storybook/vue3-vite";
const config: StorybookConfig = {
stories: ["../components/**/*.stories.@(ts|tsx)"],
addons: ["@storybook/addon-essentials", "@storybook/addon-interactions"],
framework: {
name: "@storybook/vue3-vite",
options: {},
},
};
export default config;

View File

@@ -0,0 +1,16 @@
import type { Preview } from "@storybook/vue3-vite";
import "../assets/css/main.css";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
export default preview;

20
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:22-bookworm-slim
WORKDIR /app/frontend
COPY package*.json ./
RUN npm ci --legacy-peer-deps
COPY . .
# Build server bundle at image build time.
RUN npx prisma generate && npm run build
ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
EXPOSE 3000
# Keep schema in sync, then start Nitro production server.
CMD ["bash", "-lc", "npx prisma db push && node .output/server/index.mjs"]

View File

@@ -0,0 +1,15 @@
FROM node:22-bookworm-slim
WORKDIR /app/delivery
COPY package*.json ./
# Worker does not need Nuxt postinstall hooks.
RUN npm install --ignore-scripts --legacy-peer-deps
COPY . .
RUN npx prisma generate
ENV NODE_ENV=production
CMD ["npm", "run", "worker:delivery"]

4453
frontend/app.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
@import "tailwindcss";
@plugin "daisyui";
:root {
--color-accent: #1e6bff;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at 100% 0%, rgba(30, 107, 255, 0.08), transparent 40%),
radial-gradient(circle at 0% 100%, rgba(30, 107, 255, 0.08), transparent 40%),
#f5f7fb;
color: #111827;
}

27
frontend/codegen.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const schemaUrl = process.env.GRAPHQL_SCHEMA_URL || process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql";
const config: CodegenConfig = {
schema: schemaUrl,
documents: ["graphql/operations/**/*.graphql"],
generates: {
"composables/graphql/generated.ts": {
plugins: [
"typescript",
"typescript-operations",
"typed-document-node",
"typescript-vue-apollo",
],
config: {
withCompositionFunctions: true,
vueCompositionApiImportFrom: "vue",
dedupeFragments: true,
namingConvention: "keep",
},
},
},
ignoreNoDocuments: false,
};
export default config;

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import ContactCollaborativeEditor from "./ContactCollaborativeEditor.client.vue";
const meta: Meta<typeof ContactCollaborativeEditor> = {
title: "Components/ContactCollaborativeEditor",
component: ContactCollaborativeEditor,
args: {
modelValue: "<p>Client summary draft...</p>",
room: "storybook-contact-editor-room",
placeholder: "Type here...",
plain: false,
},
};
export default meta;
type Story = StoryObj<typeof ContactCollaborativeEditor>;
export const Default: Story = {};

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { EditorContent, useEditor } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import Placeholder from "@tiptap/extension-placeholder";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
const props = defineProps<{
modelValue: string;
room: string;
placeholder?: string;
plain?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
}>();
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(props.room, ydoc);
const isBootstrapped = ref(false);
const awarenessVersion = ref(0);
const userPalette = ["#2563eb", "#0ea5e9", "#14b8a6", "#16a34a", "#eab308", "#f97316", "#ef4444"];
const currentUser = {
name: `You ${Math.floor(Math.random() * 900 + 100)}`,
color: userPalette[Math.floor(Math.random() * userPalette.length)],
};
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeInitialContent(value: string) {
const input = value.trim();
if (!input) return "<p></p>";
if (input.includes("<") && input.includes(">")) return value;
const blocks = value
.replaceAll("\r\n", "\n")
.split(/\n\n+/)
.map((block) => `<p>${escapeHtml(block).replaceAll("\n", "<br />")}</p>`);
return blocks.join("");
}
const editor = useEditor({
extensions: [
StarterKit.configure({
history: false,
}),
Placeholder.configure({
placeholder: props.placeholder ?? "Type here...",
includeChildren: true,
}),
Collaboration.configure({
document: ydoc,
field: "contact",
}),
CollaborationCursor.configure({
provider,
user: currentUser,
}),
],
autofocus: true,
editorProps: {
attributes: {
class: "contact-editor-content",
spellcheck: "true",
},
},
onCreate: ({ editor: instance }) => {
if (instance.isEmpty) {
instance.commands.setContent(normalizeInitialContent(props.modelValue), false);
}
isBootstrapped.value = true;
},
onUpdate: ({ editor: instance }) => {
emit("update:modelValue", instance.getHTML());
},
});
watch(
() => props.modelValue,
(incoming) => {
const instance = editor.value;
if (!instance || !isBootstrapped.value) return;
const current = instance.getHTML();
if (incoming === current || !incoming.trim()) return;
if (instance.isEmpty) {
instance.commands.setContent(normalizeInitialContent(incoming), false);
}
},
);
const peerCount = computed(() => {
awarenessVersion.value;
const states = Array.from(provider.awareness.getStates().values());
return states.length;
});
const onAwarenessChange = () => {
awarenessVersion.value += 1;
};
provider.awareness.on("change", onAwarenessChange);
function runCommand(action: () => void) {
const instance = editor.value;
if (!instance) return;
action();
instance.commands.focus();
}
onBeforeUnmount(() => {
provider.awareness.off("change", onAwarenessChange);
editor.value?.destroy();
provider.destroy();
ydoc.destroy();
});
</script>
<template>
<div :class="props.plain ? 'space-y-2' : 'space-y-3'">
<div :class="props.plain ? 'flex flex-wrap items-center justify-between gap-2 bg-transparent p-0' : 'flex flex-wrap items-center justify-between gap-2 rounded-xl border border-base-300 bg-base-100 p-2'">
<div class="flex flex-wrap items-center gap-1">
<button
class="btn btn-xs"
:class="editor?.isActive('bold') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBold().run())"
>
B
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('italic') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleItalic().run())"
>
I
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('bulletList') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBulletList().run())"
>
List
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('heading', { level: 2 }) ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleHeading({ level: 2 }).run())"
>
H2
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('blockquote') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBlockquote().run())"
>
Quote
</button>
</div>
<p class="px-1 text-xs text-base-content/60">Live: {{ peerCount }}</p>
</div>
<div :class="props.plain ? 'bg-transparent p-0' : 'rounded-xl border border-base-300 bg-base-100 p-2'">
<EditorContent :editor="editor" class="contact-editor min-h-[420px]" />
</div>
</div>
</template>
<style scoped>
.contact-editor :deep(.ProseMirror) {
min-height: 390px;
padding: 0.75rem;
outline: none;
line-height: 1.65;
color: rgba(17, 24, 39, 0.95);
}
.contact-editor :deep(.ProseMirror p) {
margin: 0.45rem 0;
}
.contact-editor :deep(.ProseMirror h1),
.contact-editor :deep(.ProseMirror h2),
.contact-editor :deep(.ProseMirror h3) {
margin: 0.75rem 0 0.45rem;
font-weight: 700;
line-height: 1.3;
}
.contact-editor :deep(.ProseMirror ul),
.contact-editor :deep(.ProseMirror ol) {
margin: 0.45rem 0;
padding-left: 1.25rem;
}
.contact-editor :deep(.ProseMirror blockquote) {
margin: 0.6rem 0;
border-left: 3px solid rgba(30, 107, 255, 0.5);
padding-left: 0.75rem;
color: rgba(55, 65, 81, 0.95);
}
.contact-editor :deep(.ProseMirror .collaboration-cursor__caret) {
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid currentColor;
border-right: 1px solid currentColor;
pointer-events: none;
position: relative;
}
.contact-editor :deep(.ProseMirror .collaboration-cursor__label) {
position: absolute;
top: -1.35em;
left: -1px;
border-radius: 4px;
padding: 0.1rem 0.4rem;
color: #fff;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,43 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
expose:
- "3000"
environment:
DATABASE_URL: "${DATABASE_URL:-postgresql://postgres:dpb6gmj1umjhohso@crm-sql-q57r8m:5432/postgres?schema=public}"
REDIS_URL: "${REDIS_URL:-redis://default:nw0mv1pemhnbh7gw@crm-redis-vkpxku:6379}"
CF_AGENT_MODE: "langgraph"
OPENROUTER_API_KEY: "${OPENROUTER_API_KEY:-}"
OPENROUTER_BASE_URL: "https://openrouter.ai/api/v1"
OPENROUTER_MODEL: "arcee-ai/trinity-large-preview:free"
OPENROUTER_HTTP_REFERER: "${OPENROUTER_HTTP_REFERER:-}"
OPENROUTER_X_TITLE: "clientsflow"
OPENROUTER_REASONING_ENABLED: "${OPENROUTER_REASONING_ENABLED:-1}"
CF_WHISPER_MODEL: "${CF_WHISPER_MODEL:-Xenova/whisper-small}"
CF_WHISPER_LANGUAGE: "${CF_WHISPER_LANGUAGE:-ru}"
LANGFUSE_ENABLED: "${LANGFUSE_ENABLED:-true}"
LANGFUSE_BASE_URL: "${LANGFUSE_BASE_URL:-http://langfuse-web:3000}"
LANGFUSE_PUBLIC_KEY: "${LANGFUSE_PUBLIC_KEY:-pk-lf-local}"
LANGFUSE_SECRET_KEY: "${LANGFUSE_SECRET_KEY:-sk-lf-local}"
labels:
- traefik.enable=true
- traefik.docker.network=dokploy-network
- traefik.http.routers.clientsflow-web.entrypoints=web
- traefik.http.routers.clientsflow-web.middlewares=redirect-to-https@file
- traefik.http.routers.clientsflow-web.rule=Host(`clientsflow.dsrptlab.com`)
- traefik.http.routers.clientsflow-web.service=clientsflow-web
- traefik.http.routers.clientsflow-websecure.entrypoints=websecure
- traefik.http.routers.clientsflow-websecure.rule=Host(`clientsflow.dsrptlab.com`)
- traefik.http.routers.clientsflow-websecure.service=clientsflow-websecure
- traefik.http.routers.clientsflow-websecure.tls.certresolver=letsencrypt
- traefik.http.services.clientsflow-web.loadbalancer.server.port=3000
- traefik.http.services.clientsflow-websecure.loadbalancer.server.port=3000
networks:
- default
- dokploy-network
networks:
dokploy-network:
external: true

View File

@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

View File

@@ -0,0 +1,14 @@
mutation ArchiveCalendarEventMutation($input: ArchiveCalendarEventInput!) {
archiveCalendarEvent(input: $input) {
id
title
start
end
contact
note
isArchived
createdAt
archiveNote
archivedAt
}
}

View File

@@ -0,0 +1,5 @@
mutation ArchiveChatConversationMutation($id: ID!) {
archiveChatConversation(id: $id) {
ok
}
}

View File

@@ -0,0 +1,10 @@
query ChatConversationsQuery {
chatConversations {
id
title
createdAt
updatedAt
lastMessageAt
lastMessageText
}
}

View File

@@ -0,0 +1,32 @@
query ChatMessagesQuery {
chatMessages {
id
role
text
messageKind
requestId
eventType
phase
transient
thinking
tools
toolRuns {
name
status
input
output
at
}
changeSetId
changeStatus
changeSummary
changeItems {
entity
action
title
before
after
}
createdAt
}
}

View File

@@ -0,0 +1,6 @@
mutation ConfirmLatestChangeSetMutation {
confirmLatestChangeSet {
ok
}
}

View File

@@ -0,0 +1,14 @@
mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
createCalendarEvent(input: $input) {
id
title
start
end
contact
note
isArchived
createdAt
archiveNote
archivedAt
}
}

View File

@@ -0,0 +1,10 @@
mutation CreateChatConversationMutation($title: String) {
createChatConversation(title: $title) {
id
title
createdAt
updatedAt
lastMessageAt
lastMessageText
}
}

View File

@@ -0,0 +1,6 @@
mutation CreateCommunicationMutation($input: CreateCommunicationInput!) {
createCommunication(input: $input) {
ok
id
}
}

View File

@@ -0,0 +1,88 @@
query DashboardQuery {
dashboard {
contacts {
id
name
avatar
company
country
location
channels
lastContactAt
description
}
communications {
id
at
contactId
contact
channel
kind
direction
text
audioUrl
duration
transcript
}
calendar {
id
title
start
end
contact
note
isArchived
createdAt
archiveNote
archivedAt
}
deals {
id
contact
title
company
stage
amount
nextStep
summary
currentStepId
steps {
id
title
description
status
dueAt
order
completedAt
}
}
feed {
id
at
contact
text
proposal {
title
details
key
}
decision
decisionNote
}
pins {
id
contact
text
}
documents {
id
title
type
owner
scope
updatedAt
summary
body
}
}
}

View File

@@ -0,0 +1,5 @@
mutation LogPilotNoteMutation($text: String!) {
logPilotNote(text: $text) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation LoginMutation($phone: String!, $password: String!) {
login(phone: $phone, password: $password) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation LogoutMutation {
logout {
ok
}
}

View File

@@ -0,0 +1,17 @@
query MeQuery {
me {
user {
id
phone
name
}
team {
id
name
}
conversation {
id
title
}
}
}

View File

@@ -0,0 +1,6 @@
mutation RollbackLatestChangeSetMutation {
rollbackLatestChangeSet {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation SelectChatConversationMutation($id: ID!) {
selectChatConversation(id: $id) {
ok
}
}

View File

@@ -0,0 +1,5 @@
mutation SendPilotMessageMutation($text: String!) {
sendPilotMessage(text: $text) {
ok
}
}

View File

@@ -0,0 +1,6 @@
mutation ToggleContactPinMutation($contact: String!, $text: String!) {
toggleContactPin(contact: $contact, text: $text) {
ok
pinned
}
}

View File

@@ -0,0 +1,6 @@
mutation UpdateCommunicationTranscriptMutation($id: ID!, $transcript: [String!]!) {
updateCommunicationTranscript(id: $id, transcript: $transcript) {
ok
id
}
}

View File

@@ -0,0 +1,6 @@
mutation UpdateFeedDecisionMutation($id: ID!, $decision: String!, $decisionNote: String) {
updateFeedDecision(id: $id, decision: $decision, decisionNote: $decisionNote) {
ok
id
}
}

8
frontend/nixpacks.toml Normal file
View File

@@ -0,0 +1,8 @@
[phases.install]
cmds = ["npm ci --legacy-peer-deps"]
[phases.build]
cmds = ["npm run db:generate", "npm run build"]
[start]
cmd = "npm run preview -- --host 0.0.0.0 --port ${PORT:-3000}"

28
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
css: ["~/assets/css/main.css"],
vite: {
plugins: [tailwindcss() as any],
},
modules: ["@nuxt/eslint", "@nuxtjs/apollo"],
runtimeConfig: {
public: {
graphqlHttpEndpoint: process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql",
},
},
apollo: {
clients: {
default: {
httpEndpoint: process.env.GRAPHQL_HTTP_ENDPOINT || "http://localhost:3000/api/graphql",
connectToDevTools: process.dev,
},
},
},
});

23266
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

70
frontend/package.json Normal file
View File

@@ -0,0 +1,70 @@
{
"name": "crm-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "node prisma/seed.mjs",
"dataset:export": "node scripts/export-dataset.mjs",
"dev": "nuxt dev",
"generate": "nuxt generate",
"postinstall": "nuxt prepare && prisma generate",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck",
"worker:delivery": "tsx server/queues/worker.ts",
"codegen": "graphql-codegen --config codegen.ts",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
},
"dependencies": {
"@ai-sdk/vue": "^3.0.91",
"@apollo/client": "^3.14.0",
"@langchain/core": "^0.3.77",
"@langchain/langgraph": "^0.2.74",
"@langchain/openai": "^0.6.9",
"@nuxt/eslint": "^1.15.1",
"@nuxtjs/apollo": "^5.0.0-alpha.15",
"@prisma/client": "^6.16.1",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-collaboration": "^2.27.2",
"@tiptap/extension-collaboration-cursor": "^2.27.2",
"@tiptap/extension-placeholder": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@tiptap/vue-3": "^2.27.2",
"@vue/apollo-composable": "^4.2.2",
"@xenova/transformers": "^2.17.2",
"ai": "^6.0.91",
"bullmq": "^5.58.2",
"daisyui": "^5.5.18",
"graphql": "^16.12.0",
"graphql-tag": "^2.12.6",
"ioredis": "^5.7.0",
"langfuse": "^3.38.6",
"langsmith": "^0.5.4",
"nuxt": "^4.3.1",
"tailwindcss": "^4.1.18",
"vue": "^3.5.27",
"wavesurfer.js": "^7.12.1",
"y-prosemirror": "^1.3.7",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.29",
"zod": "^4.1.5"
},
"devDependencies": {
"@graphql-codegen/cli": "^6.1.1",
"@graphql-codegen/typed-document-node": "^6.1.6",
"@graphql-codegen/typescript": "^5.0.8",
"@graphql-codegen/typescript-operations": "^5.0.8",
"@graphql-codegen/typescript-vue-apollo": "^4.1.2",
"@storybook/addon-essentials": "^8.6.17",
"@storybook/addon-interactions": "^8.6.17",
"@storybook/test": "^8.6.17",
"@storybook/vue3-vite": "^8.6.17",
"prisma": "^6.16.1",
"storybook": "^8.6.17",
"tsx": "^4.20.5"
}
}

View File

@@ -0,0 +1,392 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
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
}
model Team {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members TeamMember[]
contacts Contact[]
calendarEvents CalendarEvent[]
deals Deal[]
conversations ChatConversation[]
chatMessages ChatMessage[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
telegramBusinessConnections TelegramBusinessConnection[]
feedCards FeedCard[]
contactPins ContactPin[]
documents WorkspaceDocument[]
}
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[]
conversations ChatConversation[] @relation("ConversationCreator")
chatMessages ChatMessage[] @relation("ChatAuthor")
}
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
company String?
country String?
location 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[]
@@index([teamId, updatedAt])
}
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
kind ContactMessageKind @default(MESSAGE)
direction MessageDirection
channel MessageChannel
content String
audioUrl String?
durationSec Int?
transcriptJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([contactId, occurredAt])
}
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?
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 ChatConversation {
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 ChatMessage[]
@@index([teamId, updatedAt])
@@index([createdByUserId])
}
model ChatMessage {
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 ChatConversation @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])
}
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])
}

423
frontend/prisma/seed.mjs Normal file
View File

@@ -0,0 +1,423 @@
import { PrismaClient } from "@prisma/client";
import fs from "node:fs";
import path from "node:path";
import { randomBytes, scryptSync } from "node:crypto";
function loadEnvFromDotEnv() {
const p = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(p)) return;
const raw = fs.readFileSync(p, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
let val = trimmed.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (!key) continue;
if (key === "DATABASE_URL") {
if (!process.env[key]) process.env[key] = val;
continue;
}
if (!process.env[key]) process.env[key] = val;
}
}
loadEnvFromDotEnv();
const prisma = new PrismaClient();
const LOGIN_PHONE = "+15550000001";
const LOGIN_PASSWORD = "ConnectFlow#2026";
const LOGIN_NAME = "Владелец Connect";
const REF_DATE_ISO = "2026-02-20T12:00:00.000Z";
const SCRYPT_KEY_LENGTH = 64;
function hashPassword(password) {
const salt = randomBytes(16).toString("base64url");
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
return `scrypt$${salt}$${digest}`;
}
function atOffset(days, hour, minute) {
const d = new Date(REF_DATE_ISO);
d.setDate(d.getDate() + days);
d.setHours(hour, minute, 0, 0);
return d;
}
function plusMinutes(date, minutes) {
const d = new Date(date);
d.setMinutes(d.getMinutes() + minutes);
return d;
}
function buildOdooAiContacts(teamId) {
const prospects = [
{ name: "Оливия Рид", company: "РитейлНова", country: "США", location: "Нью-Йорк", email: "olivia.reed@retailnova.com", phone: "+1 555 120 0101" },
{ name: "Даниэль Ким", company: "ФорджПик Производство", country: "США", location: "Чикаго", email: "daniel.kim@forgepeak.com", phone: "+1 555 120 0102" },
{ name: "Марта Алонсо", company: "Иберия Фудс Групп", country: "Испания", location: "Барселона", email: "marta.alonso@iberiafoods.es", phone: "+34 91 555 0103" },
{ name: "Юсеф Хаддад", company: "ГалфТрейд Дистрибуция", country: "ОАЭ", location: "Дубай", email: "youssef.haddad@gulftrade.ae", phone: "+971 4 555 0104" },
{ name: "Эмма Коллинз", company: "НортБридж Логистика", country: "Великобритания", location: "Лондон", email: "emma.collins@northbridge.co.uk", phone: "+44 20 5550 0105" },
{ name: "Ноа Фишер", company: "Бергман Автозапчасти", country: "Германия", location: "Мюнхен", email: "noah.fischer@bergmann-auto.de", phone: "+49 89 5550 0106" },
{ name: "Ава Чой", company: "Пасифик МедТех Сапплай", country: "Сингапур", location: "Сингапур", email: "ava.choi@pacificmedtech.sg", phone: "+65 6555 0107" },
{ name: "Лиам Дюбуа", company: "ГексаКоммерс", country: "Франция", location: "Париж", email: "liam.dubois@hexacommerce.fr", phone: "+33 1 55 50 0108" },
{ name: "Майя Шах", company: "Зенит Консьюмер Брендс", country: "Канада", location: "Торонто", email: "maya.shah@zenithbrands.ca", phone: "+1 416 555 0109" },
{ name: "Арман Петросян", company: "Арарат Электроникс", country: "Армения", location: "Ереван", email: "arman.petrosyan@ararat-electronics.am", phone: "+374 10 555110" },
{ name: "София Мартинес", company: "Санлайн Товары для дома", country: "США", location: "Остин", email: "sophia.martinez@sunlinehg.com", phone: "+1 555 120 0111" },
{ name: "Лео Новак", company: "ЦентралБилд Материалы", country: "Германия", location: "Берлин", email: "leo.novak@centralbuild.de", phone: "+49 30 5550 0112" },
{ name: "Айла Грант", company: "БлюХарбор Фарма", country: "Великобритания", location: "Манчестер", email: "isla.grant@blueharbor.co.uk", phone: "+44 161 555 0113" },
{ name: "Матео Росси", company: "Милано Фэшн Хаус", country: "Италия", location: "Милан", email: "mateo.rossi@milanofh.it", phone: "+39 02 5550 0114" },
{ name: "Нина Волкова", company: "Полар АгриТех", country: "Казахстан", location: "Алматы", email: "nina.volkova@polaragri.kz", phone: "+7 727 555 0115" },
{ name: "Итан Пак", company: "Вертекс Компонентс", country: "Южная Корея", location: "Сеул", email: "ethan.park@vertexcomponents.kr", phone: "+82 2 555 0116" },
{ name: "Зара Хан", company: "Кресент Ритейл Чейн", country: "ОАЭ", location: "Абу-Даби", email: "zara.khan@crescentretail.ae", phone: "+971 2 555 0117" },
{ name: "Уго Силва", company: "Лузо Индастриал Системс", country: "Португалия", location: "Лиссабон", email: "hugo.silva@lusois.pt", phone: "+351 21 555 0118" },
{ name: "Хлоя Бернар", company: "Сантекс Сеть Клиник", country: "Франция", location: "Лион", email: "chloe.bernard@santex.fr", phone: "+33 4 55 50 0119" },
{ name: "Джеймс Уокер", company: "Метро Оптовая Группа", country: "США", location: "Лос-Анджелес", email: "james.walker@metrowholesale.com", phone: "+1 555 120 0120" },
];
return prospects.map((p, idx) => {
const female = idx % 2 === 0;
const picIdx = (idx % 70) + 1;
return {
teamId,
name: p.name,
company: p.company,
country: p.country,
location: p.location,
avatarUrl: `https://randomuser.me/api/portraits/${female ? "women" : "men"}/${picIdx}.jpg`,
email: p.email,
phone: p.phone,
};
});
}
async function main() {
const passwordHash = hashPassword(LOGIN_PASSWORD);
const user = await prisma.user.upsert({
where: { id: "demo-user" },
update: { phone: LOGIN_PHONE, passwordHash, name: LOGIN_NAME, email: "owner@clientsflow.local" },
create: {
id: "demo-user",
phone: LOGIN_PHONE,
passwordHash,
name: LOGIN_NAME,
email: "owner@clientsflow.local",
},
});
const team = await prisma.team.upsert({
where: { id: "demo-team" },
update: { name: "Connect Рабочее пространство" },
create: { id: "demo-team", name: "Connect Рабочее пространство" },
});
await prisma.teamMember.upsert({
where: { teamId_userId: { teamId: team.id, userId: user.id } },
update: { role: "OWNER" },
create: { teamId: team.id, userId: user.id, role: "OWNER" },
});
const conversation = await prisma.chatConversation.upsert({
where: { id: `pilot-${team.id}` },
update: { title: "Пилот" },
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Пилот" },
});
await prisma.$transaction([
prisma.feedCard.deleteMany({ where: { teamId: team.id } }),
prisma.contactPin.deleteMany({ where: { teamId: team.id } }),
prisma.workspaceDocument.deleteMany({ where: { teamId: team.id } }),
prisma.deal.deleteMany({ where: { teamId: team.id } }),
prisma.calendarEvent.deleteMany({ where: { teamId: team.id } }),
prisma.contactMessage.deleteMany({ where: { contact: { teamId: team.id } } }),
prisma.chatMessage.deleteMany({ where: { teamId: team.id, conversationId: conversation.id } }),
prisma.omniMessage.deleteMany({ where: { teamId: team.id } }),
prisma.omniThread.deleteMany({ where: { teamId: team.id } }),
prisma.omniContactIdentity.deleteMany({ where: { teamId: team.id } }),
prisma.telegramBusinessConnection.deleteMany({ where: { teamId: team.id } }),
prisma.contact.deleteMany({ where: { teamId: team.id } }),
]);
const contacts = await prisma.contact.createManyAndReturn({
data: buildOdooAiContacts(team.id),
select: { id: true, name: true, company: true },
});
const integrationModules = [
"Продажи + CRM + копилот прогнозирования",
"Склад + прогноз спроса",
"Закупки + оценка рисков поставщиков",
"Бухгалтерия + AI-детекция аномалий",
"Поддержка + ассистент триажа заявок",
"Производство + AI-планирование мощностей",
];
await prisma.contactNote.createMany({
data: contacts.map((c, idx) => ({
contactId: c.id,
content:
`${c.company ?? c.name} рассматривает внедрение Odoo с AI-расширениями. ` +
`Основной контур интеграции: ${integrationModules[idx % integrationModules.length]}. ` +
`Ключевой драйвер покупки: сократить ручные операции и ускорить цикл принятия решений. ` +
`Следующая веха: провести сессию уточнения, согласовать владельцев данных и утвердить KPI пилота.`,
})),
});
const channels = ["TELEGRAM", "WHATSAPP", "INSTAGRAM", "EMAIL"];
const contactMessages = [];
for (let i = 0; i < contacts.length; i += 1) {
const contact = contacts[i];
const base = atOffset(-(i % 18), 9 + (i % 7), (i * 7) % 60);
contactMessages.push({
contactId: contact.id,
kind: "MESSAGE",
direction: "IN",
channel: channels[i % channels.length],
content: `Здравствуйте! Мы рассматриваем запуск Odoo + AI для ${contact.company}. Можем согласовать план интеграции на этой неделе?`,
occurredAt: base,
});
contactMessages.push({
contactId: contact.id,
kind: "MESSAGE",
direction: "OUT",
channel: channels[(i + 1) % channels.length],
content: "Да, предлагаю 45-минутный разбор: процессы, ограничения API и KPI пилота.",
occurredAt: plusMinutes(base, 22),
});
contactMessages.push({
contactId: contact.id,
kind: "MESSAGE",
direction: i % 3 === 0 ? "OUT" : "IN",
channel: channels[(i + 2) % channels.length],
content: "Обновление статуса: технический объём ясен; блокер — согласование бюджета и анкета по безопасности.",
occurredAt: plusMinutes(base, 65),
});
if (i % 3 === 0) {
contactMessages.push({
contactId: contact.id,
kind: "CALL",
direction: "OUT",
channel: "PHONE",
content: "Созвон по уточнению: модули Odoo, потоки данных и AI-сценарии",
audioUrl: "/audio-samples/national-road-9.m4a",
durationSec: 180 + ((i * 23) % 420),
occurredAt: plusMinutes(base, 110),
});
}
}
await prisma.contactMessage.createMany({ data: contactMessages });
await prisma.calendarEvent.createMany({
data: contacts.flatMap((c, idx) => {
// Историческая неделя до 20 Feb 2026: все сидовые встречи завершены.
const firstStart = atOffset(-6 + (idx % 5), 10 + (idx % 6), (idx * 5) % 60);
const secondStart = atOffset(-5 + (idx % 5), 14 + (idx % 4), (idx * 3) % 60);
return [
{
teamId: team.id,
contactId: c.id,
title: `Сессия уточнения: Odoo + AI с ${c.company ?? c.name}`,
startsAt: firstStart,
endsAt: plusMinutes(firstStart, 30),
note: "Подтвердить рамки интеграции, текущий стек и метрики успеха пилота.",
},
{
teamId: team.id,
contactId: c.id,
title: `Архитектурный воркшоп: ${c.company ?? c.name}`,
startsAt: secondStart,
endsAt: plusMinutes(secondStart, 45),
note: "Проверить маппинг API, границы ETL и ограничения для AI-ассистента.",
},
];
}),
});
const stages = ["Лид", "Уточнение", "Подбор решения", "Коммерческое предложение", "Переговоры", "Пилот", "Проверка договора"];
for (const [idx, c] of contacts.entries()) {
const nextStepText =
idx % 4 === 0
? "Отправить предложение по пилоту и зафиксировать список задач интеграции."
: "Провести воркшоп по решению и согласовать сроки с коммерческим владельцем.";
const deal = await prisma.deal.create({
data: {
teamId: team.id,
contactId: c.id,
title: `${c.company ?? "Клиент"}: интеграция Odoo + AI`,
stage: stages[idx % stages.length],
amount: 18000 + (idx % 8) * 7000,
nextStep: nextStepText,
summary:
"Потенциальная сделка на поэтапное внедрение Odoo с AI-копилотами для операций, продаж и планирования. " +
"Коммерческая модель: уточнение + пилот + тиражирование.",
},
select: { id: true },
});
const dueBase = atOffset((idx % 5) + 1, 11 + (idx % 4), 0);
const steps = [
{
dealId: deal.id,
title: "Собрать уточняющие требования",
description: "Подтвердить модули Odoo, владельцев данных и критерии успеха.",
status: "done",
order: 1,
completedAt: atOffset(-2 - (idx % 3), 16, 0),
dueAt: atOffset(-1, 12, 0),
},
{
dealId: deal.id,
title: "Провести воркшоп по решению",
description: "Согласовать границы интеграции и план пилота.",
status: idx % 3 === 0 ? "in_progress" : "todo",
order: 2,
dueAt: dueBase,
},
{
dealId: deal.id,
title: "Согласовать и отправить договор",
description: "Выслать договор и зафиксировать дату подписи.",
status: "todo",
order: 3,
dueAt: atOffset((idx % 5) + 6, 15, 0),
},
];
await prisma.dealStep.createMany({ data: steps });
const current = await prisma.dealStep.findFirst({
where: { dealId: deal.id, status: { not: "done" } },
orderBy: [{ order: "asc" }, { createdAt: "asc" }],
select: { id: true },
});
await prisma.deal.update({
where: { id: deal.id },
data: { currentStepId: current?.id ?? null },
});
}
await prisma.contactPin.createMany({
data: contacts.map((c, idx) => ({
teamId: team.id,
contactId: c.id,
text:
idx % 3 === 0
? "Уточнить владельца ERP, владельца данных и целевой квартал запуска."
: "Держать коммуникацию вокруг одного KPI и следующего шага.",
})),
});
const proposalKeys = ["create_followup", "open_comm", "call", "draft_message", "run_summary", "prepare_question"];
await prisma.feedCard.createMany({
data: contacts
.filter((_, idx) => idx % 3 === 0)
.slice(0, 80)
.map((c, idx) => ({
teamId: team.id,
contactId: c.id,
happenedAt: atOffset(-(idx % 6), 9 + (idx % 8), (idx * 9) % 60),
text:
`Я проверил активность по аккаунту ${c.company ?? c.name} в рамках сделки Odoo + AI. ` +
"Есть достаточный импульс, чтобы перевести сделку на следующий этап при чётком следующем шаге.",
proposalJson: {
title: idx % 2 === 0 ? "Назначить созвон по рамкам пилота" : "Отправить сообщение для разблокировки у владельца бюджета",
details: [
`Контакт: ${c.name}`,
idx % 2 === 0 ? "Когда: на этой неделе, 45 минут" : "Когда: сегодня в основном канале",
"Цель: подтвердить объём, владельца и следующую коммерческую контрольную точку",
],
key: proposalKeys[idx % proposalKeys.length],
},
})),
});
await prisma.workspaceDocument.createMany({
data: [
{
teamId: team.id,
title: "Чеклист уточнения для интеграции Odoo",
type: "Regulation",
owner: "Команда решений",
scope: "Предпродажное уточнение",
summary: "Обязательные вопросы перед оценкой запуска Odoo + AI.",
body: "## Нужно зафиксировать\n- Текущие модули ERP\n- Точки интеграции\n- Владельца данных по каждому домену\n- Ограничения безопасности\n- Базовые KPI пилота",
updatedAt: atOffset(-1, 11, 10),
},
{
teamId: team.id,
title: "Плейбук AI-копилота для Odoo",
type: "Playbook",
owner: "Лид AI-практики",
scope: "Квалификация сценариев",
summary: "Как позиционировать прогнозирование, ассистента и детекцию аномалий.",
body: "## Поток\n1. Боль процесса\n2. Качество данных\n3. Целевая модель\n4. KPI успеха\n5. Объём пилота",
updatedAt: atOffset(-2, 15, 0),
},
{
teamId: team.id,
title: "Матрица цен для пилота",
type: "Policy",
owner: "Коммерческие операции",
scope: "Контракты уточнения и пилота",
summary: "Диапазоны цен для уточнения, пилота и продуктивной фазы.",
body: "## Типовые диапазоны\n- Уточнение: 5k-12k\n- Пилот: 15k-45k\n- Тиражирование: 50k+\n\nВсегда привязывай стоимость к объёму и срокам.",
updatedAt: atOffset(-3, 9, 30),
},
{
teamId: team.id,
title: "Шаблон по безопасности и комплаенсу",
type: "Template",
owner: "Офис внедрения",
scope: "Крупные клиенты",
summary: "Шаблон ответов по data residency, RBAC, аудиту и обработке PII.",
body: "## Разделы\n- Модель хостинга\n- Контроль доступа\n- Логирование и аудит\n- Срок хранения данных\n- Реакция на инциденты",
updatedAt: atOffset(-4, 13, 45),
},
{
teamId: team.id,
title: "Референс интеграционной архитектуры",
type: "Playbook",
owner: "Архитектурная команда",
scope: "Технические воркшопы",
summary: "Референс-архитектура для коннекторов Odoo, ETL и AI-сервисного слоя.",
body: "## Слои\n- Базовые модули Odoo\n- Интеграционная шина\n- Хранилище данных\n- Эндпоинты AI-сервиса\n- Мониторинг",
updatedAt: atOffset(-5, 10, 0),
},
{
teamId: team.id,
title: "Чеклист готовности к запуску",
type: "Regulation",
owner: "PMO",
scope: "Переход от пилота к продакшену",
summary: "Чеклист перехода от приёмки пилота к запуску в прод.",
body: "## Обязательно\n- KPI пилота утверждены\n- Backlog тиражирования приоритизирован\n- Владельцы назначены\n- Модель поддержки определена",
updatedAt: atOffset(-6, 16, 15),
},
],
});
console.log("Seed completed.");
console.log(`Login phone: ${LOGIN_PHONE}`);
console.log(`Login password: ${LOGIN_PASSWORD}`);
console.log(`Team: ${team.name}`);
console.log(`Contacts created: ${contacts.length}`);
}
main()
.catch((e) => {
console.error(e);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

Binary file not shown.

43
frontend/scripts/compose-dev.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
# Clean previous build artifacts before production build.
mkdir -p .nuxt .output
find .nuxt -mindepth 1 -maxdepth 1 -exec rm -rf {} + || true
find .output -mindepth 1 -maxdepth 1 -exec rm -rf {} + || true
rm -rf node_modules/.cache node_modules/.vite
# Install deps (container starts from a clean image).
# This workspace has mixed Apollo/Nuxt peer graphs; keep install deterministic in Docker.
npm install --legacy-peer-deps
# sharp is a native module and can break when cached node_modules were installed
# for a different CPU variant (for example arm64v8). Force a local rebuild.
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
npm rebuild sharp --platform=linux --arch=arm64v8 \
|| npm rebuild sharp --platform=linux --arch=arm64 \
|| npm install sharp --platform=linux --arch=arm64v8 --save-exact=false \
|| npm install sharp --platform=linux --arch=arm64 --save-exact=false
elif [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then
npm rebuild sharp --platform=linux --arch=x64 \
|| npm install sharp --platform=linux --arch=x64 --save-exact=false
else
npm rebuild sharp || true
fi
# Wait until PostgreSQL is reachable before applying schema.
until node -e "const u=new URL(process.env.DATABASE_URL||''); const net=require('net'); const s=net.createConnection({host:u.hostname,port:Number(u.port||5432)}); s.on('connect',()=>{s.end(); process.exit(0);}); s.on('error',()=>process.exit(1));" ; do
echo "Waiting for PostgreSQL..."
sleep 1
done
npx prisma db push
# Run Nuxt in production mode (Nitro server), no dev/preview runtime.
npm run build
export NITRO_HOST=0.0.0.0
export NITRO_PORT=3000
exec node .output/server/index.mjs

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
# Worker container starts from clean image.
# Install deps without frontend postinstall hooks (nuxt prepare) to keep worker lean/stable.
npm install --ignore-scripts --legacy-peer-deps
ARCH="$(uname -m)"
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
npm rebuild sharp --platform=linux --arch=arm64v8 \
|| npm rebuild sharp --platform=linux --arch=arm64 \
|| npm install sharp --platform=linux --arch=arm64v8 --save-exact=false \
|| npm install sharp --platform=linux --arch=arm64 --save-exact=false
elif [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "amd64" ]; then
npm rebuild sharp --platform=linux --arch=x64 \
|| npm install sharp --platform=linux --arch=x64 --save-exact=false
else
npm rebuild sharp || true
fi
npx prisma generate
# Ensure DB is reachable before the worker starts consuming jobs.
until node -e "const u=new URL(process.env.DATABASE_URL||''); const net=require('net'); const s=net.createConnection({host:u.hostname,port:Number(u.port||5432)}); s.on('connect',()=>{s.end(); process.exit(0);}); s.on('error',()=>process.exit(1));" ; do
echo "Waiting for PostgreSQL..."
sleep 1
done
exec npm run worker:delivery

View File

@@ -0,0 +1,182 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { PrismaClient } from "@prisma/client";
function loadEnvFromDotEnv() {
const p = path.resolve(process.cwd(), ".env");
if (!fsSync.existsSync(p)) return;
const raw = fsSync.readFileSync(p, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
let val = trimmed.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (!key) continue;
if (key === "DATABASE_URL") {
process.env[key] = val;
continue;
}
if (!process.env[key]) process.env[key] = val;
}
}
loadEnvFromDotEnv();
const prisma = new PrismaClient();
function datasetRoot() {
const teamId = process.env.TEAM_ID || "demo-team";
const userId = process.env.USER_ID || "demo-user";
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", teamId, "users", userId);
}
async function ensureDir(p) {
await fs.mkdir(p, { recursive: true });
}
async function writeJson(p, value) {
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
}
function jsonlLine(value) {
return JSON.stringify(value) + "\n";
}
async function main() {
const root = datasetRoot();
const tmp = root + ".tmp";
await fs.rm(tmp, { recursive: true, force: true });
await ensureDir(tmp);
const contactsDir = path.join(tmp, "contacts");
const notesDir = path.join(tmp, "notes");
const messagesDir = path.join(tmp, "messages");
const eventsDir = path.join(tmp, "events");
const indexDir = path.join(tmp, "index");
await Promise.all([
ensureDir(contactsDir),
ensureDir(notesDir),
ensureDir(messagesDir),
ensureDir(eventsDir),
ensureDir(indexDir),
]);
const teamId = process.env.TEAM_ID || "demo-team";
const contacts = await prisma.contact.findMany({
where: { teamId },
orderBy: { updatedAt: "desc" },
include: {
note: { select: { content: true, updatedAt: true } },
messages: {
select: {
kind: true,
direction: true,
channel: true,
content: true,
durationSec: true,
transcriptJson: true,
occurredAt: true,
},
orderBy: { occurredAt: "asc" },
},
events: {
select: { title: true, startsAt: true, endsAt: true, status: true, note: true },
orderBy: { startsAt: "asc" },
},
},
take: 5000,
});
const contactIndex = [];
for (const c of contacts) {
await writeJson(path.join(contactsDir, `${c.id}.json`), {
id: c.id,
teamId: c.teamId,
name: c.name,
company: c.company ?? null,
country: c.country ?? null,
location: c.location ?? null,
avatarUrl: c.avatarUrl ?? null,
email: c.email ?? null,
phone: c.phone ?? null,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
});
await fs.writeFile(
path.join(notesDir, `${c.id}.md`),
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
"utf8",
);
await fs.writeFile(
path.join(messagesDir, `${c.id}.jsonl`),
c.messages
.map((m) =>
jsonlLine({
kind: m.kind,
direction: m.direction,
channel: m.channel,
occurredAt: m.occurredAt,
content: m.content,
durationSec: m.durationSec ?? null,
transcript: m.transcriptJson ?? null,
}),
)
.join(""),
"utf8",
);
await fs.writeFile(
path.join(eventsDir, `${c.id}.jsonl`),
c.events
.map((e) =>
jsonlLine({
title: e.title,
startsAt: e.startsAt,
endsAt: e.endsAt,
status: e.status ?? null,
note: e.note ?? null,
}),
)
.join(""),
"utf8",
);
const lastMessageAt = c.messages.length ? c.messages[c.messages.length - 1].occurredAt : null;
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
contactIndex.push({
id: c.id,
name: c.name,
company: c.company ?? null,
lastMessageAt,
nextEventAt,
updatedAt: c.updatedAt,
});
}
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
await writeJson(path.join(tmp, "meta.json"), { exportedAt: new Date().toISOString(), version: 1 });
await ensureDir(path.dirname(root));
await fs.rm(root, { recursive: true, force: true });
await fs.rename(tmp, root);
console.log("exported", root);
}
await main()
.catch((e) => {
console.error(e);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,270 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ChatRole, Prisma } from "@prisma/client";
import { prisma } from "../utils/prisma";
import { datasetRoot } from "../dataset/paths";
import { ensureDataset } from "../dataset/exporter";
import { runLangGraphCrmAgentFor } from "./langgraphCrmAgent";
import type { ChangeSet } from "../utils/changeSet";
type ContactIndexRow = {
id: string;
name: string;
company: string | null;
lastMessageAt: string | null;
nextEventAt: string | null;
updatedAt: string;
};
export type AgentReply = {
text: string;
plan: string[];
tools: string[];
thinking?: string[];
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}>;
dbWrites?: Array<{ kind: string; detail: string }>;
};
export type AgentTraceEvent = {
text: string;
toolRun?: {
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
};
};
function normalize(s: string) {
return s.trim().toLowerCase();
}
function isToday(date: Date) {
const now = new Date();
return (
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
);
}
async function readContactIndex(): Promise<ContactIndexRow[]> {
throw new Error("readContactIndex now requires dataset root");
}
async function readContactIndexFrom(root: string): Promise<ContactIndexRow[]> {
const p = path.join(root, "index", "contacts.json");
const raw = await fs.readFile(p, "utf8");
return JSON.parse(raw);
}
async function countJsonlLines(p: string): Promise<number> {
const raw = await fs.readFile(p, "utf8");
if (!raw.trim()) return 0;
// cheap line count (JSONL is 1 item per line)
return raw.trimEnd().split("\n").length;
}
async function readJsonl(p: string): Promise<any[]> {
const raw = await fs.readFile(p, "utf8");
if (!raw.trim()) return [];
return raw
.trimEnd()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line));
}
function formatContactLine(c: ContactIndexRow) {
const company = c.company ? ` (${c.company})` : "";
const lastAt = c.lastMessageAt ? new Date(c.lastMessageAt).toLocaleString("ru-RU") : "нет";
return `- ${c.name}${company} · последнее: ${lastAt}`;
}
export async function runCrmAgent(userText: string): Promise<AgentReply> {
throw new Error("runCrmAgent now requires auth context");
}
export async function runCrmAgentFor(
input: {
teamId: string;
userId: string;
userText: string;
requestId?: string;
conversationId?: string;
onTrace?: (event: AgentTraceEvent) => Promise<void> | void;
},
): Promise<AgentReply> {
const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase();
const llmApiKey =
process.env.OPENROUTER_API_KEY ||
process.env.LLM_API_KEY ||
process.env.OPENAI_API_KEY ||
process.env.DASHSCOPE_API_KEY ||
process.env.QWEN_API_KEY;
const hasGigachat = Boolean((process.env.GIGACHAT_AUTH_KEY ?? "").trim() && (process.env.GIGACHAT_SCOPE ?? "").trim());
if (mode !== "rule") {
return runLangGraphCrmAgentFor(input);
}
if (!llmApiKey && !hasGigachat) {
throw new Error("LLM API key is not configured. Set OPENROUTER_API_KEY or GIGACHAT_AUTH_KEY/GIGACHAT_SCOPE.");
}
await ensureDataset({ teamId: input.teamId, userId: input.userId });
const q = normalize(input.userText);
const root = datasetRoot({ teamId: input.teamId, userId: input.userId });
const contacts = await readContactIndexFrom(root);
// "10 лучших клиентов"
if (q.includes("10 лучших") || (q.includes("топ") && q.includes("клиент"))) {
const ranked = await Promise.all(
contacts.map(async (c) => {
const msgPath = path.join(root, "messages", `${c.id}.jsonl`);
const evPath = path.join(root, "events", `${c.id}.jsonl`);
const msgCount = await countJsonlLines(msgPath).catch(() => 0);
const ev = await readJsonl(evPath).catch(() => []);
const todayEvCount = ev.filter((e) => (e?.startsAt ? isToday(new Date(e.startsAt)) : false)).length;
const score = msgCount * 2 + todayEvCount * 3;
return { c, score };
}),
);
ranked.sort((a, b) => b.score - a.score);
const top = ranked.slice(0, 10).map((x) => x.c);
return {
plan: [
"Загрузить индекс контактов из файлового датасета",
"Посчитать активность по JSONL (сообщения/события сегодня)",
"Отсортировать и показать топ",
],
tools: ["read index/contacts.json", "read messages/{contactId}.jsonl", "read events/{contactId}.jsonl"],
toolRuns: [
{
name: "dataset:index_contacts",
status: "ok",
input: "index/contacts.json",
output: "Loaded contacts index",
at: new Date().toISOString(),
},
],
text:
`Топ-10 по активности (сообщения + события):\n` +
top.map(formatContactLine).join("\n") +
`\n\nЕсли хочешь, скажи критерий "лучший" (выручка/стадия/вероятность/давность) и я пересчитаю.`,
};
}
// "чем заняться сегодня"
if (q.includes("чем") && (q.includes("сегодня") || q.includes("заняться"))) {
const todayEvents: Array<{ who: string; title: string; at: Date; note?: string | null }> = [];
for (const c of contacts) {
const evPath = path.join(root, "events", `${c.id}.jsonl`);
const ev = await readJsonl(evPath).catch(() => []);
for (const e of ev) {
if (!e?.startsAt) continue;
const at = new Date(e.startsAt);
if (!isToday(at)) continue;
todayEvents.push({ who: c.name, title: e.title ?? "Event", at, note: e.note ?? null });
}
}
todayEvents.sort((a, b) => a.at.getTime() - b.at.getTime());
const followups = [...contacts]
.map((c) => ({ c, last: c.lastMessageAt ? new Date(c.lastMessageAt).getTime() : 0 }))
.sort((a, b) => a.last - b.last)
.slice(0, 3)
.map((x) => x.c);
const lines: string[] = [];
if (todayEvents.length > 0) {
lines.push("Сегодня по календарю:");
for (const e of todayEvents) {
const hhmm = e.at.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
lines.push(`- ${hhmm} · ${e.title} · ${e.who}${e.note ? ` · ${e.note}` : ""}`);
}
} else {
lines.push("Сегодня нет запланированных событий в календаре.");
}
lines.push("");
lines.push("Фокус дня (если нужно добить прогресс):");
for (const c of followups) {
lines.push(`- Написать follow-up: ${c.name}${c.company ? ` (${c.company})` : ""}`);
}
return {
plan: [
"Прочитать события на сегодня из файлового датасета",
"Найти контакты без свежего касания (по lastMessageAt)",
"Сформировать короткий список действий",
],
tools: ["read index/contacts.json", "read events/{contactId}.jsonl"],
toolRuns: [
{
name: "dataset:query_events",
status: "ok",
input: "events/*.jsonl (today)",
output: `Found ${todayEvents.length} events`,
at: new Date().toISOString(),
},
],
text: lines.join("\n"),
};
}
throw new Error(
"Rule mode supports only structured built-in queries. Use a supported query or switch to langgraph mode with a configured LLM API key.",
);
}
export async function persistChatMessage(input: {
role: ChatRole;
text: string;
plan?: string[];
tools?: string[];
thinking?: string[];
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}>;
changeSet?: ChangeSet | null;
requestId?: string;
eventType?: "user" | "trace" | "assistant" | "note";
phase?: "pending" | "running" | "final" | "error";
transient?: boolean;
messageKind?: "change_set_summary";
teamId: string;
conversationId: string;
authorUserId?: string | null;
}) {
const hasStoredPayload = Boolean(input.changeSet || input.messageKind);
const data: Prisma.ChatMessageCreateInput = {
team: { connect: { id: input.teamId } },
conversation: { connect: { id: input.conversationId } },
authorUser: input.authorUserId ? { connect: { id: input.authorUserId } } : undefined,
role: input.role,
text: input.text,
planJson: hasStoredPayload
? ({
messageKind: input.messageKind ?? null,
changeSet: input.changeSet ?? null,
} as any)
: undefined,
};
return prisma.chatMessage.create({ data });
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import { readBody } from "h3";
import { graphql } from "graphql";
import { getAuthContext } from "../utils/auth";
import { crmGraphqlRoot, crmGraphqlSchema } from "../graphql/schema";
type GraphqlBody = {
query?: string;
operationName?: string;
variables?: Record<string, unknown>;
};
export default defineEventHandler(async (event) => {
const body = await readBody<GraphqlBody>(event);
if (!body?.query || !body.query.trim()) {
throw createError({ statusCode: 400, statusMessage: "GraphQL query is required" });
}
let auth = null;
try {
auth = await getAuthContext(event);
} catch {
auth = null;
}
const result = await graphql({
schema: crmGraphqlSchema,
source: body.query,
rootValue: crmGraphqlRoot,
contextValue: { auth, event },
variableValues: body.variables,
operationName: body.operationName,
});
return {
data: result.data ?? null,
errors: result.errors?.map((error) => ({ message: error.message })) ?? undefined,
};
});

View File

@@ -0,0 +1,60 @@
import { readBody } from "h3";
import { getAuthContext } from "../../../utils/auth";
import { prisma } from "../../../utils/prisma";
import { enqueueOutboundDelivery } from "../../../queues/outboundDelivery";
type EnqueueBody = {
omniMessageId?: string;
endpoint?: string;
method?: "POST" | "PUT" | "PATCH";
headers?: Record<string, string>;
payload?: unknown;
provider?: string;
channel?: string;
attempts?: number;
};
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const body = await readBody<EnqueueBody>(event);
const omniMessageId = String(body?.omniMessageId ?? "").trim();
const endpoint = String(body?.endpoint ?? "").trim();
if (!omniMessageId) {
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
}
if (!endpoint) {
throw createError({ statusCode: 400, statusMessage: "endpoint is required" });
}
const msg = await prisma.omniMessage.findFirst({
where: { id: omniMessageId, teamId: auth.teamId },
select: { id: true },
});
if (!msg) {
throw createError({ statusCode: 404, statusMessage: "omni message not found" });
}
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
const job = await enqueueOutboundDelivery(
{
omniMessageId,
endpoint,
method: body?.method ?? "POST",
headers: body?.headers ?? {},
payload: body?.payload ?? {},
provider: body?.provider ?? undefined,
channel: body?.channel ?? undefined,
},
{
attempts,
},
);
return {
ok: true,
queue: "omni-outbound",
jobId: job.id,
omniMessageId,
};
});

View File

@@ -0,0 +1,32 @@
import { readBody } from "h3";
import { getAuthContext } from "../../../utils/auth";
import { prisma } from "../../../utils/prisma";
import { enqueueTelegramSend } from "../../../queues/telegramSend";
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const body = await readBody<{ omniMessageId?: string; attempts?: number }>(event);
const omniMessageId = String(body?.omniMessageId ?? "").trim();
if (!omniMessageId) {
throw createError({ statusCode: 400, statusMessage: "omniMessageId is required" });
}
const msg = await prisma.omniMessage.findFirst({
where: { id: omniMessageId, teamId: auth.teamId, channel: "TELEGRAM", direction: "OUT" },
select: { id: true },
});
if (!msg) {
throw createError({ statusCode: 404, statusMessage: "telegram outbound message not found" });
}
const attempts = Math.max(1, Math.min(Number(body?.attempts ?? 12), 50));
const job = await enqueueTelegramSend({ omniMessageId }, { attempts });
return {
ok: true,
queue: "omni-outbound",
jobId: job.id,
omniMessageId,
};
});

View File

@@ -0,0 +1,182 @@
import { readBody } from "h3";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
import { getAuthContext } from "../utils/auth";
import { prisma } from "../utils/prisma";
import { buildChangeSet, captureSnapshot } from "../utils/changeSet";
import { persistChatMessage, runCrmAgentFor, type AgentTraceEvent } from "../agent/crmAgent";
import type { ChangeSet } from "../utils/changeSet";
function extractMessageText(message: any): string {
if (!message || !Array.isArray(message.parts)) return "";
return message.parts
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("")
.trim();
}
function getLastUserText(messages: any[]): string {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== "user") continue;
const text = extractMessageText(message);
if (text) return text;
}
return "";
}
function humanizeTraceText(trace: AgentTraceEvent): string {
if (trace.toolRun?.name) {
return `Использую инструмент: ${trace.toolRun.name}`;
}
const text = (trace.text ?? "").trim();
if (!text) return "Агент работает с данными CRM.";
if (text.toLowerCase().includes("ошиб")) return "Возникла ошибка шага, пробую другой путь.";
if (text.toLowerCase().includes("итог")) return "Готовлю финальный ответ.";
return text;
}
function renderChangeSetSummary(changeSet: ChangeSet): string {
const totals = { created: 0, updated: 0, deleted: 0 };
for (const item of changeSet.items) {
if (item.action === "created") totals.created += 1;
else if (item.action === "updated") totals.updated += 1;
else if (item.action === "deleted") totals.deleted += 1;
}
const byEntity = new Map<string, number>();
for (const item of changeSet.items) {
byEntity.set(item.entity, (byEntity.get(item.entity) ?? 0) + 1);
}
const lines = [
"Technical change summary",
`Total: ${changeSet.items.length} · Created: ${totals.created} · Updated: ${totals.updated} · Archived: ${totals.deleted}`,
...[...byEntity.entries()].map(([entity, count]) => `- ${entity}: ${count}`),
];
return lines.join("\n");
}
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const body = await readBody<{ messages?: any[] }>(event);
const messages = Array.isArray(body?.messages) ? body.messages : [];
const userText = getLastUserText(messages);
if (!userText) {
throw createError({ statusCode: 400, statusMessage: "Last user message is required" });
}
const requestId = `req_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`;
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const textId = `text-${Date.now()}`;
writer.write({ type: "start" });
try {
const snapshotBefore = await captureSnapshot(prisma, auth.teamId);
await persistChatMessage({
teamId: auth.teamId,
conversationId: auth.conversationId,
authorUserId: auth.userId,
role: "USER",
text: userText,
requestId,
eventType: "user",
phase: "final",
transient: false,
});
const reply = await runCrmAgentFor({
teamId: auth.teamId,
userId: auth.userId,
userText,
requestId,
conversationId: auth.conversationId,
onTrace: async (trace: AgentTraceEvent) => {
writer.write({
type: "data-agent-log",
data: {
requestId,
at: new Date().toISOString(),
text: humanizeTraceText(trace),
},
});
},
});
const snapshotAfter = await captureSnapshot(prisma, auth.teamId);
const changeSet = buildChangeSet(snapshotBefore, snapshotAfter);
await persistChatMessage({
teamId: auth.teamId,
conversationId: auth.conversationId,
authorUserId: null,
role: "ASSISTANT",
text: reply.text,
requestId,
eventType: "assistant",
phase: "final",
transient: false,
});
if (changeSet) {
await persistChatMessage({
teamId: auth.teamId,
conversationId: auth.conversationId,
authorUserId: null,
role: "ASSISTANT",
text: renderChangeSetSummary(changeSet),
requestId,
eventType: "note",
phase: "final",
transient: false,
messageKind: "change_set_summary",
changeSet,
});
}
writer.write({ type: "text-start", id: textId });
writer.write({ type: "text-delta", id: textId, delta: reply.text });
writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish", finishReason: "stop" });
} catch (error: any) {
const errorText = String(error?.message ?? error);
await persistChatMessage({
teamId: auth.teamId,
conversationId: auth.conversationId,
authorUserId: null,
role: "ASSISTANT",
text: errorText,
requestId,
eventType: "assistant",
phase: "error",
transient: false,
});
writer.write({
type: "data-agent-log",
data: {
requestId,
at: new Date().toISOString(),
text: "Ошибка выполнения агентского цикла.",
},
});
writer.write({ type: "text-start", id: textId });
writer.write({
type: "text-delta",
id: textId,
delta: errorText,
});
writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish", finishReason: "stop" });
}
},
});
return createUIMessageStreamResponse({ stream });
});

View File

@@ -0,0 +1,62 @@
import { readBody } from "h3";
import { getAuthContext } from "../utils/auth";
import { transcribeWithWhisper } from "../utils/whisper";
type TranscribeBody = {
audioBase64?: string;
sampleRate?: number;
language?: string;
};
function decodeBase64Pcm16(audioBase64: string) {
const pcmBuffer = Buffer.from(audioBase64, "base64");
if (pcmBuffer.length < 2) return new Float32Array();
const sampleCount = Math.floor(pcmBuffer.length / 2);
const out = new Float32Array(sampleCount);
for (let i = 0; i < sampleCount; i += 1) {
const lo = pcmBuffer[i * 2]!;
const hi = pcmBuffer[i * 2 + 1]!;
const int16 = (hi << 8) | lo;
const signed = int16 >= 0x8000 ? int16 - 0x10000 : int16;
out[i] = signed / 32768;
}
return out;
}
export default defineEventHandler(async (event) => {
await getAuthContext(event);
const body = await readBody<TranscribeBody>(event);
const audioBase64 = String(body?.audioBase64 ?? "").trim();
const sampleRateRaw = Number(body?.sampleRate ?? 0);
const language = String(body?.language ?? "").trim() || undefined;
if (!audioBase64) {
throw createError({ statusCode: 400, statusMessage: "audioBase64 is required" });
}
if (!Number.isFinite(sampleRateRaw) || sampleRateRaw < 8000 || sampleRateRaw > 48000) {
throw createError({ statusCode: 400, statusMessage: "sampleRate must be between 8000 and 48000" });
}
const samples = decodeBase64Pcm16(audioBase64);
if (!samples.length) {
throw createError({ statusCode: 400, statusMessage: "Audio is empty" });
}
const maxSamples = Math.floor(sampleRateRaw * 120);
if (samples.length > maxSamples) {
throw createError({ statusCode: 413, statusMessage: "Audio is too long (max 120s)" });
}
const text = await transcribeWithWhisper({
samples,
sampleRate: sampleRateRaw,
language,
});
return { text };
});

View File

@@ -0,0 +1,156 @@
import fs from "node:fs/promises";
import path from "node:path";
import { prisma } from "../utils/prisma";
import { datasetRoot } from "./paths";
type ExportMeta = {
exportedAt: string;
version: number;
};
async function ensureDir(p: string) {
await fs.mkdir(p, { recursive: true });
}
async function writeJson(p: string, value: unknown) {
await fs.writeFile(p, JSON.stringify(value, null, 2) + "\n", "utf8");
}
function jsonlLine(value: unknown) {
return JSON.stringify(value) + "\n";
}
export async function exportDatasetFromPrisma() {
throw new Error("exportDatasetFromPrisma now requires { teamId, userId }");
}
export async function exportDatasetFromPrismaFor(input: { teamId: string; userId: string }) {
const root = datasetRoot(input);
const tmp = root + ".tmp";
await fs.rm(tmp, { recursive: true, force: true });
await ensureDir(tmp);
const contactsDir = path.join(tmp, "contacts");
const notesDir = path.join(tmp, "notes");
const messagesDir = path.join(tmp, "messages");
const eventsDir = path.join(tmp, "events");
const indexDir = path.join(tmp, "index");
await Promise.all([
ensureDir(contactsDir),
ensureDir(notesDir),
ensureDir(messagesDir),
ensureDir(eventsDir),
ensureDir(indexDir),
]);
const contacts = await prisma.contact.findMany({
where: { teamId: input.teamId },
orderBy: { updatedAt: "desc" },
include: {
note: { select: { content: true, updatedAt: true } },
messages: {
select: {
kind: true,
direction: true,
channel: true,
content: true,
durationSec: true,
transcriptJson: true,
occurredAt: true,
},
orderBy: { occurredAt: "asc" },
},
events: {
select: { title: true, startsAt: true, endsAt: true, isArchived: true, note: true },
orderBy: { startsAt: "asc" },
},
},
take: 5000,
});
const contactIndex = [];
for (const c of contacts) {
const contactFile = path.join(contactsDir, `${c.id}.json`);
await writeJson(contactFile, {
id: c.id,
teamId: c.teamId,
name: c.name,
company: c.company ?? null,
country: c.country ?? null,
location: c.location ?? null,
avatarUrl: c.avatarUrl ?? null,
email: c.email ?? null,
phone: c.phone ?? null,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
});
const noteFile = path.join(notesDir, `${c.id}.md`);
await fs.writeFile(
noteFile,
(c.note?.content?.trim() ? c.note.content.trim() : "") + "\n",
"utf8",
);
const msgFile = path.join(messagesDir, `${c.id}.jsonl`);
const msgLines = c.messages.map((m) =>
jsonlLine({
kind: m.kind,
direction: m.direction,
channel: m.channel,
occurredAt: m.occurredAt,
content: m.content,
durationSec: m.durationSec ?? null,
transcript: m.transcriptJson ?? null,
}),
);
await fs.writeFile(msgFile, msgLines.join(""), "utf8");
const evFile = path.join(eventsDir, `${c.id}.jsonl`);
const evLines = c.events.map((e) =>
jsonlLine({
title: e.title,
startsAt: e.startsAt,
endsAt: e.endsAt,
isArchived: e.isArchived,
note: e.note ?? null,
}),
);
await fs.writeFile(evFile, evLines.join(""), "utf8");
const lastMessageAt = c.messages.at(-1)?.occurredAt ?? null;
const nextEventAt = c.events.find((e) => new Date(e.startsAt).getTime() >= Date.now())?.startsAt ?? null;
contactIndex.push({
id: c.id,
name: c.name,
company: c.company ?? null,
lastMessageAt,
nextEventAt,
updatedAt: c.updatedAt,
});
}
await writeJson(path.join(indexDir, "contacts.json"), contactIndex);
const meta: ExportMeta = { exportedAt: new Date().toISOString(), version: 1 };
await writeJson(path.join(tmp, "meta.json"), meta);
await ensureDir(path.dirname(root));
await fs.rm(root, { recursive: true, force: true });
await fs.rename(tmp, root);
}
export async function ensureDataset(input: { teamId: string; userId: string }) {
const root = datasetRoot(input);
try {
const metaPath = path.join(root, "meta.json");
await fs.access(metaPath);
return;
} catch {
// fallthrough
}
await exportDatasetFromPrismaFor(input);
}

View File

@@ -0,0 +1,6 @@
import path from "node:path";
export function datasetRoot(input: { teamId: string; userId: string }) {
// Keep it outside frontend so it can be easily ignored and shared.
return path.resolve(process.cwd(), "..", ".data", "crmfs", "teams", input.teamId, "users", input.userId);
}

View File

@@ -0,0 +1,9 @@
import { startTelegramSendWorker } from "../queues/telegramSend";
export default defineNitroPlugin(() => {
// Disabled by default. If you need background processing, wire it explicitly.
if (process.env.RUN_QUEUE_WORKER !== "1") return;
startTelegramSendWorker();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,212 @@
import { Queue, Worker, type JobsOptions, type ConnectionOptions } from "bullmq";
import { Prisma } from "@prisma/client";
import { prisma } from "../utils/prisma";
export const OUTBOUND_DELIVERY_QUEUE_NAME = "omni-outbound";
export type OutboundDeliveryJob = {
omniMessageId: string;
endpoint: string;
method?: "POST" | "PUT" | "PATCH";
headers?: Record<string, string>;
payload: unknown;
channel?: string;
provider?: string;
};
function redisConnectionFromEnv(): ConnectionOptions {
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
const parsed = new URL(raw);
return {
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : 6379,
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
maxRetriesPerRequest: null,
};
}
function ensureHttpUrl(value: string) {
const raw = (value ?? "").trim();
if (!raw) throw new Error("endpoint is required");
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Unsupported endpoint protocol: ${parsed.protocol}`);
}
return parsed.toString();
}
function compactError(error: unknown) {
if (!error) return "unknown_error";
if (typeof error === "string") return error;
const anyErr = error as any;
return String(anyErr?.message ?? anyErr);
}
function extractProviderMessageId(body: unknown): string | null {
const obj = body as any;
if (!obj || typeof obj !== "object") return null;
const candidate =
obj?.message_id ??
obj?.messageId ??
obj?.id ??
obj?.result?.message_id ??
obj?.result?.id ??
null;
if (candidate == null) return null;
return String(candidate);
}
export function outboundDeliveryQueue() {
return new Queue<OutboundDeliveryJob, unknown, "deliver">(OUTBOUND_DELIVERY_QUEUE_NAME, {
connection: redisConnectionFromEnv(),
defaultJobOptions: {
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
}
export async function enqueueOutboundDelivery(input: OutboundDeliveryJob, opts?: JobsOptions) {
const endpoint = ensureHttpUrl(input.endpoint);
const q = outboundDeliveryQueue();
const payload = (input.payload ?? null) as Prisma.InputJsonValue;
// Keep source message in pending before actual send starts.
await prisma.omniMessage.update({
where: { id: input.omniMessageId },
data: {
status: "PENDING",
rawJson: {
queue: {
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
enqueuedAt: new Date().toISOString(),
},
deliveryRequest: {
endpoint,
method: input.method ?? "POST",
channel: input.channel ?? null,
provider: input.provider ?? null,
payload,
},
},
},
});
return q.add("deliver", { ...input, endpoint }, {
jobId: `omni:${input.omniMessageId}`,
attempts: 12,
backoff: { type: "exponential", delay: 1000 },
...opts,
});
}
export function startOutboundDeliveryWorker() {
return new Worker<OutboundDeliveryJob, unknown, "deliver">(
OUTBOUND_DELIVERY_QUEUE_NAME,
async (job) => {
const msg = await prisma.omniMessage.findUnique({
where: { id: job.data.omniMessageId },
include: { thread: true },
});
if (!msg) return;
// Idempotency: if already sent/delivered, do not resend.
if ((msg.status === "SENT" || msg.status === "DELIVERED" || msg.status === "READ") && msg.providerMessageId) {
return;
}
const endpoint = ensureHttpUrl(job.data.endpoint);
const method = job.data.method ?? "POST";
const headers: Record<string, string> = {
"content-type": "application/json",
...(job.data.headers ?? {}),
};
const requestPayload = (job.data.payload ?? null) as Prisma.InputJsonValue;
const requestStartedAt = new Date().toISOString();
try {
const response = await fetch(endpoint, {
method,
headers,
body: JSON.stringify(requestPayload ?? {}),
});
const text = await response.text();
const responseBody = (() => {
try {
return JSON.parse(text);
} catch {
return text;
}
})();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody)}`);
}
const providerMessageId = extractProviderMessageId(responseBody);
await prisma.omniMessage.update({
where: { id: msg.id },
data: {
status: "SENT",
providerMessageId,
rawJson: {
queue: {
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
completedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade + 1,
},
deliveryRequest: {
endpoint,
method,
channel: job.data.channel ?? null,
provider: job.data.provider ?? null,
startedAt: requestStartedAt,
payload: requestPayload,
},
deliveryResponse: {
status: response.status,
body: responseBody,
},
},
},
});
} catch (error) {
const isLastAttempt =
typeof job.opts.attempts === "number" && job.attemptsMade + 1 >= job.opts.attempts;
if (isLastAttempt) {
await prisma.omniMessage.update({
where: { id: msg.id },
data: {
status: "FAILED",
rawJson: {
queue: {
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
failedAt: new Date().toISOString(),
attemptsMade: job.attemptsMade + 1,
},
deliveryRequest: {
endpoint,
method,
channel: job.data.channel ?? null,
provider: job.data.provider ?? null,
startedAt: requestStartedAt,
payload: requestPayload,
},
deliveryError: {
message: compactError(error),
},
},
},
});
}
throw error;
}
},
{ connection: redisConnectionFromEnv() },
);
}

View File

@@ -0,0 +1,43 @@
import type { JobsOptions } from "bullmq";
import { prisma } from "../utils/prisma";
import { telegramApiBase, requireTelegramBotToken } from "../utils/telegram";
import { enqueueOutboundDelivery, startOutboundDeliveryWorker } from "./outboundDelivery";
type TelegramSendJob = {
omniMessageId: string;
};
export async function enqueueTelegramSend(input: TelegramSendJob, opts?: JobsOptions) {
const msg = await prisma.omniMessage.findUnique({
where: { id: input.omniMessageId },
include: { thread: true },
});
if (!msg) throw new Error(`omni message not found: ${input.omniMessageId}`);
if (msg.channel !== "TELEGRAM" || msg.direction !== "OUT") {
throw new Error(`Invalid omni message for telegram send: ${msg.id}`);
}
const token = requireTelegramBotToken();
const endpoint = `${telegramApiBase()}/bot${token}/sendMessage`;
const payload = {
chat_id: msg.thread.externalChatId,
text: msg.text,
...(msg.thread.businessConnectionId ? { business_connection_id: msg.thread.businessConnectionId } : {}),
};
return enqueueOutboundDelivery(
{
omniMessageId: msg.id,
endpoint,
method: "POST",
payload,
provider: "telegram_business",
channel: "TELEGRAM",
},
opts,
);
}
export function startTelegramSendWorker() {
return startOutboundDeliveryWorker();
}

View File

@@ -0,0 +1,35 @@
import { startOutboundDeliveryWorker } from "./outboundDelivery";
import { prisma } from "../utils/prisma";
import { getRedis } from "../utils/redis";
const worker = startOutboundDeliveryWorker();
console.log("[delivery-worker] started queue omni:outbound");
async function shutdown(signal: string) {
console.log(`[delivery-worker] shutting down by ${signal}`);
try {
await worker.close();
} catch {
// ignore shutdown errors
}
try {
const redis = getRedis();
await redis.quit();
} 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");
});

View File

@@ -0,0 +1,101 @@
import type { H3Event } from "h3";
import { getCookie, setCookie, deleteCookie, getHeader } from "h3";
import { prisma } from "./prisma";
import { hashPassword } from "./password";
export type AuthContext = {
teamId: string;
userId: string;
conversationId: string;
};
const COOKIE_USER = "cf_user";
const COOKIE_TEAM = "cf_team";
const COOKIE_CONV = "cf_conv";
function cookieOpts() {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure: process.env.NODE_ENV === "production",
};
}
export function clearAuthSession(event: H3Event) {
deleteCookie(event, COOKIE_USER, { path: "/" });
deleteCookie(event, COOKIE_TEAM, { path: "/" });
deleteCookie(event, COOKIE_CONV, { path: "/" });
}
export function setSession(event: H3Event, ctx: AuthContext) {
setCookie(event, COOKIE_USER, ctx.userId, cookieOpts());
setCookie(event, COOKIE_TEAM, ctx.teamId, cookieOpts());
setCookie(event, COOKIE_CONV, ctx.conversationId, cookieOpts());
}
export async function getAuthContext(event: H3Event): Promise<AuthContext> {
const cookieUser = getCookie(event, COOKIE_USER)?.trim();
const cookieTeam = getCookie(event, COOKIE_TEAM)?.trim();
const cookieConv = getCookie(event, COOKIE_CONV)?.trim();
// Temporary compatibility: allow passing via headers for debugging/dev tools.
const hdrTeam = getHeader(event, "x-team-id")?.trim();
const hdrUser = getHeader(event, "x-user-id")?.trim();
const hdrConv = getHeader(event, "x-conversation-id")?.trim();
const hasAnySession = Boolean(cookieUser || cookieTeam || cookieConv || hdrTeam || hdrUser || hdrConv);
if (!hasAnySession) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const userId = cookieUser || hdrUser;
const teamId = cookieTeam || hdrTeam;
const conversationId = cookieConv || hdrConv;
if (!userId || !teamId || !conversationId) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
const team = await prisma.team.findUnique({ where: { id: teamId } });
if (!user || !team) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
const conv = await prisma.chatConversation.findFirst({
where: { id: conversationId, teamId: team.id, createdByUserId: user.id },
});
if (!conv) {
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
}
return { teamId: team.id, userId: user.id, conversationId: conv.id };
}
export async function ensureDemoAuth() {
const demoPasswordHash = hashPassword("DemoPass123!");
const user = await prisma.user.upsert({
where: { id: "demo-user" },
update: { phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
create: { id: "demo-user", phone: "+15550000099", email: "demo@clientsflow.local", passwordHash: demoPasswordHash, name: "Demo User" },
});
const team = await prisma.team.upsert({
where: { id: "demo-team" },
update: { name: "Demo Team" },
create: { id: "demo-team", name: "Demo Team" },
});
await prisma.teamMember.upsert({
where: { teamId_userId: { teamId: team.id, userId: user.id } },
update: {},
create: { teamId: team.id, userId: user.id, role: "OWNER" },
});
const conv = await prisma.chatConversation.upsert({
where: { id: `pilot-${team.id}` },
update: {},
create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Pilot" },
});
return { teamId: team.id, userId: user.id, conversationId: conv.id };
}

View File

@@ -0,0 +1,426 @@
import { randomUUID } from "node:crypto";
import type { PrismaClient } from "@prisma/client";
type CalendarSnapshotRow = {
id: string;
teamId: string;
contactId: string | null;
title: string;
startsAt: string;
endsAt: string | null;
note: string | null;
isArchived: boolean;
archiveNote: string | null;
archivedAt: string | null;
};
type ContactNoteSnapshotRow = {
contactId: string;
contactName: string;
content: string;
};
type MessageSnapshotRow = {
id: string;
contactId: string;
contactName: string;
kind: string;
direction: string;
channel: string;
content: string;
durationSec: number | null;
occurredAt: string;
};
type DealSnapshotRow = {
id: string;
title: string;
contactName: string;
stage: string;
nextStep: string | null;
summary: string | null;
};
export type SnapshotState = {
calendarById: Map<string, CalendarSnapshotRow>;
noteByContactId: Map<string, ContactNoteSnapshotRow>;
messageById: Map<string, MessageSnapshotRow>;
dealById: Map<string, DealSnapshotRow>;
};
export type ChangeItem = {
entity: "calendar_event" | "contact_note" | "message" | "deal";
action: "created" | "updated" | "deleted";
title: string;
before: string;
after: string;
};
type UndoOp =
| { kind: "delete_calendar_event"; id: string }
| { kind: "restore_calendar_event"; data: CalendarSnapshotRow }
| { kind: "delete_contact_message"; id: string }
| { kind: "restore_contact_message"; data: MessageSnapshotRow }
| { kind: "restore_contact_note"; contactId: string; content: string | null }
| { kind: "restore_deal"; id: string; stage: string; nextStep: string | null; summary: string | null };
export type ChangeSet = {
id: string;
status: "pending" | "confirmed" | "rolled_back";
createdAt: string;
summary: string;
items: ChangeItem[];
undo: UndoOp[];
};
function fmt(val: string | null | undefined) {
return (val ?? "").trim();
}
function toCalendarText(row: CalendarSnapshotRow) {
const when = new Date(row.startsAt).toLocaleString("ru-RU");
return `${row.title} · ${when}${row.note ? ` · ${row.note}` : ""}`;
}
function toMessageText(row: MessageSnapshotRow) {
const when = new Date(row.occurredAt).toLocaleString("ru-RU");
return `${row.contactName} · ${row.channel} · ${row.kind.toLowerCase()} · ${when} · ${row.content}`;
}
function toDealText(row: DealSnapshotRow) {
return `${row.title} (${row.contactName}) · ${row.stage}${row.nextStep ? ` · next: ${row.nextStep}` : ""}`;
}
export async function captureSnapshot(prisma: PrismaClient, teamId: string): Promise<SnapshotState> {
const [calendar, notes, messages, deals] = await Promise.all([
prisma.calendarEvent.findMany({
where: { teamId },
select: {
id: true,
teamId: true,
contactId: true,
title: true,
startsAt: true,
endsAt: true,
note: true,
isArchived: true,
archiveNote: true,
archivedAt: true,
},
take: 4000,
}),
prisma.contactNote.findMany({
where: { contact: { teamId } },
select: { contactId: true, content: true, contact: { select: { name: true } } },
take: 4000,
}),
prisma.contactMessage.findMany({
where: { contact: { teamId } },
include: { contact: { select: { name: true } } },
orderBy: { createdAt: "asc" },
take: 6000,
}),
prisma.deal.findMany({
where: { teamId },
include: { contact: { select: { name: true } } },
take: 4000,
}),
]);
return {
calendarById: new Map(
calendar.map((row) => [
row.id,
{
id: row.id,
teamId: row.teamId,
contactId: row.contactId ?? null,
title: row.title,
startsAt: row.startsAt.toISOString(),
endsAt: row.endsAt?.toISOString() ?? null,
note: row.note ?? null,
isArchived: Boolean(row.isArchived),
archiveNote: row.archiveNote ?? null,
archivedAt: row.archivedAt?.toISOString() ?? null,
},
]),
),
noteByContactId: new Map(
notes.map((row) => [
row.contactId,
{
contactId: row.contactId,
contactName: row.contact.name,
content: row.content,
},
]),
),
messageById: new Map(
messages.map((row) => [
row.id,
{
id: row.id,
contactId: row.contactId,
contactName: row.contact.name,
kind: row.kind,
direction: row.direction,
channel: row.channel,
content: row.content,
durationSec: row.durationSec ?? null,
occurredAt: row.occurredAt.toISOString(),
},
]),
),
dealById: new Map(
deals.map((row) => [
row.id,
{
id: row.id,
title: row.title,
contactName: row.contact.name,
stage: row.stage,
nextStep: row.nextStep ?? null,
summary: row.summary ?? null,
},
]),
),
};
}
export function buildChangeSet(before: SnapshotState, after: SnapshotState): ChangeSet | null {
const items: ChangeItem[] = [];
const undo: UndoOp[] = [];
for (const [id, row] of after.calendarById) {
const prev = before.calendarById.get(id);
if (!prev) {
items.push({
entity: "calendar_event",
action: "created",
title: `Event created: ${row.title}`,
before: "",
after: toCalendarText(row),
});
undo.push({ kind: "delete_calendar_event", id });
continue;
}
if (
prev.title !== row.title ||
prev.startsAt !== row.startsAt ||
prev.endsAt !== row.endsAt ||
fmt(prev.note) !== fmt(row.note) ||
prev.isArchived !== row.isArchived ||
fmt(prev.archiveNote) !== fmt(row.archiveNote) ||
fmt(prev.archivedAt) !== fmt(row.archivedAt) ||
prev.contactId !== row.contactId
) {
items.push({
entity: "calendar_event",
action: "updated",
title: `Event updated: ${row.title}`,
before: toCalendarText(prev),
after: toCalendarText(row),
});
undo.push({ kind: "restore_calendar_event", data: prev });
}
}
for (const [id, row] of before.calendarById) {
if (after.calendarById.has(id)) continue;
items.push({
entity: "calendar_event",
action: "deleted",
title: `Event archived: ${row.title}`,
before: toCalendarText(row),
after: "",
});
undo.push({ kind: "restore_calendar_event", data: row });
}
for (const [contactId, row] of after.noteByContactId) {
const prev = before.noteByContactId.get(contactId);
if (!prev) {
items.push({
entity: "contact_note",
action: "created",
title: `Summary added: ${row.contactName}`,
before: "",
after: row.content,
});
undo.push({ kind: "restore_contact_note", contactId, content: null });
continue;
}
if (prev.content !== row.content) {
items.push({
entity: "contact_note",
action: "updated",
title: `Summary updated: ${row.contactName}`,
before: prev.content,
after: row.content,
});
undo.push({ kind: "restore_contact_note", contactId, content: prev.content });
}
}
for (const [contactId, row] of before.noteByContactId) {
if (after.noteByContactId.has(contactId)) continue;
items.push({
entity: "contact_note",
action: "deleted",
title: `Summary cleared: ${row.contactName}`,
before: row.content,
after: "",
});
undo.push({ kind: "restore_contact_note", contactId, content: row.content });
}
for (const [id, row] of after.messageById) {
if (before.messageById.has(id)) continue;
items.push({
entity: "message",
action: "created",
title: `Message created: ${row.contactName}`,
before: "",
after: toMessageText(row),
});
undo.push({ kind: "delete_contact_message", id });
}
for (const [id, row] of after.dealById) {
const prev = before.dealById.get(id);
if (!prev) continue;
if (prev.stage !== row.stage || fmt(prev.nextStep) !== fmt(row.nextStep) || fmt(prev.summary) !== fmt(row.summary)) {
items.push({
entity: "deal",
action: "updated",
title: `Deal updated: ${row.title}`,
before: toDealText(prev),
after: toDealText(row),
});
undo.push({
kind: "restore_deal",
id,
stage: prev.stage,
nextStep: prev.nextStep,
summary: prev.summary,
});
}
}
if (items.length === 0) return null;
const created = items.filter((x) => x.action === "created").length;
const updated = items.filter((x) => x.action === "updated").length;
const deleted = items.filter((x) => x.action === "deleted").length;
return {
id: randomUUID(),
status: "pending",
createdAt: new Date().toISOString(),
summary: `Created: ${created}, Updated: ${updated}, Archived: ${deleted}`,
items,
undo,
};
}
export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, changeSet: ChangeSet) {
const ops = [...changeSet.undo].reverse();
await prisma.$transaction(async (tx) => {
for (const op of ops) {
if (op.kind === "delete_calendar_event") {
await tx.calendarEvent.deleteMany({ where: { id: op.id, teamId } });
continue;
}
if (op.kind === "restore_calendar_event") {
const row = op.data;
await tx.calendarEvent.upsert({
where: { id: row.id },
update: {
teamId: row.teamId,
contactId: row.contactId,
title: row.title,
startsAt: new Date(row.startsAt),
endsAt: row.endsAt ? new Date(row.endsAt) : null,
note: row.note,
isArchived: row.isArchived,
archiveNote: row.archiveNote,
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
},
create: {
id: row.id,
teamId: row.teamId,
contactId: row.contactId,
title: row.title,
startsAt: new Date(row.startsAt),
endsAt: row.endsAt ? new Date(row.endsAt) : null,
note: row.note,
isArchived: row.isArchived,
archiveNote: row.archiveNote,
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
},
});
continue;
}
if (op.kind === "delete_contact_message") {
await tx.contactMessage.deleteMany({ where: { id: op.id } });
continue;
}
if (op.kind === "restore_contact_message") {
const row = op.data;
await tx.contactMessage.upsert({
where: { id: row.id },
update: {
contactId: row.contactId,
kind: row.kind as any,
direction: row.direction as any,
channel: row.channel as any,
content: row.content,
durationSec: row.durationSec,
occurredAt: new Date(row.occurredAt),
},
create: {
id: row.id,
contactId: row.contactId,
kind: row.kind as any,
direction: row.direction as any,
channel: row.channel as any,
content: row.content,
durationSec: row.durationSec,
occurredAt: new Date(row.occurredAt),
},
});
continue;
}
if (op.kind === "restore_contact_note") {
const contact = await tx.contact.findFirst({ where: { id: op.contactId, teamId }, select: { id: true } });
if (!contact) continue;
if (op.content === null) {
await tx.contactNote.deleteMany({ where: { contactId: op.contactId } });
} else {
await tx.contactNote.upsert({
where: { contactId: op.contactId },
update: { content: op.content },
create: { contactId: op.contactId, content: op.content },
});
}
continue;
}
if (op.kind === "restore_deal") {
await tx.deal.updateMany({
where: { id: op.id, teamId },
data: {
stage: op.stage,
nextStep: op.nextStep,
summary: op.summary,
},
});
}
}
});
}

View File

@@ -0,0 +1,29 @@
import { Langfuse } from "langfuse";
let client: Langfuse | null = null;
function isTruthy(value: string | undefined) {
const v = (value ?? "").trim().toLowerCase();
return v === "1" || v === "true" || v === "yes" || v === "on";
}
export function isLangfuseEnabled() {
const enabledRaw = process.env.LANGFUSE_ENABLED;
if (enabledRaw && !isTruthy(enabledRaw)) return false;
return Boolean((process.env.LANGFUSE_PUBLIC_KEY ?? "").trim() && (process.env.LANGFUSE_SECRET_KEY ?? "").trim());
}
export function getLangfuseClient() {
if (!isLangfuseEnabled()) return null;
if (client) return client;
client = new Langfuse({
publicKey: (process.env.LANGFUSE_PUBLIC_KEY ?? "").trim(),
secretKey: (process.env.LANGFUSE_SECRET_KEY ?? "").trim(),
baseUrl: (process.env.LANGFUSE_BASE_URL ?? "http://langfuse-web:3000").trim(),
enabled: true,
});
return client;
}

View File

@@ -0,0 +1,29 @@
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
const SCRYPT_KEY_LENGTH = 64;
export function normalizePhone(raw: string) {
const trimmed = (raw ?? "").trim();
if (!trimmed) return "";
const hasPlus = trimmed.startsWith("+");
const digits = trimmed.replace(/\D/g, "");
if (!digits) return "";
return `${hasPlus ? "+" : ""}${digits}`;
}
export function hashPassword(password: string) {
const salt = randomBytes(16).toString("base64url");
const digest = scryptSync(password, salt, SCRYPT_KEY_LENGTH).toString("base64url");
return `scrypt$${salt}$${digest}`;
}
export function verifyPassword(password: string, encodedHash: string) {
const [algo, salt, digest] = (encodedHash ?? "").split("$");
if (algo !== "scrypt" || !salt || !digest) return false;
const actual = scryptSync(password, salt, SCRYPT_KEY_LENGTH);
const expected = Buffer.from(digest, "base64url");
if (actual.byteLength !== expected.byteLength) return false;
return timingSafeEqual(actual, expected);
}

View File

@@ -0,0 +1,17 @@
import { PrismaClient } from "@prisma/client";
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma =
globalThis.__prisma ??
new PrismaClient({
log: ["error", "warn"],
});
if (process.env.NODE_ENV !== "production") {
globalThis.__prisma = prisma;
}

View File

@@ -0,0 +1,22 @@
import Redis from "ioredis";
declare global {
// eslint-disable-next-line no-var
var __redis: Redis | undefined;
}
export function getRedis() {
if (globalThis.__redis) return globalThis.__redis;
const url = process.env.REDIS_URL || "redis://localhost:6379";
const client = new Redis(url, {
maxRetriesPerRequest: null, // recommended for BullMQ
});
if (process.env.NODE_ENV !== "production") {
globalThis.__redis = client;
}
return client;
}

View File

@@ -0,0 +1,29 @@
export type TelegramUpdate = Record<string, any>;
export function telegramApiBase() {
return process.env.TELEGRAM_API_BASE || "https://api.telegram.org";
}
export function requireTelegramBotToken() {
const token = process.env.TELEGRAM_BOT_TOKEN;
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is required");
return token;
}
export async function telegramBotApi<T>(method: string, body: unknown): Promise<T> {
const token = requireTelegramBotToken();
const res = await fetch(`${telegramApiBase()}/bot${token}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = (await res.json().catch(() => null)) as any;
if (!res.ok || !json?.ok) {
const desc = json?.description || `HTTP ${res.status}`;
throw new Error(`Telegram API ${method} failed: ${desc}`);
}
return json.result as T;
}

View File

@@ -0,0 +1,53 @@
type WhisperTranscribeInput = {
samples: Float32Array;
sampleRate: number;
language?: string;
};
let whisperPipelinePromise: Promise<any> | null = null;
let transformersPromise: Promise<any> | null = null;
function getWhisperModelId() {
return (process.env.CF_WHISPER_MODEL ?? "Xenova/whisper-small").trim() || "Xenova/whisper-small";
}
function getWhisperLanguage() {
const value = (process.env.CF_WHISPER_LANGUAGE ?? "ru").trim();
return value || "ru";
}
async function getWhisperPipeline() {
if (!transformersPromise) {
transformersPromise = import("@xenova/transformers");
}
const { env, pipeline } = await transformersPromise;
if (!whisperPipelinePromise) {
env.allowRemoteModels = true;
env.allowLocalModels = true;
env.cacheDir = "/app/.data/transformers";
const modelId = getWhisperModelId();
whisperPipelinePromise = pipeline("automatic-speech-recognition", modelId);
}
return whisperPipelinePromise;
}
export async function transcribeWithWhisper(input: WhisperTranscribeInput) {
const transcriber = (await getWhisperPipeline()) as any;
const result = await transcriber(
input.samples,
{
sampling_rate: input.sampleRate,
language: (input.language ?? getWhisperLanguage()) || "ru",
task: "transcribe",
chunk_length_s: 20,
stride_length_s: 5,
return_timestamps: false,
},
);
const text = String((result as any)?.text ?? "").trim();
return text;
}

4
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./.nuxt/tsconfig.json"
}