diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e2ed8a7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "instructions"] + path = instructions + url = git@gitea.dsrptlab.com:dsrptlab/instructions.git diff --git a/Actor/README.md b/Actor/README.md deleted file mode 100644 index d748a92..0000000 --- a/Actor/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Actor - -Placeholder for background actors/workers (async jobs, scheduled tasks, integrations). - diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 6567d05..32da408 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -19,6 +19,7 @@ "@tiptap/vue-3": "^2.27.2", "bullmq": "^5.58.2", "daisyui": "^5.5.18", + "graphql": "^16.12.0", "ioredis": "^5.7.0", "nuxt": "^4.3.1", "tailwindcss": "^4.1.18", @@ -3171,6 +3172,20 @@ "giget": "dist/cli.mjs" } }, + "node_modules/@prisma/config/node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/@prisma/config/node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -7035,6 +7050,15 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gzip-size": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-7.0.0.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index c875413..b573145 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -16,18 +16,19 @@ "typecheck": "nuxt typecheck" }, "dependencies": { - "@prisma/client": "^6.16.1", "@langchain/core": "^0.3.77", "@langchain/langgraph": "^0.2.74", "@langchain/openai": "^0.6.9", + "@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", - "daisyui": "^5.5.18", "bullmq": "^5.58.2", + "daisyui": "^5.5.18", + "graphql": "^16.12.0", "ioredis": "^5.7.0", "nuxt": "^4.3.1", "tailwindcss": "^4.1.18", diff --git a/Frontend/prisma/schema.prisma b/Frontend/prisma/schema.prisma index 272ff33..6e79dc7 100644 --- a/Frontend/prisma/schema.prisma +++ b/Frontend/prisma/schema.prisma @@ -64,16 +64,16 @@ model Team { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - members TeamMember[] - contacts Contact[] + members TeamMember[] + contacts Contact[] calendarEvents CalendarEvent[] - deals Deal[] - conversations ChatConversation[] - chatMessages ChatMessage[] + deals Deal[] + conversations ChatConversation[] + chatMessages ChatMessage[] - omniThreads OmniThread[] - omniMessages OmniMessage[] - omniIdentities OmniContactIdentity[] + omniThreads OmniThread[] + omniMessages OmniMessage[] + omniIdentities OmniContactIdentity[] telegramBusinessConnections TelegramBusinessConnection[] feedCards FeedCard[] @@ -82,11 +82,13 @@ model Team { } model User { - id String @id @default(cuid()) - email String @unique - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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") @@ -120,16 +122,16 @@ model Contact { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - note ContactNote? - messages ContactMessage[] - events CalendarEvent[] - deals Deal[] + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + note ContactNote? + messages ContactMessage[] + events CalendarEvent[] + deals Deal[] feedCards FeedCard[] - pins ContactPin[] + pins ContactPin[] - omniThreads OmniThread[] - omniMessages OmniMessage[] + omniThreads OmniThread[] + omniMessages OmniMessage[] omniIdentities OmniContactIdentity[] @@index([teamId, updatedAt]) @@ -146,16 +148,16 @@ model ContactNote { } model ContactMessage { - id String @id @default(cuid()) - contactId String - kind ContactMessageKind @default(MESSAGE) - direction MessageDirection - channel MessageChannel - content String - durationSec Int? + id String @id @default(cuid()) + contactId String + kind ContactMessageKind @default(MESSAGE) + direction MessageDirection + channel MessageChannel + content String + durationSec Int? transcriptJson Json? - occurredAt DateTime @default(now()) - createdAt DateTime @default(now()) + occurredAt DateTime @default(now()) + createdAt DateTime @default(now()) contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade) @@ -190,8 +192,8 @@ model OmniThread { 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) + 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]) @@ -241,7 +243,7 @@ model TelegramBusinessConnection { } model CalendarEvent { - id String @id @default(cuid()) + id String @id @default(cuid()) teamId String contactId String? title String @@ -249,8 +251,8 @@ model CalendarEvent { endsAt DateTime? note String? status String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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) @@ -287,8 +289,8 @@ model ChatConversation { 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) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade) messages ChatMessage[] @@index([teamId, updatedAt]) @@ -296,14 +298,14 @@ model ChatConversation { } model ChatMessage { - id String @id @default(cuid()) - teamId String + id String @id @default(cuid()) + teamId String conversationId String - authorUserId String? - role ChatRole - text String - planJson Json? - createdAt DateTime @default(now()) + 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) @@ -349,7 +351,7 @@ model ContactPin { } model WorkspaceDocument { - id String @id @default(cuid()) + id String @id @default(cuid()) teamId String title String type WorkspaceDocumentType @@ -357,8 +359,8 @@ model WorkspaceDocument { scope String summary String body String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) diff --git a/Frontend/prisma/seed.mjs b/Frontend/prisma/seed.mjs index 928a210..d94544f 100644 --- a/Frontend/prisma/seed.mjs +++ b/Frontend/prisma/seed.mjs @@ -1,6 +1,7 @@ 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"); @@ -17,7 +18,6 @@ function loadEnvFromDotEnv() { val = val.slice(1, -1); } if (!key) continue; - // Force DATABASE_URL from local .env for scripts, to avoid inheriting a stale shell env. if (key === "DATABASE_URL") { process.env[key] = val; continue; @@ -30,6 +30,18 @@ loadEnvFromDotEnv(); const prisma = new PrismaClient(); +const LOGIN_PHONE = "+15550000001"; +const LOGIN_PASSWORD = "ConnectFlow#2026"; +const LOGIN_NAME = "Connect Owner"; + +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(); d.setDate(d.getDate() + days); @@ -37,310 +49,346 @@ function atOffset(days, hour, minute) { return d; } +function plusMinutes(date, minutes) { + const d = new Date(date); + d.setMinutes(d.getMinutes() + minutes); + return d; +} + +function buildContacts(teamId, count) { + const firstNames = [ + "Alex", "Mia", "Leo", "Sofia", "Noah", "Emma", "Liam", "Ava", "Ethan", "Luna", + "Mason", "Chloe", "Logan", "Mila", "Lucas", "Nora", "Elijah", "Zoey", "James", "Aria", + "Daniel", "Nina", "Henry", "Layla", "Oliver", "Iris", "Oscar", "Diana", "Max", "Eva", + ]; + const lastNames = [ + "Carter", "Meyer", "Ali", "Petrov", "Rivera", "Ivanova", "Fisher", "Khan", "Wright", "Cole", + "Silva", "Morris", "King", "Anderson", "Lopez", "Walker", "Young", "Scott", "Green", "Parker", + ]; + const companies = [ + "Northline", "Connecta", "Volta", "Blueport", "Skyline", "PrimeGrid", "Helio", "CoreLabs", "NovaTrade", "Astera", + ]; + const locations = [ + { country: "USA", city: "New York" }, + { country: "USA", city: "Austin" }, + { country: "Germany", city: "Berlin" }, + { country: "UAE", city: "Dubai" }, + { country: "Spain", city: "Barcelona" }, + { country: "Armenia", city: "Yerevan" }, + { country: "UK", city: "London" }, + { country: "France", city: "Paris" }, + { country: "Singapore", city: "Singapore" }, + { country: "Canada", city: "Toronto" }, + ]; + + const rows = []; + for (let i = 0; i < count; i += 1) { + const first = firstNames[i % firstNames.length]; + const last = lastNames[Math.floor(i / firstNames.length) % lastNames.length]; + const company = `${companies[i % companies.length]} ${String.fromCharCode(65 + (i % 26))}`; + const loc = locations[i % locations.length]; + const female = i % 2 === 0; + const picIdx = (i % 70) + 1; + rows.push({ + teamId, + name: `${first} ${last} ${i + 1}`, + company, + country: loc.country, + location: loc.city, + avatarUrl: `https://randomuser.me/api/portraits/${female ? "women" : "men"}/${picIdx}.jpg`, + email: `${first.toLowerCase()}.${last.toLowerCase()}${i + 1}@${company.toLowerCase().replace(/\s+/g, "")}.example`, + phone: `+1 555 01${String(i).padStart(4, "0")}`, + }); + } + return rows; +} + async function main() { - // Create default team/user for dev. + const passwordHash = hashPassword(LOGIN_PASSWORD); + const user = await prisma.user.upsert({ where: { id: "demo-user" }, - update: { email: "demo@clientsflow.local", name: "Demo User" }, - create: { id: "demo-user", email: "demo@clientsflow.local", name: "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: "Demo Team" }, - create: { id: "demo-team", name: "Demo Team" }, + update: { name: "Connect Workspace" }, + create: { id: "demo-team", name: "Connect Workspace" }, }); await prisma.teamMember.upsert({ where: { teamId_userId: { teamId: team.id, userId: user.id } }, - update: {}, + update: { role: "OWNER" }, create: { teamId: team.id, userId: user.id, role: "OWNER" }, }); - // Idempotent-ish seed per team: if we already have contacts in this team, do nothing. - const existing = await prisma.contact.count({ where: { teamId: team.id } }); - if (existing > 0) return; + const conversation = await prisma.chatConversation.upsert({ + where: { id: `pilot-${team.id}` }, + update: { title: "Pilot" }, + create: { id: `pilot-${team.id}`, teamId: team.id, createdByUserId: user.id, title: "Pilot" }, + }); + + 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: [ - { - teamId: team.id, - name: "Anna Meyer", - company: "Nordline GmbH", - country: "Germany", - location: "Berlin", - avatarUrl: "https://randomuser.me/api/portraits/women/44.jpg", - email: "anna@nordline.example", - phone: "+49 30 123 45 67", - }, - { - teamId: team.id, - name: "Murat Ali", - company: "Connect FZCO", - country: "UAE", - location: "Dubai", - avatarUrl: "https://randomuser.me/api/portraits/men/32.jpg", - email: "murat@connect.example", - phone: "+971 50 123 4567", - }, - { - teamId: team.id, - name: "Ilya Petroff", - company: "Volta Tech", - country: "Armenia", - location: "Yerevan", - avatarUrl: "https://randomuser.me/api/portraits/men/18.jpg", - email: "ilya@volta.example", - phone: "+374 10 123 456", - }, - { - teamId: team.id, - name: "Carlos Rivera", - company: "BluePort", - country: "Spain", - location: "Barcelona", - avatarUrl: "https://randomuser.me/api/portraits/men/65.jpg", - email: "carlos@blueport.example", - phone: "+34 600 123 456", - }, - { - teamId: team.id, - name: "Daria Ivanova", - company: "Skyline Trade", - country: "Kazakhstan", - location: "Almaty", - avatarUrl: "https://randomuser.me/api/portraits/women/22.jpg", - email: "daria@skyline.example", - phone: "+7 777 123 45 67", - }, - ], + data: buildContacts(team.id, 220), + select: { id: true, name: true, company: true }, }); - const byName = Object.fromEntries(contacts.map((c) => [c.name, c])); - await prisma.contactNote.createMany({ - data: [ - { - contactId: byName["Anna Meyer"].id, - content: - "Decision owner. Prefers short, concrete updates with a clear next step.\nRisk: decision date slips if we don't lock timeline.", - }, - { - contactId: byName["Murat Ali"].id, - content: - "High activity. Needs legal path clarity and an explicit owner on their side.\nBest move: lock legal owner + target signature date.", - }, - { - contactId: byName["Ilya Petroff"].id, - content: - "Early-stage. Wants structured onboarding before commercial details.\nBest move: onboarding plan + 2 time slots.", - }, - ], + data: contacts.map((c, idx) => ({ + contactId: c.id, + content: + `Summary for ${c.name}. Main objective: move the account to a predictable weekly rhythm. ` + + `Current context: ${c.company ?? "Account"} is active in at least one channel. ` + + `Recommended path: keep messages short, lock a concrete next step, and update the note after each interaction. ` + + `Priority signal ${idx % 5 === 0 ? "high" : "normal"}.`, + })), }); - await prisma.contactMessage.createMany({ - data: [ - { - contactId: byName["Anna Meyer"].id, - kind: "MESSAGE", - direction: "IN", - channel: "TELEGRAM", - content: "Thanks for the demo. Can you send 2 pricing options?", - occurredAt: atOffset(0, 10, 20), - }, - { - contactId: byName["Anna Meyer"].id, - kind: "MESSAGE", - direction: "OUT", - channel: "EMAIL", - content: "Sure. Option A/B attached. Can you confirm decision date for this cycle?", - occurredAt: atOffset(0, 10, 35), - }, - { - contactId: byName["Murat Ali"].id, - kind: "MESSAGE", - direction: "IN", - channel: "WHATSAPP", - content: "Let's do a quick call. Need to clarify legal owner.", - occurredAt: atOffset(-1, 18, 10), - }, - { - contactId: byName["Ilya Petroff"].id, - kind: "MESSAGE", - direction: "OUT", - channel: "EMAIL", - content: "Draft: onboarding plan + two slots for tomorrow.", - occurredAt: atOffset(-1, 11, 12), - }, - { - contactId: byName["Murat Ali"].id, + 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: `Hi, this is ${contact.name}. Can we sync on timeline this week?`, + occurredAt: base, + }); + + contactMessages.push({ + contactId: contact.id, + kind: "MESSAGE", + direction: "OUT", + channel: channels[(i + 1) % channels.length], + content: `Sure. I suggest two slots and a clear agenda.`, + occurredAt: plusMinutes(base, 22), + }); + + contactMessages.push({ + contactId: contact.id, + kind: "MESSAGE", + direction: i % 3 === 0 ? "OUT" : "IN", + channel: channels[(i + 2) % channels.length], + content: `Status update: legal owner and decision date are the two blockers now.`, + occurredAt: plusMinutes(base, 65), + }); + + if (i % 4 === 0) { + contactMessages.push({ + contactId: contact.id, kind: "CALL", direction: "OUT", channel: "PHONE", - content: "Call started from CRM", - durationSec: 180, - occurredAt: atOffset(-1, 18, 30), - }, - ], - }); + content: "Voice call from CRM", + durationSec: 180 + ((i * 23) % 420), + transcriptJson: [ + `${contact.name}: We need a clear owner for approval.`, + `You: Agreed, let's lock this today and set the next checkpoint.`, + ], + occurredAt: plusMinutes(base, 110), + }); + } + } + await prisma.contactMessage.createMany({ data: contactMessages }); await prisma.calendarEvent.createMany({ - data: [ - { - teamId: team.id, - contactId: byName["Anna Meyer"].id, - title: "Follow-up: Anna", - startsAt: atOffset(0, 12, 30), - endsAt: atOffset(0, 13, 0), - note: "Lock decision date + confirm option A/B.", - status: "planned", - }, - { - teamId: team.id, - contactId: byName["Murat Ali"].id, - title: "Call: Murat (legal owner)", - startsAt: atOffset(0, 15, 0), - endsAt: atOffset(0, 15, 20), - note: "Confirm legal owner + target signature date.", - status: "planned", - }, - ], + data: contacts.flatMap((c, idx) => { + const firstStart = atOffset((idx % 21) - 3, 10 + (idx % 6), (idx * 5) % 60); + const secondStart = atOffset((idx % 28) + 1, 14 + (idx % 4), (idx * 3) % 60); + return [ + { + teamId: team.id, + contactId: c.id, + title: `Follow-up with ${c.name}`, + startsAt: firstStart, + endsAt: plusMinutes(firstStart, 30), + note: "Confirm owner, timeline, and next concrete action.", + status: "planned", + }, + { + teamId: team.id, + contactId: c.id, + title: `Checkpoint: ${c.company ?? c.name}`, + startsAt: secondStart, + endsAt: plusMinutes(secondStart, 45), + note: "Review progress and unblock pending decisions.", + status: idx % 6 === 0 ? "done" : "planned", + }, + ]; + }), }); + const stages = ["Qualification", "Proposal", "Negotiation", "Contract"]; await prisma.deal.createMany({ - data: [ - { + data: contacts + .filter((_, idx) => idx % 5 !== 0) + .map((c, idx) => ({ teamId: team.id, - contactId: byName["Anna Meyer"].id, - title: "Nordline onboarding", - stage: "Proposal", - amount: 25000, - nextStep: "Lock decision date", - summary: "After demo: pricing options sent; waiting for decision date.", - }, - { - teamId: team.id, - contactId: byName["Murat Ali"].id, - title: "Connect legal alignment", - stage: "Qualification", - amount: 18000, - nextStep: "Confirm legal owner", - summary: "High engagement; needs legal owner on their side.", - }, - ], - }); - - await prisma.chatConversation.upsert({ - where: { id: `pilot-${team.id}` }, - update: {}, - create: { - id: `pilot-${team.id}`, - teamId: team.id, - createdByUserId: user.id, - title: "Pilot", - }, - }); - - await prisma.chatMessage.createMany({ - data: [ - { - teamId: team.id, - conversationId: `pilot-${team.id}`, - authorUserId: null, - role: "ASSISTANT", - text: - "Я смотрю календарь, контакты и переписки как один поток. Спроси: \"чем заняться сегодня\" или \"покажи 10 лучших клиентов\".", - planJson: { steps: ["Скажи задачу", "Я соберу срез данных", "Предложу план и действия"], tools: ["read index/contacts.json"] }, - }, - ], + contactId: c.id, + title: `${c.company ?? "Account"} expansion`, + stage: stages[idx % stages.length], + amount: 8000 + (idx % 17) * 1500, + nextStep: "Lock next sync and owner on client side.", + summary: "Deal is active. Focus on speed and explicit decision checkpoints.", + })), }); await prisma.contactPin.createMany({ - data: [ - { teamId: team.id, contactId: byName["Anna Meyer"].id, text: "First lock the decision date, then send the final offer." }, - { teamId: team.id, contactId: byName["Anna Meyer"].id, text: "A short follow-up is needed no later than 30 minutes after the demo." }, - { teamId: team.id, contactId: byName["Murat Ali"].id, text: "In every update, confirm the legal owner on the client side." }, - { teamId: team.id, contactId: byName["Ilya Petroff"].id, text: "Work through a structured onboarding plan, not pricing first." }, - ], + data: contacts.map((c, idx) => ({ + teamId: team.id, + contactId: c.id, + text: + idx % 3 === 0 + ? "Pinned: calendar event is near, prepare a concise follow-up note." + : "Pinned: keep one explicit ask in each message.", + })), + }); + + 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: + `I analyzed the latest contact activity for ${c.name}. ` + + `There is enough momentum to push one concrete action now and reduce response latency.`, + proposalJson: { + title: idx % 2 === 0 ? "Add focused follow-up event" : "Draft a concise unblock message", + details: [ + `Contact: ${c.name}`, + idx % 2 === 0 ? "Timing: in the next 60 minutes" : "Timing: send in the primary active channel", + "Goal: lock owner and next exact date", + ], + key: proposalKeys[idx % proposalKeys.length], + }, + })), }); await prisma.workspaceDocument.createMany({ data: [ { teamId: team.id, - title: "Outbound cadence v1", + title: "Response time regulation", type: "Regulation", owner: "Revenue Ops", - scope: "All B2B accounts", - summary: "Unified sequence for first touch, follow-up, and qualification.", - body: - "## Goal\nMove a new contact to the first qualified call within 5 business days.\n\n## Base sequence\n- Day 0: first message in the primary channel.\n- Day 1: short follow-up with one clear ask.\n- Day 3: second follow-up + alternate channel.\n- Day 5: final ping and move to \"later\".\n\n## Rules\n- Always keep one explicit next step in each message.\n- Avoid long text walls.\n- After each reply, update context in the contact card.", - updatedAt: atOffset(-1, 10, 0), + scope: "All active deals", + summary: "Rules for first response and follow-up SLA across channels.", + body: "## SLA\n- First response in under 30 minutes for active threads.\n- Escalate if no owner is assigned.\n\n## Rule\nAlways end a message with one explicit next step.", + updatedAt: atOffset(-1, 11, 10), }, { teamId: team.id, - title: "Discovery call playbook", + title: "Discovery playbook", type: "Playbook", owner: "Sales Lead", - scope: "Discovery calls", - summary: "Call structure, mandatory questions, and outcome logging format.", - body: - "## Structure\n1. Context check (2 min)\n2. Current pain (8 min)\n3. Success criteria (6 min)\n4. Next step lock (4 min)\n\n## Mandatory outcomes\n- Confirmed business owner\n- Confirmed decision timeline\n- Confirmed next meeting date\n\n## Notes format\nAlways log: pain, impact, owner, ETA, risks.", - updatedAt: atOffset(-2, 12, 15), + scope: "Discovery and qualification", + summary: "Consistent structure for discovery calls and follow-up notes.", + body: "## Flow\n1. Pain\n2. Impact\n3. Owner\n4. Timeline\n5. Next step\n\n## Output\nStore concise summary in the contact card.", + updatedAt: atOffset(-2, 15, 0), }, { teamId: team.id, - title: "AI assistant operating policy", + title: "AI action policy", type: "Policy", owner: "Founders", - scope: "AI recommendations and automations", - summary: "What actions AI can suggest and what requires explicit approval.", - body: - "## Allowed without approval\n- Draft message suggestions\n- Calendar proposal suggestions\n- Conversation summaries\n\n## Requires explicit approval\n- Sending a message to an external contact\n- Creating a calendar event\n- Changing deal stage\n\n## Logging\nEvery AI action must leave a short trace in the feed.", - updatedAt: atOffset(-3, 9, 40), + scope: "AI recommendations", + summary: "What can be auto-drafted and what always needs explicit approval.", + body: "## Allowed\n- Draft suggestions\n- Summaries\n\n## Requires approval\n- Outbound send\n- Event creation\n- Deal stage change", + updatedAt: atOffset(-3, 9, 30), }, { teamId: team.id, - title: "Post-call follow-up template", + title: "Post-call template", type: "Template", owner: "Enablement", - scope: "Any completed client call", - summary: "Template for short post-call follow-up with aligned actions.", - body: - "## Message template\nThanks for the call. Summary below:\n- What we aligned on\n- What remains open\n- Owner per action\n- Exact date for next sync\n\n## Quality bar\nThe client should understand the next step within 10 seconds.", - updatedAt: atOffset(-4, 16, 20), + scope: "Any completed call", + summary: "Template for short post-call summary with owners and deadlines.", + body: "## Template\n- Aligned\n- Open items\n- Owner per action\n- Next date\n\nKeep it under 6 lines.", + updatedAt: atOffset(-4, 13, 45), + }, + { + teamId: team.id, + title: "Objection handling map", + type: "Playbook", + owner: "Commercial", + scope: "Late-stage objections", + summary: "Common objections and concise response strategy.", + body: "## Objections\n- Timing\n- Budget\n- Legal\n\n## Response\nAcknowledge, clarify owner, set a concrete checkpoint.", + updatedAt: atOffset(-5, 10, 0), + }, + { + teamId: team.id, + title: "Pipeline hygiene", + type: "Regulation", + owner: "Operations", + scope: "Pipeline updates", + summary: "Minimal mandatory updates after each interaction.", + body: "## Required\n- Last touch timestamp\n- Next step\n- Risk marker\n\nNo long forms, only concise text.", + updatedAt: atOffset(-6, 16, 15), }, ], }); - await prisma.feedCard.createMany({ + await prisma.chatMessage.createMany({ data: [ { teamId: team.id, - contactId: byName["Anna Meyer"].id, - happenedAt: atOffset(0, 9, 35), - text: - "I analyzed Anna Meyer's latest activity: after a demo, the decision window is usually open for 1-2 hours. I suggest scheduling a follow-up immediately to keep momentum.", - proposalJson: { - title: "Add event to calendar", - details: ["Contact: Anna Meyer", "Start: 30 minutes from now", "Duration: 30 minutes"], - key: "create_followup", + conversationId: conversation.id, + authorUserId: null, + role: "ASSISTANT", + text: "Workspace is ready. I connected contacts, communications, calendar, deals, and recommendations.", + planJson: { + steps: ["Open any contact", "Review chat + pinned tasks", "Confirm next event or message"], + tools: ["contacts", "communications", "calendar", "feed"], }, }, { teamId: team.id, - contactId: byName["Murat Ali"].id, - happenedAt: atOffset(0, 10, 8), - text: - "I found that Murat Ali gave 3 quick replies in a row over the last hour. I suggest moving to a short call now while engagement is high.", - proposalJson: { - title: "Start a call and open chat", - details: ["Contact: Murat Ali", "Channel: Phone", "After action: open the communication thread for this contact"], - key: "call", - }, + conversationId: conversation.id, + authorUserId: null, + role: "ASSISTANT", + text: "Dataset loaded with 220 contacts and linked timeline activity.", + planJson: { steps: ["Filter by country/company", "Open active threads", "Apply one recommendation"], tools: ["search", "pins"] }, }, ], }); + + 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() diff --git a/Frontend/scripts/compose-dev.sh b/Frontend/scripts/compose-dev.sh index 0dffa87..0a10f7a 100755 --- a/Frontend/scripts/compose-dev.sh +++ b/Frontend/scripts/compose-dev.sh @@ -3,8 +3,15 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Prevent path leakage between host Nuxt build cache and Docker runtime. +# Host-generated .nuxt can contain absolute /Users/... imports that break in /app. +rm -rf .nuxt .output + # Install deps (container starts from a clean image). -npm ci +# Fallback to npm install when lockfile was produced by a newer npm major. +if ! npm ci; then + npm install +fi # DB path used by DATABASE_URL="file:../../.data/clientsflow-dev.db" from /app/Frontend DB_FILE="/app/.data/clientsflow-dev.db" @@ -20,4 +27,3 @@ fi node prisma/seed.mjs exec npm run dev -- --host 0.0.0.0 --port 3000 - diff --git a/Frontend/server/api/auth/demo.post.ts b/Frontend/server/api/auth/demo.post.ts deleted file mode 100644 index a8dc961..0000000 --- a/Frontend/server/api/auth/demo.post.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ensureDemoAuth, setSession } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const demo = await ensureDemoAuth(); - setSession(event, demo); - return { ok: true }; -}); - diff --git a/Frontend/server/api/auth/login.post.ts b/Frontend/server/api/auth/login.post.ts deleted file mode 100644 index e4264ce..0000000 --- a/Frontend/server/api/auth/login.post.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { readBody } from "h3"; -import { prisma } from "../../utils/prisma"; -import { setSession } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const body = await readBody<{ email?: string; name?: string; teamName?: string }>(event); - const email = (body?.email ?? "").trim().toLowerCase(); - const name = (body?.name ?? "").trim(); - const teamName = (body?.teamName ?? "").trim() || "My Team"; - - if (!email || !email.includes("@")) { - throw createError({ statusCode: 400, statusMessage: "valid email is required" }); - } - if (!name) { - throw createError({ statusCode: 400, statusMessage: "name is required" }); - } - - const user = await prisma.user.upsert({ - where: { email }, - update: { name }, - create: { email, name }, - }); - - // For MVP: 1 user -> 1 team (created if missing) - const team = await prisma.team.create({ data: { name: teamName } }); - await prisma.teamMember.create({ data: { teamId: team.id, userId: user.id, role: "OWNER" } }); - - const conversation = await prisma.chatConversation.create({ - data: { teamId: team.id, createdByUserId: user.id, title: "Pilot" }, - }); - - setSession(event, { teamId: team.id, userId: user.id, conversationId: conversation.id }); - return { ok: true }; -}); - diff --git a/Frontend/server/api/auth/logout.post.ts b/Frontend/server/api/auth/logout.post.ts deleted file mode 100644 index 47b6863..0000000 --- a/Frontend/server/api/auth/logout.post.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { clearAuthSession } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - clearAuthSession(event); - return { ok: true }; -}); diff --git a/Frontend/server/api/auth/me.get.ts b/Frontend/server/api/auth/me.get.ts deleted file mode 100644 index 2d89e81..0000000 --- a/Frontend/server/api/auth/me.get.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { getAuthContext } from "../../utils/auth"; -import { prisma } from "../../utils/prisma"; - -export default defineEventHandler(async (event) => { - try { - const auth = await getAuthContext(event); - const [user, team, conv] = await Promise.all([ - prisma.user.findUnique({ where: { id: auth.userId } }), - prisma.team.findUnique({ where: { id: auth.teamId } }), - prisma.chatConversation.findUnique({ where: { id: auth.conversationId } }), - ]); - if (!user || !team || !conv) throw new Error("unauth"); - return { user: { id: user.id, email: user.email, name: user.name }, team: { id: team.id, name: team.name }, conversation: { id: conv.id, title: conv.title } }; - } catch { - throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); - } -}); diff --git a/Frontend/server/api/calendar.get.ts b/Frontend/server/api/calendar.get.ts deleted file mode 100644 index e652390..0000000 --- a/Frontend/server/api/calendar.get.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const query = getQuery(event) as any; - - const from = query.from ? new Date(String(query.from)) : new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); - const to = query.to ? new Date(String(query.to)) : new Date(Date.now() + 1000 * 60 * 60 * 24 * 60); - - const items = await prisma.calendarEvent.findMany({ - where: { teamId: auth.teamId, startsAt: { gte: from, lte: to } }, - include: { contact: { select: { name: true } } }, - orderBy: { startsAt: "asc" }, - take: 500, - }); - - return { - items: items.map((e) => ({ - id: e.id, - title: e.title, - start: e.startsAt.toISOString(), - end: (e.endsAt ?? e.startsAt).toISOString(), - contact: e.contact?.name ?? "", - note: e.note ?? "", - })), - }; -}); - diff --git a/Frontend/server/api/calendar.post.ts b/Frontend/server/api/calendar.post.ts deleted file mode 100644 index 54ad2cc..0000000 --- a/Frontend/server/api/calendar.post.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { readBody } from "h3"; -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const body = await readBody<{ - title?: string; - start?: string; - end?: string; - contact?: string; - note?: string; - status?: string; - }>(event); - - const title = (body?.title ?? "").trim(); - const start = body?.start ? new Date(body.start) : null; - const end = body?.end ? new Date(body.end) : null; - if (!title) throw createError({ statusCode: 400, statusMessage: "title is required" }); - if (!start || Number.isNaN(start.getTime())) throw createError({ statusCode: 400, statusMessage: "start is invalid" }); - - const contactName = (body?.contact ?? "").trim(); - const contact = contactName - ? await prisma.contact.findFirst({ where: { teamId: auth.teamId, name: contactName }, select: { id: true, name: true } }) - : null; - - const created = await prisma.calendarEvent.create({ - data: { - teamId: auth.teamId, - contactId: contact?.id ?? null, - title, - startsAt: start, - endsAt: end && !Number.isNaN(end.getTime()) ? end : null, - note: (body?.note ?? "").trim() || null, - status: (body?.status ?? "").trim() || null, - }, - include: { contact: { select: { name: true } } }, - }); - - return { - item: { - id: created.id, - title: created.title, - start: created.startsAt.toISOString(), - end: (created.endsAt ?? created.startsAt).toISOString(), - contact: created.contact?.name ?? "", - note: created.note ?? "", - }, - }; -}); - diff --git a/Frontend/server/api/chat.get.ts b/Frontend/server/api/chat.get.ts deleted file mode 100644 index 9a309ec..0000000 --- a/Frontend/server/api/chat.get.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const items = await prisma.chatMessage.findMany({ - where: { teamId: auth.teamId, conversationId: auth.conversationId }, - orderBy: { createdAt: "asc" }, - take: 200, - }); - - return { - items: items.map((m) => ({ - id: m.id, - role: m.role === "USER" ? "user" : m.role === "ASSISTANT" ? "assistant" : "system", - text: m.text, - plan: (m.planJson as any)?.steps ?? null, - tools: (m.planJson as any)?.tools ?? null, - createdAt: m.createdAt, - })), - }; -}); diff --git a/Frontend/server/api/chat.post.ts b/Frontend/server/api/chat.post.ts deleted file mode 100644 index 2a26771..0000000 --- a/Frontend/server/api/chat.post.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { readBody } from "h3"; -import { persistChatMessage, runCrmAgentFor } from "../agent/crmAgent"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const body = await readBody<{ text?: string }>(event); - const text = (body?.text ?? "").trim(); - if (!text) { - throw createError({ statusCode: 400, statusMessage: "text is required" }); - } - - const auth = await getAuthContext(event); - await persistChatMessage({ - teamId: auth.teamId, - conversationId: auth.conversationId, - authorUserId: auth.userId, - role: "USER", - text, - }); - - const reply = await runCrmAgentFor({ teamId: auth.teamId, userId: auth.userId, userText: text }); - await persistChatMessage({ - teamId: auth.teamId, - conversationId: auth.conversationId, - authorUserId: null, - role: "ASSISTANT", - text: reply.text, - plan: reply.plan, - tools: reply.tools, - }); - - return { ok: true }; -}); diff --git a/Frontend/server/api/chat/log.post.ts b/Frontend/server/api/chat/log.post.ts deleted file mode 100644 index c0427d9..0000000 --- a/Frontend/server/api/chat/log.post.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { readBody } from "h3"; -import { persistChatMessage } from "../../agent/crmAgent"; -import { getAuthContext } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const body = await readBody<{ text?: string }>(event); - const text = (body?.text ?? "").trim(); - if (!text) { - throw createError({ statusCode: 400, statusMessage: "text is required" }); - } - - const auth = await getAuthContext(event); - await persistChatMessage({ - teamId: auth.teamId, - conversationId: auth.conversationId, - authorUserId: null, - role: "ASSISTANT", - text, - plan: [], - tools: [], - }); - - return { ok: true }; -}); - diff --git a/Frontend/server/api/communications.get.ts b/Frontend/server/api/communications.get.ts deleted file mode 100644 index 6caad02..0000000 --- a/Frontend/server/api/communications.get.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - - const items = await prisma.contactMessage.findMany({ - where: { contact: { teamId: auth.teamId } }, - orderBy: { occurredAt: "asc" }, - take: 2000, - include: { - contact: { select: { id: true, name: true } }, - }, - }); - - return { - items: items.map((m) => ({ - id: m.id, - at: m.occurredAt.toISOString(), - contactId: m.contactId, - contact: m.contact.name, - channel: - m.channel === "TELEGRAM" - ? "Telegram" - : m.channel === "WHATSAPP" - ? "WhatsApp" - : m.channel === "INSTAGRAM" - ? "Instagram" - : m.channel === "EMAIL" - ? "Email" - : "Phone", - kind: m.kind === "CALL" ? "call" : "message", - direction: m.direction === "IN" ? "in" : "out", - text: m.content, - duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : undefined, - transcript: Array.isArray(m.transcriptJson) ? (m.transcriptJson as any) : undefined, - })), - }; -}); - diff --git a/Frontend/server/api/communications.post.ts b/Frontend/server/api/communications.post.ts deleted file mode 100644 index 6c9d36f..0000000 --- a/Frontend/server/api/communications.post.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { readBody } from "h3"; -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -function toDbChannel(channel: string) { - const c = channel.toLowerCase(); - if (c === "telegram") return "TELEGRAM"; - if (c === "whatsapp") return "WHATSAPP"; - if (c === "instagram") return "INSTAGRAM"; - if (c === "email") return "EMAIL"; - return "PHONE"; -} - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const body = await readBody<{ - contact?: string; - channel?: string; - kind?: "message" | "call"; - direction?: "in" | "out"; - text?: string; - at?: string; - durationSec?: number; - transcript?: string[]; - }>(event); - - const contactName = (body?.contact ?? "").trim(); - if (!contactName) throw createError({ statusCode: 400, statusMessage: "contact is required" }); - - const contact = await prisma.contact.findFirst({ - where: { teamId: auth.teamId, name: contactName }, - select: { id: true, name: true }, - }); - if (!contact) throw createError({ statusCode: 404, statusMessage: "contact not found" }); - - const occurredAt = body?.at ? new Date(body.at) : new Date(); - if (Number.isNaN(occurredAt.getTime())) throw createError({ statusCode: 400, statusMessage: "at is invalid" }); - - const created = await prisma.contactMessage.create({ - data: { - contactId: contact.id, - kind: body?.kind === "call" ? "CALL" : "MESSAGE", - direction: body?.direction === "in" ? "IN" : "OUT", - channel: toDbChannel(body?.channel ?? "Phone") as any, - content: (body?.text ?? "").trim(), - durationSec: typeof body?.durationSec === "number" ? body.durationSec : null, - transcriptJson: Array.isArray(body?.transcript) ? body.transcript : undefined, - occurredAt, - }, - }); - - return { ok: true, id: created.id }; -}); diff --git a/Frontend/server/api/contacts.get.ts b/Frontend/server/api/contacts.get.ts deleted file mode 100644 index 3edd5f5..0000000 --- a/Frontend/server/api/contacts.get.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const items = await prisma.contact.findMany({ - where: { teamId: auth.teamId }, - include: { - note: { select: { content: true, updatedAt: true } }, - messages: { select: { occurredAt: true }, orderBy: { occurredAt: "desc" }, take: 1 }, - }, - orderBy: { updatedAt: "desc" }, - take: 500, - }); - - return { - items: items.map((c) => ({ - id: c.id, - name: c.name, - avatar: c.avatarUrl ?? "", - company: c.company ?? "", - country: c.country ?? "", - location: c.location ?? "", - channels: [], // derived client-side from comm list for now - lastContactAt: c.messages[0]?.occurredAt?.toISOString?.() ?? c.updatedAt.toISOString(), - description: c.note?.content ?? "", - })), - }; -}); - diff --git a/Frontend/server/api/contacts/[id].get.ts b/Frontend/server/api/contacts/[id].get.ts deleted file mode 100644 index b7b689a..0000000 --- a/Frontend/server/api/contacts/[id].get.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { prisma } from "../../utils/prisma"; -import { getAuthContext } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const id = getRouterParam(event, "id"); - if (!id) throw createError({ statusCode: 400, statusMessage: "id is required" }); - - const contact = await prisma.contact.findFirst({ - where: { id, teamId: auth.teamId }, - include: { note: { select: { content: true } } }, - }); - if (!contact) throw createError({ statusCode: 404, statusMessage: "not found" }); - - return { - id: contact.id, - name: contact.name, - avatar: contact.avatarUrl ?? "", - company: contact.company ?? "", - country: contact.country ?? "", - location: contact.location ?? "", - email: contact.email ?? "", - phone: contact.phone ?? "", - description: contact.note?.content ?? "", - }; -}); - diff --git a/Frontend/server/api/contacts/[id]/note.put.ts b/Frontend/server/api/contacts/[id]/note.put.ts deleted file mode 100644 index 26ea613..0000000 --- a/Frontend/server/api/contacts/[id]/note.put.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readBody } from "h3"; -import { prisma } from "../../../utils/prisma"; -import { getAuthContext } from "../../../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const id = getRouterParam(event, "id"); - if (!id) throw createError({ statusCode: 400, statusMessage: "id is required" }); - const body = await readBody<{ content?: string }>(event); - const content = (body?.content ?? "").toString(); - - const contact = await prisma.contact.findFirst({ where: { id, teamId: auth.teamId } }); - if (!contact) throw createError({ statusCode: 404, statusMessage: "not found" }); - - await prisma.contactNote.upsert({ - where: { contactId: id }, - update: { content }, - create: { contactId: id, content }, - }); - - return { ok: true }; -}); - diff --git a/Frontend/server/api/dataset/export.post.ts b/Frontend/server/api/dataset/export.post.ts deleted file mode 100644 index 52c1cd7..0000000 --- a/Frontend/server/api/dataset/export.post.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { exportDatasetFromPrismaFor } from "../../dataset/exporter"; -import { getAuthContext } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - await exportDatasetFromPrismaFor({ teamId: auth.teamId, userId: auth.userId }); - return { ok: true }; -}); diff --git a/Frontend/server/api/deals.get.ts b/Frontend/server/api/deals.get.ts deleted file mode 100644 index 38ea439..0000000 --- a/Frontend/server/api/deals.get.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - - const items = await prisma.deal.findMany({ - where: { teamId: auth.teamId }, - include: { contact: { select: { name: true, company: true } } }, - orderBy: { updatedAt: "desc" }, - take: 500, - }); - - return { - items: items.map((d) => ({ - id: d.id, - contact: d.contact.name, - title: d.title, - company: d.contact.company ?? "", - stage: d.stage, - amount: d.amount ? String(d.amount) : "", - nextStep: d.nextStep ?? "", - summary: d.summary ?? "", - })), - }; -}); - diff --git a/Frontend/server/api/documents.get.ts b/Frontend/server/api/documents.get.ts deleted file mode 100644 index a5365e5..0000000 --- a/Frontend/server/api/documents.get.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - - const items = await prisma.workspaceDocument.findMany({ - where: { teamId: auth.teamId }, - orderBy: { updatedAt: "desc" }, - take: 200, - }); - - return { - items: items.map((d) => ({ - id: d.id, - title: d.title, - type: d.type, - owner: d.owner, - scope: d.scope, - updatedAt: d.updatedAt.toISOString(), - summary: d.summary, - body: d.body, - })), - }; -}); - diff --git a/Frontend/server/api/feed.get.ts b/Frontend/server/api/feed.get.ts deleted file mode 100644 index a2d1b78..0000000 --- a/Frontend/server/api/feed.get.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - - const items = await prisma.feedCard.findMany({ - where: { teamId: auth.teamId }, - include: { contact: { select: { name: true } } }, - orderBy: { happenedAt: "desc" }, - take: 200, - }); - - return { - items: items.map((c) => ({ - id: c.id, - at: c.happenedAt.toISOString(), - contact: c.contact?.name ?? "", - text: c.text, - proposal: c.proposalJson as any, - decision: - c.decision === "ACCEPTED" ? "accepted" : c.decision === "REJECTED" ? "rejected" : ("pending" as const), - decisionNote: c.decisionNote ?? undefined, - })), - }; -}); - diff --git a/Frontend/server/api/feed/[id].put.ts b/Frontend/server/api/feed/[id].put.ts deleted file mode 100644 index e8f7109..0000000 --- a/Frontend/server/api/feed/[id].put.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { readBody } from "h3"; -import { prisma } from "../../utils/prisma"; -import { getAuthContext } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const id = String(getRouterParam(event, "id") ?? ""); - if (!id) throw createError({ statusCode: 400, statusMessage: "id is required" }); - - const body = await readBody<{ decision?: "accepted" | "rejected" | "pending"; decisionNote?: string }>(event); - const decision = body?.decision; - if (!decision) throw createError({ statusCode: 400, statusMessage: "decision is required" }); - - const nextDecision = decision === "accepted" ? "ACCEPTED" : decision === "rejected" ? "REJECTED" : "PENDING"; - - const res = await prisma.feedCard.updateMany({ - where: { id, teamId: auth.teamId }, - data: { decision: nextDecision, decisionNote: body?.decisionNote ?? null }, - }); - if (res.count === 0) throw createError({ statusCode: 404, statusMessage: "feed card not found" }); - - return { ok: true, id }; -}); diff --git a/Frontend/server/api/pins.get.ts b/Frontend/server/api/pins.get.ts deleted file mode 100644 index 8b49d14..0000000 --- a/Frontend/server/api/pins.get.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { prisma } from "../utils/prisma"; -import { getAuthContext } from "../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - - const items = await prisma.contactPin.findMany({ - where: { teamId: auth.teamId }, - include: { contact: { select: { name: true } } }, - orderBy: { updatedAt: "desc" }, - take: 500, - }); - - return { - items: items.map((p) => ({ - id: p.id, - contact: p.contact.name, - text: p.text, - })), - }; -}); - diff --git a/Frontend/server/api/telegram/messages.get.ts b/Frontend/server/api/telegram/messages.get.ts deleted file mode 100644 index b6fca90..0000000 --- a/Frontend/server/api/telegram/messages.get.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { getQuery } from "h3"; -import { prisma } from "../../utils/prisma"; -import { getAuthContext } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const q = getQuery(event); - const threadId = typeof q.threadId === "string" ? q.threadId : ""; - if (!threadId) throw createError({ statusCode: 400, statusMessage: "threadId is required" }); - - const thread = await prisma.omniThread.findFirst({ - where: { id: threadId, teamId: auth.teamId, channel: "TELEGRAM" }, - }); - if (!thread) throw createError({ statusCode: 404, statusMessage: "thread not found" }); - - const items = await prisma.omniMessage.findMany({ - where: { teamId: auth.teamId, threadId: thread.id, channel: "TELEGRAM" }, - orderBy: { occurredAt: "asc" }, - take: 200, - }); - - return { - thread: { - id: thread.id, - contactId: thread.contactId, - externalChatId: thread.externalChatId, - businessConnectionId: thread.businessConnectionId, - title: thread.title, - updatedAt: thread.updatedAt, - }, - items: items.map((m) => ({ - id: m.id, - direction: m.direction, - status: m.status, - text: m.text, - providerMessageId: m.providerMessageId, - occurredAt: m.occurredAt, - })), - }; -}); - diff --git a/Frontend/server/api/telegram/send.post.ts b/Frontend/server/api/telegram/send.post.ts deleted file mode 100644 index ce77999..0000000 --- a/Frontend/server/api/telegram/send.post.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { readBody } from "h3"; -import { prisma } from "../../utils/prisma"; -import { getAuthContext } from "../../utils/auth"; -import { enqueueTelegramSend } from "../../queues/telegramSend"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - const body = await readBody<{ threadId?: string; text?: string }>(event); - - const threadId = (body?.threadId || "").trim(); - const text = (body?.text || "").trim(); - if (!threadId) throw createError({ statusCode: 400, statusMessage: "threadId is required" }); - if (!text) throw createError({ statusCode: 400, statusMessage: "text is required" }); - - const thread = await prisma.omniThread.findFirst({ - where: { id: threadId, teamId: auth.teamId, channel: "TELEGRAM" }, - }); - if (!thread) throw createError({ statusCode: 404, statusMessage: "thread not found" }); - - const msg = await prisma.omniMessage.create({ - data: { - teamId: auth.teamId, - contactId: thread.contactId, - threadId: thread.id, - direction: "OUT", - channel: "TELEGRAM", - status: "PENDING", - text, - occurredAt: new Date(), - }, - }); - - await enqueueTelegramSend({ omniMessageId: msg.id }); - return { ok: true, messageId: msg.id }; -}); - diff --git a/Frontend/server/api/telegram/threads.get.ts b/Frontend/server/api/telegram/threads.get.ts deleted file mode 100644 index 26c6a4e..0000000 --- a/Frontend/server/api/telegram/threads.get.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { prisma } from "../../utils/prisma"; -import { getAuthContext } from "../../utils/auth"; - -export default defineEventHandler(async (event) => { - const auth = await getAuthContext(event); - - const threads = await prisma.omniThread.findMany({ - where: { teamId: auth.teamId, channel: "TELEGRAM" }, - orderBy: { updatedAt: "desc" }, - take: 50, - include: { - contact: true, - messages: { orderBy: { occurredAt: "desc" }, take: 1 }, - }, - }); - - return { - items: threads.map((t) => ({ - id: t.id, - contact: { id: t.contact.id, name: t.contact.name }, - externalChatId: t.externalChatId, - businessConnectionId: t.businessConnectionId, - title: t.title, - updatedAt: t.updatedAt, - lastMessage: t.messages[0] - ? { - id: t.messages[0].id, - direction: t.messages[0].direction, - status: t.messages[0].status, - text: t.messages[0].text, - occurredAt: t.messages[0].occurredAt, - } - : null, - })), - }; -}); - diff --git a/Frontend/server/api/telegram/webhook.post.ts b/Frontend/server/api/telegram/webhook.post.ts deleted file mode 100644 index 4ed56ca..0000000 --- a/Frontend/server/api/telegram/webhook.post.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { readBody, getQuery, getHeader } from "h3"; -import { prisma } from "../../utils/prisma"; - -function teamIdFromWebhook(event: any) { - const q = getQuery(event); - const fromQuery = typeof q.teamId === "string" ? q.teamId : null; - return fromQuery || process.env.TELEGRAM_DEFAULT_TEAM_ID || "demo-team"; -} - -function assertSecret(event: any) { - const expected = process.env.TELEGRAM_WEBHOOK_SECRET; - if (!expected) return; - - const got = getHeader(event, "x-telegram-bot-api-secret-token"); - if (!got || got !== expected) { - throw createError({ statusCode: 401, statusMessage: "invalid telegram secret token" }); - } -} - -function displayNameFromTelegram(obj: any) { - const first = obj?.first_name || ""; - const last = obj?.last_name || ""; - const u = obj?.username ? `@${obj.username}` : ""; - const full = `${first} ${last}`.trim(); - return (full || u || "Telegram user").trim(); -} - -async function upsertBusinessConnection(teamId: string, bc: any) { - if (!bc?.id) return; - const businessConnectionId = String(bc.id); - - await prisma.telegramBusinessConnection.upsert({ - where: { teamId_businessConnectionId: { teamId, businessConnectionId } }, - update: { - isEnabled: typeof bc.is_enabled === "boolean" ? bc.is_enabled : undefined, - canReply: typeof bc.can_reply === "boolean" ? bc.can_reply : undefined, - rawJson: bc, - }, - create: { - teamId, - businessConnectionId, - isEnabled: typeof bc.is_enabled === "boolean" ? bc.is_enabled : null, - canReply: typeof bc.can_reply === "boolean" ? bc.can_reply : null, - rawJson: bc, - }, - }); -} - -async function ensureContactForTelegramChat(teamId: string, externalChatId: string, tgUser: any) { - const existing = await prisma.omniContactIdentity.findUnique({ - where: { teamId_channel_externalId: { teamId, channel: "TELEGRAM", externalId: externalChatId } }, - include: { contact: true }, - }); - if (existing) return existing.contact; - - const contact = await prisma.contact.create({ - data: { - teamId, - name: displayNameFromTelegram(tgUser), - }, - }); - - await prisma.omniContactIdentity.create({ - data: { - teamId, - contactId: contact.id, - channel: "TELEGRAM", - externalId: externalChatId, - }, - }); - - return contact; -} - -async function ensureThread(input: { - teamId: string; - contactId: string; - externalChatId: string; - businessConnectionId?: string | null; - title?: string | null; -}) { - return prisma.omniThread.upsert({ - where: { - teamId_channel_externalChatId_businessConnectionId: { - teamId: input.teamId, - channel: "TELEGRAM", - externalChatId: input.externalChatId, - businessConnectionId: input.businessConnectionId ?? null, - }, - }, - update: { - contactId: input.contactId, - title: input.title ?? undefined, - }, - create: { - teamId: input.teamId, - contactId: input.contactId, - channel: "TELEGRAM", - externalChatId: input.externalChatId, - businessConnectionId: input.businessConnectionId ?? null, - title: input.title ?? null, - }, - }); -} - -export default defineEventHandler(async (event) => { - assertSecret(event); - const teamId = teamIdFromWebhook(event); - - const update = (await readBody(event)) || {}; - - // business_connection updates (user connected/disconnected bot) - if (update.business_connection) { - await upsertBusinessConnection(teamId, update.business_connection); - return { ok: true }; - } - - const msg = update.business_message || update.edited_business_message; - if (!msg) return { ok: true }; - - const businessConnectionId = msg.business_connection_id ? String(msg.business_connection_id) : null; - const chatId = msg.chat?.id != null ? String(msg.chat.id) : null; - const providerMessageId = msg.message_id != null ? String(msg.message_id) : null; - - if (!chatId || !providerMessageId) return { ok: true }; - - const text = typeof msg.text === "string" ? msg.text : typeof msg.caption === "string" ? msg.caption : ""; - const occurredAt = msg.date ? new Date(Number(msg.date) * 1000) : new Date(); - - const contact = await ensureContactForTelegramChat(teamId, chatId, msg.from || msg.chat); - const thread = await ensureThread({ - teamId, - contactId: contact.id, - externalChatId: chatId, - businessConnectionId, - title: msg.chat?.title ? String(msg.chat.title) : null, - }); - - // Dedupe on (threadId, providerMessageId). If duplicate, ignore. - try { - await prisma.omniMessage.create({ - data: { - teamId, - contactId: contact.id, - threadId: thread.id, - direction: "IN", - channel: "TELEGRAM", - status: "DELIVERED", - text: text || "", - providerMessageId, - providerUpdateId: update.update_id != null ? String(update.update_id) : null, - rawJson: update, - occurredAt, - }, - }); - } catch (e: any) { - // Prisma unique constraint violation => duplicate delivery - if (e?.code !== "P2002") throw e; - } - - return { ok: true }; -}); - diff --git a/README.md b/README.md deleted file mode 100644 index 2e8ae3e..0000000 --- a/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# CRM Draft - -Minimal CRM draft focused on: -- AI chat panel (left side) -- Calendar + contacts core workflow (right side) -- Additional tabs: chats, calls, feed, deals placeholder - -## Project structure - -- `Frontend` - Nuxt 4 + Tailwind 4 + DaisyUI UI app -- `Actor` - placeholder for workers/automation -- `Testflow` / `Testfow` - placeholder for test scenarios - -## Run - -```bash -cd Frontend -npm install -npm run dev -``` - -## Run (Docker Compose) - -Prereqs: Docker Desktop (compose v2). - -```bash -docker compose up --build -``` - -Open: `http://localhost:3000/` - -Notes: -- DB is SQLite persisted in a named volume (`clientsflow_data`). -- For LangGraph agent: set `OPENAI_API_KEY` (otherwise it falls back to the rule-based agent). diff --git a/Testflow/README.md b/Testflow/README.md deleted file mode 100644 index 06d7c20..0000000 --- a/Testflow/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Testflow - -Placeholder for end-to-end and regression scenarios (Playwright/Cypress flows, fixtures, mocks). - diff --git a/Testfow/README.md b/Testfow/README.md deleted file mode 100644 index 06d7c20..0000000 --- a/Testfow/README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Testflow - -Placeholder for end-to-end and regression scenarios (Playwright/Cypress flows, fixtures, mocks). - diff --git a/instructions b/instructions new file mode 160000 index 0000000..19bbaf3 --- /dev/null +++ b/instructions @@ -0,0 +1 @@ +Subproject commit 19bbaf3e08a05156135f75e2cd449af3ebb5d66c