diff --git a/Frontend/.env.example b/Frontend/.env.example index 176581f..7d6ef52 100644 --- a/Frontend/.env.example +++ b/Frontend/.env.example @@ -1 +1,12 @@ DATABASE_URL="file:../../.data/clientsflow-dev.db" +REDIS_URL="redis://localhost:6379" + +# Agent (LangGraph + OpenAI) +OPENAI_API_KEY="" +OPENAI_MODEL="gpt-4o-mini" +# "langgraph" (default) or "rule" +CF_AGENT_MODE="langgraph" + +TELEGRAM_BOT_TOKEN="" +TELEGRAM_WEBHOOK_SECRET="" +TELEGRAM_DEFAULT_TEAM_ID="demo-team" diff --git a/Frontend/app.vue b/Frontend/app.vue index 100cf16..ec9a171 100644 --- a/Frontend/app.vue +++ b/Frontend/app.vue @@ -148,351 +148,19 @@ function endAfter(startIso: string, minutes: number) { return d.toISOString(); } -const feedCards = ref([ - { - id: "f1", - at: atOffset(0, 9, 35), - contact: "Anna Meyer", - 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.", - proposal: { - title: "Add event to calendar", - details: [ - "Contact: Anna Meyer", - "Start: 30 minutes from now", - "Duration: 30 minutes", - ], - key: "create_followup", - }, - decision: "pending", - }, - { - id: "f2", - at: atOffset(0, 10, 8), - contact: "Murat Ali", - 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.", - proposal: { - title: "Start a call and open chat", - details: [ - "Contact: Murat Ali", - "Channel: Phone", - "After action: open the communication thread for this contact", - ], - key: "call", - }, - decision: "pending", - }, -]); +const feedCards = ref([]); -const contacts = ref([ - { - id: "c1", - name: "Anna Meyer", - avatar: "https://randomuser.me/api/portraits/women/44.jpg", - company: "Nordline GmbH", - country: "Germany", - location: "Berlin", - channels: ["Telegram", "Phone", "Email"], - lastContactAt: atOffset(-0, 10, 20), - description: "Decision owner for procurement. Prefers short, concrete updates with clear deadlines.\n\nBest pattern: one clear question per message, then one explicit next step.\n\nRisk: if timeline question is delayed, decision date keeps slipping.", - }, - { - id: "c2", - name: "Murat Ali", - avatar: "https://randomuser.me/api/portraits/men/32.jpg", - company: "Connect FZCO", - country: "UAE", - location: "Dubai", - channels: ["WhatsApp", "Instagram", "Phone", "Email"], - lastContactAt: atOffset(-1, 18, 10), - description: "High activity in chat, fast response cycle.\n\nNeeds legal path clarity and explicit owner from his side.\n\nBest move: in next call, lock legal owner and target signature date.", - }, - { - id: "c3", - name: "Ilya Petroff", - avatar: "https://randomuser.me/api/portraits/men/18.jpg", - company: "Volta Tech", - country: "Armenia", - location: "Yerevan", - channels: ["Email", "Phone"], - lastContactAt: atOffset(-1, 11, 12), - description: "Early-stage lead. Interested, but wants structured onboarding before commercial details.\n\nBest move: send concise onboarding plan + 2 time slots for intro session.", - }, - { - id: "c4", - name: "Carlos Rivera", - avatar: "https://randomuser.me/api/portraits/men/65.jpg", - company: "BluePort", - country: "Spain", - location: "Barcelona", - channels: ["WhatsApp", "Email"], - lastContactAt: atOffset(-3, 14, 35), - description: "Pilot interest for two teams. Focus on rollout timeline and ownership per team.\n\nBest move: propose phased pilot with weekly checkpoints.", - }, - { - id: "c5", - name: "Daria Ivanova", - avatar: "https://randomuser.me/api/portraits/women/22.jpg", - company: "Skyline Trade", - country: "Kazakhstan", - location: "Almaty", - channels: ["Telegram", "Phone"], - lastContactAt: atOffset(-4, 16, 10), - description: "Commercial discussion is blocked by ROI framing.\n\nBest move: provide short ROI model with three measurable outcomes.", - }, - { - id: "c6", - name: "Ethan Moore", - avatar: "https://randomuser.me/api/portraits/men/76.jpg", - company: "NorthBridge", - country: "USA", - location: "Austin", - channels: ["Email", "Phone"], - lastContactAt: atOffset(-5, 12, 45), - description: "Prefers stable weekly cadence and structured updates.\n\nBest move: fixed weekly summary + one priority ask per cycle.", - }, -]); +const contacts = ref([]); -const calendarEvents = ref([ - { - id: "e1", - title: "Anna follow-up", - start: inMinutes(12), - end: endAfter(inMinutes(12), 30), - contact: "Anna Meyer", - note: "Confirm decision date and next owner.", - }, - { - id: "e2", - title: "Murat contract call", - start: atOffset(2, 16, 30), - end: endAfter(atOffset(2, 16, 30), 30), - contact: "Murat Ali", - note: "Lock legal owner and signing target.", - }, - { - id: "e3", - title: "Ilya discovery", - start: atOffset(3, 11, 0), - end: endAfter(atOffset(3, 11, 0), 30), - contact: "Ilya Petroff", - note: "Qualify onboarding readiness.", - }, - { - id: "e4", - title: "Internal sync", - start: atOffset(0, 9, 0), - end: endAfter(atOffset(0, 9, 0), 30), - contact: "Team", - note: "Align next actions across accounts.", - }, - { - id: "e5", - title: "BluePort intro", - start: atOffset(4, 13, 0), - end: endAfter(atOffset(4, 13, 0), 30), - contact: "Carlos Rivera", - note: "Check pilot scope and timeline.", - }, -]); +const calendarEvents = ref([]); -const commItems = ref([ - { - id: "m1", - at: atOffset(0, 9, 20), - contact: "Anna Meyer", - channel: "Telegram", - kind: "message", - direction: "in", - text: "Can you share final pricing today?", - }, - { - id: "m2", - at: atOffset(0, 9, 22), - contact: "Anna Meyer", - channel: "Telegram", - kind: "message", - direction: "out", - text: "Yes, sending after demo.", - }, - { - id: "m3", - at: atOffset(0, 10, 0), - contact: "Anna Meyer", - channel: "Phone", - kind: "call", - direction: "out", - text: "Quick sync call", - duration: "14m", - transcript: [ - "Anna: We can review pricing today, but I need one clear next step.", - "You: Agreed. I will send the final version right after this call.", - "Anna: Perfect. Add a follow-up tomorrow morning.", - ], - }, - { - id: "m4", - at: atOffset(-1, 15, 5), - contact: "Murat Ali", - channel: "WhatsApp", - kind: "message", - direction: "in", - text: "Need to shift call by one day.", - }, - { - id: "m5", - at: atOffset(-1, 15, 8), - contact: "Murat Ali", - channel: "WhatsApp", - kind: "message", - direction: "out", - text: "Works. Let us do 12:00 tomorrow.", - }, - { - id: "m6", - at: atOffset(-1, 18, 10), - contact: "Murat Ali", - channel: "Phone", - kind: "call", - direction: "in", - text: "Contract call", - duration: "9m", - transcript: [ - "Murat: We can move forward if legal owner is confirmed today.", - "You: Understood. I will lock owner and send the timeline.", - "Murat: Then we target signature this week.", - ], - }, - { - id: "m9", - at: atOffset(-1, 13, 40), - contact: "Murat Ali", - channel: "Instagram", - kind: "message", - direction: "in", - text: "Sent details in DM, can we align tomorrow?", - }, - { - id: "m7", - at: atOffset(-1, 11, 12), - contact: "Ilya Petroff", - channel: "Email", - kind: "message", - direction: "in", - text: "Interested in pilot, what are next steps?", - }, - { - id: "m8", - at: atOffset(-1, 12, 0), - contact: "Ilya Petroff", - channel: "Phone", - kind: "call", - direction: "out", - text: "Missed callback", - duration: "-", - transcript: [ - "No full transcript available. Call was not connected.", - ], - }, -]); +const commItems = ref([]); -const commPins = ref([ - { - id: "cp-1", - contact: "Anna Meyer", - text: "First lock the decision date, then send the final offer.", - }, - { - id: "cp-2", - contact: "Anna Meyer", - text: "A short follow-up is needed no later than 30 minutes after the demo.", - }, - { - id: "cp-3", - contact: "Murat Ali", - text: "In every update, confirm the legal owner on the client side.", - }, - { - id: "cp-4", - contact: "Ilya Petroff", - text: "Work through a structured onboarding plan, not pricing first.", - }, -]); +const commPins = ref([]); -const deals = ref([ - { - id: "d1", - contact: "Anna Meyer", - title: "Nordline pilot", - company: "Nordline GmbH", - stage: "Negotiation", - amount: "$28,000", - nextStep: "Send post-demo offer within 30 minutes", - summary: "Active thread, fast feedback, high close probability this week.", - }, - { - id: "d2", - contact: "Murat Ali", - title: "Connect annual", - company: "Connect FZCO", - stage: "Legal review", - amount: "$74,000", - nextStep: "Lock legal owner and expected sign date", - summary: "Strong engagement in chat and calls, pending legal alignment.", - }, - { - id: "d3", - contact: "Ilya Petroff", - title: "Volta onboarding", - company: "Volta Tech", - stage: "Discovery", - amount: "$12,000", - nextStep: "Offer two onboarding slots for tomorrow", - summary: "Early stage interest, needs concise onboarding plan.", - }, -]); +const deals = ref([]); -const documents = ref([ - { - id: "doc-1", - title: "Outbound cadence v1", - type: "Regulation", - owner: "Revenue Ops", - scope: "All B2B accounts", - updatedAt: atOffset(-1, 10, 0), - 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.", - }, - { - id: "doc-2", - title: "Discovery call playbook", - type: "Playbook", - owner: "Sales Lead", - scope: "Discovery calls", - updatedAt: atOffset(-2, 12, 15), - 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.", - }, - { - id: "doc-3", - title: "AI assistant operating policy", - type: "Policy", - owner: "Founders", - scope: "AI recommendations and automations", - updatedAt: atOffset(-3, 9, 40), - 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.", - }, - { - id: "doc-4", - title: "Post-call follow-up template", - type: "Template", - owner: "Enablement", - scope: "Any completed client call", - updatedAt: atOffset(-4, 16, 20), - 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.", - }, -]); +const documents = ref([]); type PilotMessage = { id: string; @@ -506,12 +174,83 @@ type PilotMessage = { const pilotMessages = ref([]); const pilotInput = ref(""); const pilotSending = ref(false); +const authMe = ref<{ user: { id: string; email: string; name: string }; team: { id: string; name: string } } | null>( + null, +); +const loginEmail = ref(""); +const loginName = ref(""); +const loginTeamName = ref("My Team"); +const loginError = ref(null); +const loginBusy = ref(false); async function loadPilotMessages() { const res = await $fetch<{ items: PilotMessage[] }>("/api/chat"); pilotMessages.value = res.items ?? []; } +async function loadMe() { + authMe.value = await $fetch("/api/auth/me"); +} + +async function login() { + loginError.value = null; + loginBusy.value = true; + try { + await $fetch("/api/auth/login", { + method: "POST", + body: { email: loginEmail.value, name: loginName.value, teamName: loginTeamName.value }, + }); + await loadMe(); + await Promise.all([loadPilotMessages(), refreshCrmData()]); + } catch (e: any) { + loginError.value = e?.data?.message || e?.message || "Login failed"; + } finally { + loginBusy.value = false; + } +} + +async function logout() { + await $fetch("/api/auth/logout", { method: "POST" }); + authMe.value = null; +} + +async function useDemo() { + await $fetch("/api/auth/demo", { method: "POST" }); + await loadMe(); + await Promise.all([loadPilotMessages(), refreshCrmData()]); +} + +async function refreshCrmData() { + const [contactsRes, commRes, calRes, dealsRes, feedRes, pinsRes, docsRes] = await Promise.all([ + $fetch<{ items: Contact[] }>("/api/contacts"), + $fetch<{ items: CommItem[] }>("/api/communications"), + $fetch<{ items: CalendarEvent[] }>("/api/calendar"), + $fetch<{ items: Deal[] }>("/api/deals"), + $fetch<{ items: FeedCard[] }>("/api/feed"), + $fetch<{ items: CommPin[] }>("/api/pins"), + $fetch<{ items: WorkspaceDocument[] }>("/api/documents"), + ]); + + contacts.value = contactsRes.items ?? []; + commItems.value = commRes.items ?? []; + calendarEvents.value = calRes.items ?? []; + deals.value = dealsRes.items ?? []; + feedCards.value = feedRes.items ?? []; + commPins.value = pinsRes.items ?? []; + documents.value = docsRes.items ?? []; + + // Derive channels per contact from communication items. + const byName = new Map>(); + for (const item of commItems.value) { + if (!byName.has(item.contact)) byName.set(item.contact, new Set()); + byName.get(item.contact)?.add(item.channel); + } + contacts.value = contacts.value.map((c) => ({ + ...c, + channels: Array.from(byName.get(c.name) ?? []), + })); +} + async function sendPilotMessage() { const text = pilotInput.value.trim(); if (!text || pilotSending.value) return; @@ -527,7 +266,9 @@ async function sendPilotMessage() { } onMounted(() => { - loadPilotMessages(); + loadMe() + .then(() => Promise.all([loadPilotMessages(), refreshCrmData()])) + .catch(() => {}); }); const calendarView = ref("month"); @@ -1179,7 +920,8 @@ function openMessageFromContact(channel: CommItem["channel"]) { selectedCommChannel.value = channel; } -function executeFeedAction(key: FeedCard["proposal"]["key"]) { +async function executeFeedAction(card: FeedCard) { + const key = card.proposal.key; if (key === "create_followup") { const start = new Date(); start.setMinutes(start.getMinutes() + 30); @@ -1187,54 +929,62 @@ function executeFeedAction(key: FeedCard["proposal"]["key"]) { const end = new Date(start); end.setMinutes(end.getMinutes() + 30); - calendarEvents.value.unshift({ - id: makeId("e"), - title: "Follow-up: Anna", - start: start.toISOString(), - end: end.toISOString(), - contact: "Anna Meyer", - note: "Created from feed action.", + const res = await $fetch<{ item: CalendarEvent }>("/api/calendar", { + method: "POST", + body: { + title: `Follow-up: ${card.contact.split(" ")[0] ?? "Contact"}`, + start: start.toISOString(), + end: end.toISOString(), + contact: card.contact, + note: "Created from feed action.", + status: "planned", + }, }); + calendarEvents.value = [res.item, ...calendarEvents.value]; selectedDateKey.value = dayKey(start); calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1); selectedTab.value = "communications"; peopleLeftMode.value = "calendar"; - return `Event created: Follow-up: Anna · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())}`; + return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`; } if (key === "open_comm") { - openCommunicationThread("Murat Ali"); - return "Opened Murat Ali communication thread."; + openCommunicationThread(card.contact); + return `Opened ${card.contact} communication thread.`; } if (key === "call") { - commItems.value.push({ - id: makeId("m"), - at: new Date().toISOString(), - contact: "Murat Ali", - channel: "Phone", - kind: "call", - direction: "out", - text: "Call started from feed", - duration: "00:00", + await $fetch("/api/communications", { + method: "POST", + body: { + contact: card.contact, + channel: "Phone", + kind: "call", + direction: "out", + text: "Call started from feed", + durationSec: 0, + }, }); - openCommunicationThread("Murat Ali"); - return "Call event created and Murat Ali chat opened."; + await refreshCrmData(); + openCommunicationThread(card.contact); + return `Call event created and ${card.contact} chat opened.`; } if (key === "draft_message") { - commItems.value.push({ - id: makeId("m"), - at: new Date().toISOString(), - contact: "Ilya Petroff", - channel: "Email", - kind: "message", - direction: "out", - text: "Draft: onboarding plan + two slots for tomorrow.", + await $fetch("/api/communications", { + method: "POST", + body: { + contact: card.contact, + channel: "Email", + kind: "message", + direction: "out", + text: "Draft: onboarding plan + two slots for tomorrow.", + }, }); - openCommunicationThread("Ilya Petroff"); - return "Draft message added to Ilya Petroff communications."; + await refreshCrmData(); + openCommunicationThread(card.contact); + return `Draft message added to ${card.contact} communications.`; } if (key === "run_summary") { @@ -1242,32 +992,38 @@ function executeFeedAction(key: FeedCard["proposal"]["key"]) { } if (key === "prepare_question") { - commItems.value.push({ - id: makeId("m"), - at: new Date().toISOString(), - contact: "Anna Meyer", - channel: "Telegram", - kind: "message", - direction: "out", - text: "Draft: can you confirm your decision date for this cycle?", + await $fetch("/api/communications", { + method: "POST", + body: { + contact: card.contact, + channel: "Telegram", + kind: "message", + direction: "out", + text: "Draft: can you confirm your decision date for this cycle?", + }, }); - openCommunicationThread("Anna Meyer"); - return "Question about decision date added to Anna Meyer chat."; + await refreshCrmData(); + openCommunicationThread(card.contact); + return `Question about decision date added to ${card.contact} chat.`; } return "Action completed."; } -function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") { +async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") { card.decision = decision; + if (decision === "rejected") { - card.decisionNote = "Rejected. Nothing created."; + const note = "Rejected. Nothing created."; + card.decisionNote = note; + await $fetch(`/api/feed/${card.id}`, { method: "PUT", body: { decision: "rejected", decisionNote: note } }); pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`); return; } - const result = executeFeedAction(card.proposal.key); + const result = await executeFeedAction(card); card.decisionNote = result; + await $fetch(`/api/feed/${card.id}`, { method: "PUT", body: { decision: "accepted", decisionNote: result } }); pushPilotNote(`[${card.contact}] ${result}`); } @@ -1278,56 +1034,84 @@ function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 6c6e3d2..6567d05 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -17,7 +17,9 @@ "@tiptap/extension-placeholder": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", "@tiptap/vue-3": "^2.27.2", + "bullmq": "^5.58.2", "daisyui": "^5.5.18", + "ioredis": "^5.7.0", "nuxt": "^4.3.1", "tailwindcss": "^4.1.18", "vue": "^3.5.27", @@ -1251,6 +1253,84 @@ "node": ">=18" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -5449,6 +5529,58 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.69.3", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.69.3.tgz", + "integrity": "sha512-P9uLsR7fDvejH/1m6uur6j7U9mqY6nNt+XvhlhStOUe7jdwbZoP/c2oWNtE+8ljOlubw4pRUKymtRqkyvloc4A==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.2", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -5833,6 +5965,18 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/croner": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz", @@ -7902,6 +8046,15 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-regexp": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz", @@ -8201,6 +8354,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/muggle-string": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", @@ -8349,6 +8533,12 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -8401,6 +8591,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-mock-http": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", @@ -10840,8 +11045,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-fest": { "version": "5.4.4", diff --git a/Frontend/package.json b/Frontend/package.json index 1958724..c875413 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -27,6 +27,8 @@ "@tiptap/starter-kit": "^2.27.2", "@tiptap/vue-3": "^2.27.2", "daisyui": "^5.5.18", + "bullmq": "^5.58.2", + "ioredis": "^5.7.0", "nuxt": "^4.3.1", "tailwindcss": "^4.1.18", "vue": "^3.5.27", diff --git a/Frontend/prisma/schema.prisma b/Frontend/prisma/schema.prisma index ec907ee..272ff33 100644 --- a/Frontend/prisma/schema.prisma +++ b/Frontend/prisma/schema.prisma @@ -26,12 +26,38 @@ enum MessageChannel { 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 @@ -41,8 +67,18 @@ model Team { 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 { @@ -76,6 +112,9 @@ model Contact { teamId String name String company String? + country String? + location String? + avatarUrl String? email String? phone String? createdAt DateTime @default(now()) @@ -85,6 +124,13 @@ model Contact { note ContactNote? messages ContactMessage[] events CalendarEvent[] + deals Deal[] + feedCards FeedCard[] + pins ContactPin[] + + omniThreads OmniThread[] + omniMessages OmniMessage[] + omniIdentities OmniContactIdentity[] @@index([teamId, updatedAt]) } @@ -102,9 +148,12 @@ model ContactNote { model ContactMessage { 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()) @@ -113,6 +162,84 @@ model ContactMessage { @@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 @@ -133,6 +260,25 @@ model CalendarEvent { @@index([teamId, startsAt]) } +model Deal { + id String @id @default(cuid()) + teamId String + contactId String + title String + stage String + amount Int? + nextStep String? + summary 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 ChatConversation { id String @id @default(cuid()) teamId String @@ -167,3 +313,54 @@ model ChatMessage { @@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]) +} diff --git a/Frontend/prisma/seed.mjs b/Frontend/prisma/seed.mjs index 6b41b73..928a210 100644 --- a/Frontend/prisma/seed.mjs +++ b/Frontend/prisma/seed.mjs @@ -67,6 +67,9 @@ async function main() { 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", }, @@ -74,6 +77,9 @@ async function main() { 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", }, @@ -81,6 +87,9 @@ async function main() { 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", }, @@ -88,6 +97,9 @@ async function main() { 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", }, @@ -95,6 +107,9 @@ async function main() { 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", }, @@ -127,6 +142,7 @@ async function main() { data: [ { contactId: byName["Anna Meyer"].id, + kind: "MESSAGE", direction: "IN", channel: "TELEGRAM", content: "Thanks for the demo. Can you send 2 pricing options?", @@ -134,6 +150,7 @@ async function main() { }, { 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?", @@ -141,6 +158,7 @@ async function main() { }, { contactId: byName["Murat Ali"].id, + kind: "MESSAGE", direction: "IN", channel: "WHATSAPP", content: "Let's do a quick call. Need to clarify legal owner.", @@ -148,11 +166,21 @@ async function main() { }, { 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, + kind: "CALL", + direction: "OUT", + channel: "PHONE", + content: "Call started from CRM", + durationSec: 180, + occurredAt: atOffset(-1, 18, 30), + }, ], }); @@ -179,6 +207,29 @@ async function main() { ], }); + await prisma.deal.createMany({ + data: [ + { + 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: {}, @@ -203,6 +254,93 @@ async function main() { }, ], }); + + 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." }, + ], + }); + + await prisma.workspaceDocument.createMany({ + data: [ + { + teamId: team.id, + title: "Outbound cadence v1", + 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), + }, + { + teamId: team.id, + title: "Discovery call 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), + }, + { + teamId: team.id, + title: "AI assistant operating 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), + }, + { + teamId: team.id, + title: "Post-call follow-up 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), + }, + ], + }); + + await prisma.feedCard.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", + }, + }, + { + 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", + }, + }, + ], + }); } main() diff --git a/Frontend/scripts/export-dataset.mjs b/Frontend/scripts/export-dataset.mjs index f484a36..3f9c95d 100644 --- a/Frontend/scripts/export-dataset.mjs +++ b/Frontend/scripts/export-dataset.mjs @@ -75,7 +75,15 @@ async function main() { include: { note: { select: { content: true, updatedAt: true } }, messages: { - select: { direction: true, channel: true, content: true, occurredAt: true }, + select: { + kind: true, + direction: true, + channel: true, + content: true, + durationSec: true, + transcriptJson: true, + occurredAt: true, + }, orderBy: { occurredAt: "asc" }, }, events: { @@ -93,6 +101,9 @@ async function main() { 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, @@ -110,10 +121,13 @@ async function main() { 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(""), diff --git a/Frontend/server/agent/crmAgent.ts b/Frontend/server/agent/crmAgent.ts index b76f57b..f576089 100644 --- a/Frontend/server/agent/crmAgent.ts +++ b/Frontend/server/agent/crmAgent.ts @@ -4,6 +4,7 @@ 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"; type ContactIndexRow = { id: string; @@ -74,6 +75,11 @@ export async function runCrmAgent(userText: string): Promise { export async function runCrmAgentFor( input: { teamId: string; userId: string; userText: string }, ): Promise { + const mode = (process.env.CF_AGENT_MODE ?? "langgraph").toLowerCase(); + if (mode !== "rule" && process.env.OPENAI_API_KEY) { + return runLangGraphCrmAgentFor(input); + } + await ensureDataset({ teamId: input.teamId, userId: input.userId }); const q = normalize(input.userText); const root = datasetRoot({ teamId: input.teamId, userId: input.userId }); diff --git a/Frontend/server/agent/langgraphCrmAgent.ts b/Frontend/server/agent/langgraphCrmAgent.ts new file mode 100644 index 0000000..83d6cb0 --- /dev/null +++ b/Frontend/server/agent/langgraphCrmAgent.ts @@ -0,0 +1,375 @@ +import type { AgentReply } from "./crmAgent"; +import { prisma } from "../utils/prisma"; +import { ensureDataset } from "../dataset/exporter"; +import { createReactAgent } from "@langchain/langgraph/prebuilt"; +import { ChatOpenAI } from "@langchain/openai"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; + +function iso(d: Date) { + return d.toISOString(); +} + +async function buildCrmSnapshot(input: { teamId: string }) { + const now = new Date(); + const in7 = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const [contacts, upcoming, deals] = await Promise.all([ + prisma.contact.findMany({ + where: { teamId: input.teamId }, + orderBy: { updatedAt: "desc" }, + take: 25, + include: { + messages: { select: { occurredAt: true, channel: true, direction: true }, orderBy: { occurredAt: "desc" }, take: 1 }, + deals: { select: { stage: true, amount: true, updatedAt: true }, orderBy: { updatedAt: "desc" }, take: 1 }, + }, + }), + prisma.calendarEvent.findMany({ + where: { teamId: input.teamId, startsAt: { gte: now, lte: in7 } }, + orderBy: { startsAt: "asc" }, + take: 20, + include: { contact: { select: { name: true } } }, + }), + prisma.deal.findMany({ + where: { teamId: input.teamId }, + orderBy: { updatedAt: "desc" }, + take: 20, + include: { contact: { select: { name: true, company: true } } }, + }), + ]); + + const byStage = new Map(); + for (const d of deals) byStage.set(d.stage, (byStage.get(d.stage) ?? 0) + 1); + + const lines: string[] = []; + lines.push(`Snapshot time: ${iso(now)}`); + lines.push(`Contacts: ${await prisma.contact.count({ where: { teamId: input.teamId } })}`); + lines.push(`Deals: ${await prisma.deal.count({ where: { teamId: input.teamId } })}`); + lines.push(`Upcoming events (7d): ${upcoming.length}`); + lines.push(""); + + if (upcoming.length) { + lines.push("Upcoming events:"); + for (const e of upcoming) { + lines.push(`- ${e.startsAt.toISOString()} · ${e.title} · ${e.contact?.name ?? "No contact"}`); + } + lines.push(""); + } + + if (byStage.size) { + lines.push("Deals by stage:"); + for (const [stage, n] of [...byStage.entries()].sort((a, b) => b[1] - a[1])) { + lines.push(`- ${stage}: ${n}`); + } + lines.push(""); + } + + if (contacts.length) { + lines.push("Recently updated contacts:"); + for (const c of contacts.slice(0, 12)) { + const last = c.messages[0]?.occurredAt ? c.messages[0].occurredAt.toISOString() : c.updatedAt.toISOString(); + const deal = c.deals[0] ? `${c.deals[0].stage}${c.deals[0].amount ? ` $${c.deals[0].amount}` : ""}` : "no deal"; + lines.push(`- ${c.name}${c.company ? ` (${c.company})` : ""} · last touch ${last} · ${deal}`); + } + } + + return lines.join("\n"); +} + +export async function runLangGraphCrmAgentFor(input: { + teamId: string; + userId: string; + userText: string; +}): Promise { + if (!process.env.OPENAI_API_KEY) { + return { + text: "OPENAI_API_KEY не задан. Сейчас включен fallback-агент без LLM.", + plan: ["Проверить .env", "Добавить OPENAI_API_KEY", "Перезапустить dev-сервер"], + tools: [], + }; + } + + // Keep the dataset fresh so the "CRM filesystem" stays in sync with DB. + await ensureDataset({ teamId: input.teamId, userId: input.userId }); + + const toolsUsed: string[] = []; + const dbWrites: Array<{ kind: string; detail: string }> = []; + + const CrmToolSchema = z.object({ + action: z.enum([ + "query_contacts", + "query_deals", + "query_events", + "update_contact_note", + "create_event", + "create_message", + "update_deal_stage", + ]), + // queries + query: z.string().optional(), + stage: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + limit: z.number().int().optional(), + // writes + contact: z.string().optional(), + content: z.string().optional(), + title: z.string().optional(), + start: z.string().optional(), + end: z.string().optional(), + note: z.string().optional(), + status: z.string().optional(), + channel: z.enum(["Telegram", "WhatsApp", "Instagram", "Phone", "Email"]).optional(), + kind: z.enum(["message", "call"]).optional(), + direction: z.enum(["in", "out"]).optional(), + text: z.string().optional(), + at: z.string().optional(), + durationSec: z.number().int().optional(), + transcript: z.array(z.string()).optional(), + dealId: z.string().optional(), + }); + + const crmTool = tool( + async (raw: z.infer) => { + toolsUsed.push(`crm:${raw.action}`); + + if (raw.action === "query_contacts") { + const q = (raw.query ?? "").trim(); + const items = await prisma.contact.findMany({ + where: { + teamId: input.teamId, + ...(q + ? { + OR: [ + { name: { contains: q } }, + { company: { contains: q } }, + { email: { contains: q } }, + { phone: { contains: q } }, + ], + } + : {}), + }, + orderBy: { updatedAt: "desc" }, + take: Math.max(1, Math.min(raw.limit ?? 20, 100)), + include: { note: { select: { content: true, updatedAt: true } } }, + }); + return JSON.stringify( + items.map((c) => ({ + id: c.id, + name: c.name, + company: c.company, + country: c.country, + location: c.location, + email: c.email, + phone: c.phone, + note: c.note?.content ?? null, + })), + null, + 2, + ); + } + + if (raw.action === "query_deals") { + const items = await prisma.deal.findMany({ + where: { teamId: input.teamId, ...(raw.stage ? { stage: raw.stage } : {}) }, + orderBy: { updatedAt: "desc" }, + take: Math.max(1, Math.min(raw.limit ?? 20, 100)), + include: { contact: { select: { name: true, company: true } } }, + }); + return JSON.stringify( + items.map((d) => ({ + id: d.id, + title: d.title, + stage: d.stage, + amount: d.amount, + nextStep: d.nextStep, + summary: d.summary, + contact: d.contact.name, + company: d.contact.company, + })), + null, + 2, + ); + } + + if (raw.action === "query_events") { + const from = raw.from ? new Date(raw.from) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const to = raw.to ? new Date(raw.to) : new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); + const items = await prisma.calendarEvent.findMany({ + where: { teamId: input.teamId, startsAt: { gte: from, lte: to } }, + orderBy: { startsAt: "asc" }, + take: Math.max(1, Math.min(raw.limit ?? 100, 500)), + include: { contact: { select: { name: true } } }, + }); + return JSON.stringify( + items.map((e) => ({ + id: e.id, + title: e.title, + startsAt: e.startsAt.toISOString(), + endsAt: (e.endsAt ?? e.startsAt).toISOString(), + note: e.note, + contact: e.contact?.name ?? null, + })), + null, + 2, + ); + } + + if (raw.action === "update_contact_note") { + const contactName = (raw.contact ?? "").trim(); + const content = (raw.content ?? "").trim(); + if (!contactName) throw new Error("contact is required"); + if (!content) throw new Error("content is required"); + + const contact = await prisma.contact.findFirst({ + where: { teamId: input.teamId, name: contactName }, + select: { id: true }, + }); + if (!contact) throw new Error("contact not found"); + + await prisma.contactNote.upsert({ + where: { contactId: contact.id }, + update: { content }, + create: { contactId: contact.id, content }, + }); + dbWrites.push({ kind: "contact_note", detail: `${contactName}: updated` }); + return JSON.stringify({ ok: true }); + } + + if (raw.action === "create_event") { + const title = (raw.title ?? "").trim(); + const start = raw.start ? new Date(raw.start) : null; + if (!title) throw new Error("title is required"); + if (!start || Number.isNaN(start.getTime())) throw new Error("start is invalid"); + + const end = raw.end ? new Date(raw.end) : null; + const contactName = (raw.contact ?? "").trim(); + const contact = contactName + ? await prisma.contact.findFirst({ where: { teamId: input.teamId, name: contactName }, select: { id: true } }) + : null; + + const created = await prisma.calendarEvent.create({ + data: { + teamId: input.teamId, + contactId: contact?.id ?? null, + title, + startsAt: start, + endsAt: end && !Number.isNaN(end.getTime()) ? end : null, + note: (raw.note ?? "").trim() || null, + status: (raw.status ?? "").trim() || null, + }, + }); + dbWrites.push({ kind: "calendar_event", detail: `created ${created.id}` }); + return JSON.stringify({ ok: true, id: created.id }); + } + + if (raw.action === "create_message") { + const contactName = (raw.contact ?? "").trim(); + const text = (raw.text ?? "").trim(); + if (!contactName) throw new Error("contact is required"); + if (!text) throw new Error("text is required"); + + const contact = await prisma.contact.findFirst({ + where: { teamId: input.teamId, name: contactName }, + select: { id: true }, + }); + if (!contact) throw new Error("contact not found"); + + const occurredAt = raw.at ? new Date(raw.at) : new Date(); + if (Number.isNaN(occurredAt.getTime())) throw new Error("at is invalid"); + + const created = await prisma.contactMessage.create({ + data: { + contactId: contact.id, + kind: raw.kind === "call" ? "CALL" : "MESSAGE", + direction: raw.direction === "in" ? "IN" : "OUT", + channel: + raw.channel === "Telegram" + ? "TELEGRAM" + : raw.channel === "WhatsApp" + ? "WHATSAPP" + : raw.channel === "Instagram" + ? "INSTAGRAM" + : raw.channel === "Email" + ? "EMAIL" + : "PHONE", + content: text, + durationSec: typeof raw.durationSec === "number" ? raw.durationSec : null, + transcriptJson: Array.isArray(raw.transcript) ? raw.transcript : null, + occurredAt, + }, + }); + dbWrites.push({ kind: "contact_message", detail: `created ${created.id}` }); + return JSON.stringify({ ok: true, id: created.id }); + } + + if (raw.action === "update_deal_stage") { + const dealId = (raw.dealId ?? "").trim(); + const stage = (raw.stage ?? "").trim(); + if (!dealId) throw new Error("dealId is required"); + if (!stage) throw new Error("stage is required"); + + const updated = await prisma.deal.updateMany({ + where: { id: dealId, teamId: input.teamId }, + data: { stage }, + }); + if (updated.count === 0) throw new Error("deal not found"); + dbWrites.push({ kind: "deal", detail: `updated stage for ${dealId}` }); + return JSON.stringify({ ok: true }); + } + + return JSON.stringify({ ok: false, error: "unknown action" }); + }, + { + name: "crm", + description: + "Query and update CRM data (contacts, deals, events, communications). Use this tool for any data you need beyond the snapshot.", + schema: CrmToolSchema, + }, + ); + + const snapshot = await buildCrmSnapshot({ teamId: input.teamId }); + + const model = new ChatOpenAI({ + apiKey: process.env.OPENAI_API_KEY, + model: process.env.OPENAI_MODEL || "gpt-4o-mini", + temperature: 0.2, + }); + + const agent = createReactAgent({ + llm: model, + tools: [crmTool], + responseFormat: z.object({ + answer: z.string().describe("Final assistant answer for the user."), + plan: z.array(z.string()).min(1).max(10).describe("Short plan (3-8 steps)."), + }), + }); + + const system = [ + "You are Pilot, a CRM assistant.", + "Rules:", + "- Be concrete and concise.", + "- If you need data beyond the snapshot, call the crm tool.", + "- If user asks to change CRM, you may do it via the crm tool and then report what changed.", + "- Do not claim you sent an external message; you can only create draft messages/events/notes in CRM.", + "", + "CRM Snapshot:", + snapshot, + ].join("\n"); + + const res: any = await agent.invoke( + { + messages: [ + { role: "system", content: system }, + { role: "user", content: input.userText }, + ], + }, + { recursionLimit: 30 }, + ); + + const structured = res?.structuredResponse as { answer?: string; plan?: string[] } | undefined; + const text = structured?.answer?.trim() || "Готово."; + const plan = Array.isArray(structured?.plan) ? structured!.plan : ["Собрать данные", "Сформировать ответ"]; + + return { text, plan, tools: toolsUsed, dbWrites: dbWrites.length ? dbWrites : undefined }; +} diff --git a/Frontend/server/api/auth/demo.post.ts b/Frontend/server/api/auth/demo.post.ts new file mode 100644 index 0000000..a8dc961 --- /dev/null +++ b/Frontend/server/api/auth/demo.post.ts @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..e4264ce --- /dev/null +++ b/Frontend/server/api/auth/login.post.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..47b6863 --- /dev/null +++ b/Frontend/server/api/auth/logout.post.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..2d89e81 --- /dev/null +++ b/Frontend/server/api/auth/me.get.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..e652390 --- /dev/null +++ b/Frontend/server/api/calendar.get.ts @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..54ad2cc --- /dev/null +++ b/Frontend/server/api/calendar.post.ts @@ -0,0 +1,51 @@ +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/communications.get.ts b/Frontend/server/api/communications.get.ts new file mode 100644 index 0000000..6caad02 --- /dev/null +++ b/Frontend/server/api/communications.get.ts @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..6c9d36f --- /dev/null +++ b/Frontend/server/api/communications.post.ts @@ -0,0 +1,53 @@ +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 new file mode 100644 index 0000000..3edd5f5 --- /dev/null +++ b/Frontend/server/api/contacts.get.ts @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..b7b689a --- /dev/null +++ b/Frontend/server/api/contacts/[id].get.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..26ea613 --- /dev/null +++ b/Frontend/server/api/contacts/[id]/note.put.ts @@ -0,0 +1,23 @@ +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/deals.get.ts b/Frontend/server/api/deals.get.ts new file mode 100644 index 0000000..38ea439 --- /dev/null +++ b/Frontend/server/api/deals.get.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..a5365e5 --- /dev/null +++ b/Frontend/server/api/documents.get.ts @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..a2d1b78 --- /dev/null +++ b/Frontend/server/api/feed.get.ts @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..e8f7109 --- /dev/null +++ b/Frontend/server/api/feed/[id].put.ts @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..8b49d14 --- /dev/null +++ b/Frontend/server/api/pins.get.ts @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..b6fca90 --- /dev/null +++ b/Frontend/server/api/telegram/messages.get.ts @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..ce77999 --- /dev/null +++ b/Frontend/server/api/telegram/send.post.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..26c6a4e --- /dev/null +++ b/Frontend/server/api/telegram/threads.get.ts @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..4ed56ca --- /dev/null +++ b/Frontend/server/api/telegram/webhook.post.ts @@ -0,0 +1,163 @@ +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/Frontend/server/dataset/exporter.ts b/Frontend/server/dataset/exporter.ts index e82fd02..2535ca2 100644 --- a/Frontend/server/dataset/exporter.ts +++ b/Frontend/server/dataset/exporter.ts @@ -50,7 +50,15 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId include: { note: { select: { content: true, updatedAt: true } }, messages: { - select: { direction: true, channel: true, content: true, occurredAt: true }, + select: { + kind: true, + direction: true, + channel: true, + content: true, + durationSec: true, + transcriptJson: true, + occurredAt: true, + }, orderBy: { occurredAt: "asc" }, }, events: { @@ -70,6 +78,9 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId 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, @@ -86,10 +97,13 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId 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"); diff --git a/Frontend/server/plugins/queues.ts b/Frontend/server/plugins/queues.ts new file mode 100644 index 0000000..a217603 --- /dev/null +++ b/Frontend/server/plugins/queues.ts @@ -0,0 +1,9 @@ +import { startTelegramSendWorker } from "../queues/telegramSend"; + +export default defineNitroPlugin(() => { + // Keep API nodes and worker nodes separate: start only when explicitly enabled. + if (process.env.RUN_QUEUE_WORKER !== "1") return; + + startTelegramSendWorker(); +}); + diff --git a/Frontend/server/queues/telegramSend.ts b/Frontend/server/queues/telegramSend.ts new file mode 100644 index 0000000..d92a0a8 --- /dev/null +++ b/Frontend/server/queues/telegramSend.ts @@ -0,0 +1,92 @@ +import { Queue, Worker, JobsOptions } from "bullmq"; +import { getRedis } from "../utils/redis"; +import { prisma } from "../utils/prisma"; +import { telegramBotApi } from "../utils/telegram"; + +export const TELEGRAM_SEND_QUEUE_NAME = "telegram:send"; + +type TelegramSendJob = { + omniMessageId: string; +}; + +export function telegramSendQueue() { + return new Queue(TELEGRAM_SEND_QUEUE_NAME, { + connection: getRedis(), + defaultJobOptions: { + removeOnComplete: { count: 1000 }, + removeOnFail: { count: 5000 }, + }, + }); +} + +export async function enqueueTelegramSend(input: TelegramSendJob, opts?: JobsOptions) { + const q = telegramSendQueue(); + return q.add("send", input, { + jobId: input.omniMessageId, // idempotency + attempts: 10, + backoff: { type: "exponential", delay: 1000 }, + ...opts, + }); +} + +export function startTelegramSendWorker() { + return new Worker( + TELEGRAM_SEND_QUEUE_NAME, + async (job) => { + const msg = await prisma.omniMessage.findUnique({ + where: { id: job.data.omniMessageId }, + include: { thread: true }, + }); + if (!msg) return; + + // Idempotency: if we already sent it, don't send twice. + if (msg.status === "SENT" && msg.providerMessageId) return; + + if (msg.channel !== "TELEGRAM" || msg.direction !== "OUT") { + throw new Error(`Invalid omni message for telegram send: ${msg.id}`); + } + + const thread = msg.thread; + const chatId = thread.externalChatId; + const businessConnectionId = thread.businessConnectionId || undefined; + + try { + const result = await telegramBotApi("sendMessage", { + chat_id: chatId, + text: msg.text, + ...(businessConnectionId ? { business_connection_id: businessConnectionId } : {}), + }); + + const providerMessageId = result?.message_id != null ? String(result.message_id) : null; + await prisma.omniMessage.update({ + where: { id: msg.id }, + data: { + status: "SENT", + providerMessageId: providerMessageId, + rawJson: result, + }, + }); + } catch (e: any) { + 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: { + error: String(e?.message || e), + attemptsMade: job.attemptsMade + 1, + }, + }, + }); + } + + throw e; + } + }, + { connection: getRedis() }, + ); +} + diff --git a/Frontend/server/utils/auth.ts b/Frontend/server/utils/auth.ts index 44cba10..dcb5036 100644 --- a/Frontend/server/utils/auth.ts +++ b/Frontend/server/utils/auth.ts @@ -1,5 +1,6 @@ -import { prisma } from "./prisma"; import type { H3Event } from "h3"; +import { getCookie, setCookie, deleteCookie, getHeader } from "h3"; +import { prisma } from "./prisma"; export type AuthContext = { teamId: string; @@ -7,51 +8,85 @@ export type AuthContext = { conversationId: string; }; -// Minimal temporary auth: pick from headers or auto-provision a default team/user. +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 { + 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(); - // Ensure default team/user exist. - const user = - (hdrUser ? await prisma.user.findUnique({ where: { id: hdrUser } }) : null) ?? - (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" }, - })); + const hasAnySession = Boolean(cookieUser || cookieTeam || cookieConv || hdrTeam || hdrUser || hdrConv); + if (!hasAnySession) { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + } - const team = - (hdrTeam - ? await prisma.team.findUnique({ where: { id: hdrTeam } }) - : null) ?? - (await prisma.team.upsert({ - where: { id: "demo-team" }, - update: { name: "Demo Team" }, - create: { id: "demo-team", name: "Demo Team" }, - })); + 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 } }); + const conv = await prisma.chatConversation.findUnique({ where: { id: conversationId } }); + + if (!user || !team || !conv) { + throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); + } + + return { teamId: team.id, userId: user.id, conversationId: conv.id }; +} + +export async function ensureDemoAuth() { + 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" }, + }); + 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 conversation = - (hdrConv - ? await prisma.chatConversation.findUnique({ where: { id: hdrConv } }) - : null) ?? - (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: conversation.id }; + 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 }; } diff --git a/Frontend/server/utils/redis.ts b/Frontend/server/utils/redis.ts new file mode 100644 index 0000000..5b31e1f --- /dev/null +++ b/Frontend/server/utils/redis.ts @@ -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; +} + diff --git a/Frontend/server/utils/telegram.ts b/Frontend/server/utils/telegram.ts new file mode 100644 index 0000000..63d804f --- /dev/null +++ b/Frontend/server/utils/telegram.ts @@ -0,0 +1,29 @@ +export type TelegramUpdate = Record; + +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(method: string, body: unknown): Promise { + 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; +} + diff --git a/Frontend/tsconfig.json b/Frontend/tsconfig.json new file mode 100644 index 0000000..7caed85 --- /dev/null +++ b/Frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./.nuxt/tsconfig.json" +} +