Compare commits

...

132 Commits

Author SHA1 Message Date
Ruslan Bakiev
39cf198e11 fix(vault): auto-init when not initialized, then unseal 2026-03-10 21:03:25 +07:00
Ruslan Bakiev
2722aa860d fix(vault): use proven GL vault entrypoint for auto-unseal 2026-03-10 20:55:23 +07:00
Ruslan Bakiev
bb2fab8b40 fix(vault): properly use VAULT_UNSEAL_KEY from env 2026-03-10 20:49:57 +07:00
Ruslan Bakiev
8eec280b9d fix(vault): read unseal key from init.json fallback 2026-03-10 20:41:51 +07:00
Ruslan Bakiev
08a31383f0 fix(vault): auto-init and auto-unseal on first start 2026-03-10 20:32:37 +07:00
Ruslan Bakiev
9283ab436f chore: update frontend submodule 2026-03-10 20:22:58 +07:00
Ruslan Bakiev
25623b8f65 feat(vault): auto-unseal on container start via VAULT_UNSEAL_KEY env 2026-03-10 20:10:35 +07:00
Ruslan Bakiev
29309419bf chore: update frontend submodule (add preact) 2026-03-10 19:42:43 +07:00
Ruslan Bakiev
035f24387b chore: update frontend submodule (Schedule-X calendar) 2026-03-10 19:32:15 +07:00
Ruslan Bakiev
2ca1e75651 chore(repo): split frontend backend backend_worker into submodules 2026-03-09 10:23:40 +07:00
Ruslan Bakiev
e96b57a55f refactor(pilot-chat): stream native ai sdk reasoning parts 2026-03-09 09:47:32 +07:00
Ruslan Bakiev
c2cf4e6dd8 fix(backend): configure prisma datasource and client generator 2026-03-08 20:56:08 +07:00
Ruslan Bakiev
ad0f61fa8d fix(backend): set DATABASE_URL for prisma generate in docker build 2026-03-08 20:47:48 +07:00
Ruslan Bakiev
22e04e0a34 chore: clean up workspace and fix backend prisma build 2026-03-08 20:31:32 +07:00
Ruslan Bakiev
f1cf90adc7 add vault bootstrap for services and vault deploy app 2026-03-08 19:37:02 +07:00
Ruslan Bakiev
e4870ce669 add backend hatchet worker for calendar predue sync 2026-03-08 19:15:30 +07:00
Ruslan Bakiev
0df426d5d6 refactor pilot chat api contract and typed ai-sdk flow 2026-03-08 19:04:04 +07:00
Ruslan Bakiev
7d1bed0d67 refactor chat delivery to graphql + hatchet services 2026-03-08 18:55:58 +07:00
Ruslan Bakiev
fe4bd59248 refactor(whisper): use shared whisper service instead of local model
Replace @xenova/transformers in-process model with HTTP calls to shared
whisper-asr-webservice container (http://whisper:9000). Converts PCM16
samples to WAV and sends to /asr endpoint. Env: WHISPER_URL.
2026-03-07 10:51:40 +07:00
Ruslan Bakiev
12af9979ab feat(crm): add deal create/update controls with status and payment 2026-02-27 09:44:15 +07:00
Ruslan Bakiev
881a8c6d39 feat(workspace): use selects for quick event date/time controls 2026-02-26 15:50:06 +07:00
Ruslan Bakiev
b2a948889e fix(workspace): restore quick-menu handlers for events and docs 2026-02-26 14:45:54 +07:00
Ruslan Bakiev
aae2a03340 fix(contacts): keep unread inbound-only while refreshing outbound preview 2026-02-26 12:27:06 +07:00
Ruslan Bakiev
0a470d3922 feat(calendar): show contact avatars on event cards 2026-02-26 12:18:14 +07:00
Ruslan Bakiev
5063dfdecf Trigger frontend webhook redeploy 2026-02-26 11:49:03 +07:00
Ruslan Bakiev
a0ff1d00f6 Hydrate Telegram avatars on demand in frontend API 2026-02-26 11:43:47 +07:00
Ruslan Bakiev
97fb67f68c Fetch Telegram avatars via API when webhook has no photo 2026-02-26 11:22:57 +07:00
Ruslan Bakiev
6bae0300c8 Cache Telegram avatars locally and refresh avatar file ids 2026-02-26 10:59:44 +07:00
Ruslan Bakiev
ba3e5f7cac Fix Telegram contact avatars in CRM list 2026-02-26 10:41:27 +07:00
Ruslan Bakiev
0f87586e81 fix: OUT messages no longer create unread status + handle Telegram read receipts
Only inbound (IN) messages determine hasUnread in getContacts(). Telegram
read_business_message events are now parsed and processed to auto-mark
contacts as read for the entire team.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:53:55 +07:00
Ruslan Bakiev
6291797bb6 chore: upgrade Prisma 7, LangChain 1.x, Tailwind 4.2, Vue 3.5.29 and other deps
- Prisma 6 → 7: new prisma-client generator, prisma.config.ts, PrismaPg adapter, updated all imports
- LangChain 0.x → 1.x: @langchain/core, langgraph, openai
- Tailwind 4.1 → 4.2.1, daisyUI 5.5.19, Vue 3.5.29, ai 6.0.99, zod 4.3.6
- Fix MessageDirection bug in crm-updates.ts (OUTBOUND → OUT)
- Add server/generated to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:27:26 +07:00
Ruslan Bakiev
f4891e6932 fix: replace prisma.$use with $extends for Prisma 6 compatibility
prisma.$use middleware API was removed in Prisma 6. Switched to
$extends query hooks which is the supported approach.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:08:20 +07:00
Ruslan Bakiev
5c29cde13d refactor: remove manual upsertClientTimelineEntry from GraphQL resolvers
CalendarEvent and FeedCard timeline entries are now handled by Prisma
middleware automatically. Document timeline entry is inlined since
WorkspaceDocument stores contactId in scope field, not on the model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:04:04 +07:00
Ruslan Bakiev
693a96cffd feat: auto-create ClientTimelineEntry via Prisma middleware
CalendarEvent and FeedCard now automatically get a ClientTimelineEntry
when created/updated with a contactId. This ensures events created by
the agent (or any other code path) appear in the contact timeline
without needing explicit upsertClientTimelineEntry calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:01:30 +07:00
Ruslan Bakiev
bf7f4ae933 feat: broadcast pilot agent traces via WebSocket for live status on reconnect
Agent trace logs are now stored in-memory (pilotRunStore) and broadcast
through the existing /ws/crm-updates WebSocket channel. When a client
reconnects, it receives a pilot.catchup with all accumulated logs so the
user sees agent progress even after page reload. Three new WS event
types: pilot.trace, pilot.finished, pilot.catchup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:45:32 +07:00
Ruslan Bakiev
b830f3728c fix: show action label (Показать/Скрыть) instead of status in inbox settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:28:52 +07:00
Ruslan Bakiev
5ff7dc8d65 chore: update instructions submodule to latest
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:21:04 +07:00
Ruslan Bakiev
7d647bef25 fix: disable context picker mode after selecting a scope
When a context scope is picked via the pipette, the picker mode now
turns off automatically since the selection is done and shows as a chip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:18:45 +07:00
Ruslan Bakiev
9b6e8291fe fix: waveform not rendering on voice messages after thread switch
The call wave sync watcher fired before DOM was updated (flush: 'pre'),
so ref callbacks hadn't registered host elements yet. Changed to
flush: 'post' so WaveSurfer instances are created after DOM refs exist.
Also fixed getCallItem to read from clientTimelineItems (source of truth)
instead of visibleThreadItems which could be stale.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:17:17 +07:00
Ruslan Bakiev
6e3763a5fd fix: refetch contacts after hiding inbox, redirect to most recent chat
After hiding a contact inbox, the contacts list now refetches immediately
so the hidden contact disappears reactively. When the current contact is
removed from the list, the selection jumps to the most recently active
contact (by lastContactAt) instead of the first item in the current sort.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:56:10 +07:00
Ruslan Bakiev
292d587fe1 debug(calendar): add console.warn to GSAP zoom animation functions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:32:33 +07:00
Ruslan Bakiev
1a6840cdc6 fix: optimistic message send — no full timeline reload
Instead of calling openCommunicationThread() after sending (which triggered
a full timeline refetch, destroyed audio waveforms, and caused the chat to
jump), we now:
- Optimistically append the sent message to clientTimelineItems
- Scroll to bottom smoothly
- Refresh contacts sidebar for lastMessageText preview
- Auto-scroll only fires on thread switch (empty→loaded), not on every
  timeline update, preserving audio waveform DOM elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:14:56 +07:00
Ruslan Bakiev
898f0dc0c5 feat: auto-scroll chat to bottom on thread switch and new messages
Add ref to comm-thread-surface container and watch clientTimelineItems
to scroll to bottom via nextTick whenever messages load or update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:07:05 +07:00
Ruslan Bakiev
cb685446a5 fix: show loading spinner when switching between contact threads
Clear old timeline items immediately on thread switch and display a centered
loader until the new conversation loads, instead of showing stale messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:45:01 +07:00
Ruslan Bakiev
3ff9120070 fix: export isCommCallPlaying from useCallAudio composable
Same issue as isCommCallPlayable — used in template but not exported/destructured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:09:39 +07:00
Ruslan Bakiev
c07ef2026d fix: export isCommCallPlayable from useCallAudio composable
Function was used in CrmWorkspaceApp template but not exported/destructured,
causing TypeError at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:00:10 +07:00
Ruslan Bakiev
5492e0d05c feat: unread message tracking with blue dot indicator
Add ContactThreadRead model to track when users last viewed each contact thread.
Contacts with messages newer than the last read time show a blue dot in the sidebar.
Opening a thread automatically marks it as read via markThreadRead mutation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:25:32 +07:00
Ruslan Bakiev
643d8d02ba feat: granular WebSocket message.new events
- WebSocket now detects new ContactMessages and broadcasts
  message.new events with contactId, text, channel, direction
- Frontend handles message.new: refreshes timeline for open chat,
  refreshes contacts for sidebar preview update
- dashboard.changed still fires for non-message changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:04:55 +07:00
Ruslan Bakiev
ac9c50b47d feat: remove CommunicationsQuery, load messages on-demand only
- Remove bulk CommunicationsQuery from useContacts (was loading ALL
  messages for ALL contacts on init)
- Rebuild commThreads from contacts + contactInboxes using the new
  lastMessageText field from Phase 1
- Per-contact messages now load on-demand via getClientTimeline
- Remove commItems from useWorkspaceRouting, use clientTimelineItems

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:02:58 +07:00
Ruslan Bakiev
601de37ab0 feat: add lastMessageText and lastMessageChannel to contacts query
Enriches the contacts resolver to include the last message preview
and channel, so the sidebar can show thread previews without loading
all communications. No frontend changes yet — fields returned but unused.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:02:02 +07:00
Ruslan Bakiev
c229bdee23 fix(calendar): restore GSAP fly-rect + fly-label animation in useCalendar composable
The refactoring in a4d8d81 moved calendar logic into useCalendar.ts but
used the old CSS-transform animation code instead of the GSAP-based
flying rect + flying label implementation. This restores:

- GSAP-based animateCalendarZoomIntoSource and animateCalendarFlipTransition
- Flying label that animates from card title → toolbar on zoom-in and back
- Clone-and-swap pattern with skeleton content in fly-rect (no text)
- Fly-rect/fly-label refs and setters now live in the composable
- isoWeekNumber() and weekNumber field on monthRows
- Sibling card titles and week numbers faded during zoom
- Removed old CSS-transform camera state and calendarSceneTransformStyle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:50:35 +07:00
Ruslan Bakiev
3775d881f9 fix: pass selectedCommThreadId to refreshSelectedClientTimeline
The function was called without arguments in two places, causing
contactId to be empty string and clearing clientTimelineItems to [].
Messages disappeared from the chat panel as a result.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:45:19 +07:00
Ruslan Bakiev
195df8e16a fix: stop aggressive 2s chat polling, use WebSocket instead
- Add refetchChatMessages + refetchChatConversations to
  refetchAllCrmQueries so WebSocket dashboard.changed events
  cover pilot chat updates
- Reduce background polling from 2s to 30s (fallback only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:41:58 +07:00
Ruslan Bakiev
19d001815c fix: add missing ClientTimelineItem import in useDocuments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:33:22 +07:00
Ruslan Bakiev
d892d0c604 refactor: distribute types from crm-types.ts to owning composables
Each composable now owns its types and exports them. Other composables
import types from the owning composable. Deleted centralized crm-types.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:21:30 +07:00
Ruslan Bakiev
a4d8d81de9 refactor: decompose CrmWorkspaceApp.vue into 15 composables
Split the 6000+ line monolithic component into modular composables:
- crm-types.ts: shared types and utility functions
- useAuth, useContacts, useContactInboxes, useCalendar, useDeals,
  useDocuments, useFeed, useTimeline, usePilotChat, useCallAudio,
  usePins, useChangeReview, useCrmRealtime, useWorkspaceRouting
CrmWorkspaceApp.vue is now a thin orchestrator (~2500 lines) that
wires composables together with glue code, keeping template and
styles intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:05:01 +07:00
Ruslan Bakiev
e5ad3809e0 feat(calendar): flying label animation from card title to toolbar on zoom
The label (month name, week number, day label) now animates from its
position above the source card to the toolbar center on zoom-in, and
flies back from toolbar to the target card title on zoom-out. The
fly-rect rectangle no longer contains text — only skeleton placeholder
lines. Sibling card titles and week numbers also fade during zoom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:03:16 +07:00
Ruslan Bakiev
00e036946c feat(calendar): move labels outside card borders for visual continuity
- Month names moved above month cards in year view (outside article border)
  Hidden in non-year views; toolbar shows context label instead
- Day labels (Mon 24, Tue 25) moved above day columns in week view
- Each card/column wrapped in flex container: title above, card below
- New .calendar-card-title CSS: 11px, 55% opacity, subtle label above card
- No duplicate headers: toolbar is the single source of current context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:42:09 +07:00
Ruslan Bakiev
9505cecab2 feat(calendar): header continuity with week numbers + skeleton content in fly-rect
- Add ISO week numbers to the left of week rows in month view (8, 9, 10...)
  with spacer alignment on day-of-week headers
- Inject label + skeleton placeholder lines into fly-rect during zoom animations:
  zoom-in shows source label (month name / "Week N" / day name) + pulsing bars
  zoom-out shows target context label + skeleton
- Skeleton CSS uses pulse animation (0.8s alternate) for loading hint
- Non-scoped style block for dynamically injected innerHTML elements
- isoWeekNumber helper for ISO 8601 week calculation
- Extended MonthRow type with weekNumber property

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:28:31 +07:00
Ruslan Bakiev
77141978c5 feat(calendar): seamless zoom animation with clone-and-swap + full-area coverage
Zoom-in: fade siblings → fade source content → clone source style to fly-rect →
hide source → animate fly-rect to viewport → switch view → fade in new content.

Zoom-out: fade scene → show fly-rect at viewport → switch view → clone target
style → animate fly-rect to target → fade in scene.

Full-area: all views (month/week/day) now fill 100% of container height.
Month grid rows stretch equally, day cells fill row height, depth layers flex.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:56:42 +07:00
Ruslan Bakiev
227030b9ae feat(calendar): replace CSS-transform zoom with GSAP flying-rect animation and scope data to year
- Add CalendarDateRange input to GraphQL schema; server resolver now accepts from/to params
- Frontend query sends year-scoped date range variables reactively
- Rewrite zoom-in/zoom-out animations using GSAP flying-rect overlay (650ms vs 2400ms)
- Add flying-rect element to CrmCalendarPanel with proper CSS
- Remove old calendarSceneTransformStyle CSS-transition approach
- Add calendarKillTweens cleanup in onBeforeUnmount

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:41:35 +07:00
Ruslan Bakiev
638652b4d8 fix(calendar-lab): enable hover on grid cells by removing pointer-events block
Content overlay layer was intercepting mouse events (pointer-events: auto
with z-index: 5), preventing :hover from reaching the grid cells underneath.
Removed pointer-events: auto from .canvas-content-visible since the content
layer is purely visual and needs no click handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:16:20 +07:00
Ruslan Bakiev
f553c26931 fix: add browserHttpEndpoint for client-side Apollo requests
The Apollo Client was using httpEndpoint (http://localhost:3000/api/graphql)
for browser requests, which fails on remote servers. Added browserHttpEndpoint
as relative URL "/api/graphql" so browser requests go to the correct origin.
Also added error logging to setInboxHidden mutation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:40:08 +07:00
Ruslan Bakiev
5657da13c1 feat(calendar-lab): add hover-targeted zoom with progressive tension and zoom slider
- Zoom animation now targets the hovered cell (not a fixed demo block)
- Progressive "pull" tension: cell scales 5%→10% over 2 scroll ticks
  before the full flying-rect animation triggers (400ms decay timeout)
- Added zoom slider in top-right toolbar matching production design
  (range 0-3 with dot markers, drives step-by-step zoom animations)
- Slider handles mid-drag target updates via sliderTarget variable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:36:22 +07:00
Ruslan Bakiev
947ef4d56d refactor: migrate CRM data layer from manual gqlFetch to Apollo Client
Replace custom gqlFetch() with proper Apollo useQuery/useMutation hooks
powered by codegen-generated TypedDocumentNode types. Key changes:

- Add GraphQL SDL schema file and codegen config for typescript-vue-apollo
- Replace all 28 raw .graphql imports with generated typed documents
- Add 12 useQuery() hooks with cache-and-network fetch policy
- Add 17 useMutation() hooks with surgical refetchQueries per mutation
- Optimistic cache update for setContactInboxHidden (instant archive UX)
- Fix contact list subtitle: show lastText instead of channel name
- Migrate login page from gqlFetch to useMutation
- WebSocket realtime now calls Apollo refetch instead of full data reload

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:07:35 +07:00
Ruslan Bakiev
3e711a5533 fix(calendar-lab): rewrite zoom as GSAP flying-rect with proper async sequencing
- Replace panzoom + timeline approach with clean async/await GSAP tweens
- Flying rect morphs from cell position to full viewport (aspect ratio change)
- Fix zoomOut race condition: nested gsap.to inside tl.call fired outside timeline
- Fix opacity conflict: GSAP controls all opacity, CSS class only for pointer-events
- Fix gridLayerRef losing reference on :key remount during resize
- Make viewport dimensions reactive via ResizeObserver (vpWidth/vpHeight refs)
- Wait for fade-in completion before unlocking isAnimating

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-24 09:47:45 +07:00
Ruslan Bakiev
b316b024be feat(calendar-lab): replace tldraw with two-layer panzoom canvas
Drop tldraw/React dependency in favor of @panzoom/panzoom with a
two-layer architecture: outline rectangles (borders only) are zoomed
via CSS transforms while HTML content renders at native 1:1 scale
as a fade-in overlay — eliminating blur at any zoom level.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-24 01:09:46 +07:00
Ruslan Bakiev
1db8e58da1 fix(calendar-lab): rewrite tldraw zoom as LOD — render only current level shapes
Previous implementation created all ~589 shapes (year + months + weeks + days)
at once, causing visual chaos on load and broken zoom. New approach dynamically
creates/destroys shapes per level: year shows 12 months, month shows 6 weeks,
week shows 7 days, day shows time slots. Wheel prime pattern (2 ticks) prevents
accidental zooms. Double-click resets to year view.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-24 00:52:58 +07:00
Ruslan Bakiev
6cce211c0b fix(calendar-lab): use local tldraw runtime to avoid react/cdn instance mismatch 2026-02-23 19:47:57 +07:00
Ruslan Bakiev
c5d3a90413 refactor(voice): extract chat dictation into reusable component 2026-02-23 19:43:00 +07:00
Ruslan Bakiev
c1e8f912d1 fix(communications): restore voice dictation in message composer 2026-02-23 19:34:39 +07:00
Ruslan Bakiev
ed78532260 feat(calendar-lab): use tldraw canvas engine with nested zoom rectangles 2026-02-23 19:29:52 +07:00
Ruslan Bakiev
94d8d46693 fix telegram contact identity context for in/out messages 2026-02-23 19:28:03 +07:00
Ruslan Bakiev
79f1012f41 fix(calendar-lab): restore nested calendar canvas demo on zoom route 2026-02-23 19:22:14 +07:00
Ruslan Bakiev
faea65dfcb style(communications): show call play button only on waveform hover 2026-02-23 19:21:54 +07:00
Ruslan Bakiev
bb628a7c0d feat(calendar-lab): switch demo route to tldraw canvas preview 2026-02-23 18:48:34 +07:00
Ruslan Bakiev
2e1014d726 style(communications): center play control on call waveform 2026-02-23 18:44:35 +07:00
Ruslan Bakiev
5fb8113ed7 feat(communications): add play/pause for call voice messages 2026-02-23 18:42:21 +07:00
Ruslan Bakiev
2a3d18f326 compact pilot change summary row in sidebar 2026-02-23 18:37:44 +07:00
Ruslan Bakiev
0ed2a6b353 fix(calendar-lab): stabilize center fit and wheel direction 2026-02-23 18:26:07 +07:00
Ruslan Bakiev
179cc39d53 fix(calendar-lab): center panzoom fit on selected target 2026-02-23 18:19:09 +07:00
Ruslan Bakiev
6ab3b374a2 feat(calendar-lab): switch zoom scene to panzoom engine 2026-02-23 18:12:05 +07:00
Ruslan Bakiev
49c4757490 feat(calendar-lab): switch to hierarchical grid zoom mechanics 2026-02-23 18:03:27 +07:00
Ruslan Bakiev
67a186e916 feat(calendar-lab): render content only after zoom settle 2026-02-23 17:47:58 +07:00
Ruslan Bakiev
6d5402dcc1 feat(calendar-lab): amplify nested board zoom depth 2026-02-23 17:42:19 +07:00
Ruslan Bakiev
295b3a3dda fix(frontend): use exported toast-ui entry to fix nuxt build 2026-02-23 16:04:38 +07:00
Ruslan Bakiev
94c01516ba precompute call waveforms and stop list-time audio loading 2026-02-23 16:02:57 +07:00
Ruslan Bakiev
2eb2f3109c feat(calendar): add nested zoom lab page with four persistent levels 2026-02-23 15:57:12 +07:00
Ruslan Bakiev
6bc0bfa156 fix(documents): switch markdown editor to single-pane wysiwyg 2026-02-23 15:53:29 +07:00
Ruslan Bakiev
cb2d12819c fix(calendar): stretch year months grid to full viewport height 2026-02-23 15:39:09 +07:00
Ruslan Bakiev
0bbeef5594 fix(calendar): scope zoom selectors by layer and stretch week days to full height 2026-02-23 15:32:52 +07:00
Ruslan Bakiev
df8c06d313 refactor(review): rollback-only flow and compact change summary 2026-02-23 15:28:46 +07:00
Ruslan Bakiev
f716a0ea26 feat(documents): use toast-ui markdown rich editor 2026-02-23 15:23:58 +07:00
Ruslan Bakiev
7c019a6300 fix(calendar): keep depth layers mounted in one card without display swap 2026-02-23 14:58:52 +07:00
Ruslan Bakiev
ec94dd6e2a fix(calendar): zoom selected block first, then commit level 2026-02-23 14:56:11 +07:00
Ruslan Bakiev
40a225783d feat(documents): render markdown as rich text in editor 2026-02-23 14:54:03 +07:00
Ruslan Bakiev
60b9bb9fd1 remove contact company/country/location across db and ui 2026-02-23 14:52:26 +07:00
Ruslan Bakiev
f6b738352b fix(calendar): use full available calendar viewport height on desktop 2026-02-23 14:40:59 +07:00
Ruslan Bakiev
db49c4a830 fix(calendar): make nested block zoom smooth in both directions 2026-02-23 14:33:24 +07:00
Ruslan Bakiev
6ad53e64c5 feat(documents): delete document from context menu 2026-02-23 14:27:00 +07:00
Ruslan Bakiev
68cbe7bc64 fix(chat-ui): move source settings to thread header 2026-02-23 14:24:58 +07:00
Ruslan Bakiev
a19ba07baa fix(chat-ui): align voice cards by message direction 2026-02-23 12:52:33 +07:00
Ruslan Bakiev
894210cd42 fix(calendar): remove overlay swap and keep in-place zoom flow 2026-02-23 12:50:11 +07:00
Ruslan Bakiev
d3b751db65 refactor(graphql): replace dashboard query with resource queries 2026-02-23 12:46:29 +07:00
Ruslan Bakiev
aa465f65bd feat(workspace): add hidden contacts filter and remove calendar scene swap 2026-02-23 12:38:30 +07:00
Ruslan Bakiev
f076726362 fix(communications): move source settings gear to contact row 2026-02-23 12:30:26 +07:00
Ruslan Bakiev
acd974766a feat(telegram): ingest and render inbound voice messages 2026-02-23 12:21:53 +07:00
Ruslan Bakiev
c94c229a1a fix: avoid pilot sidebar trim crash on input prop 2026-02-23 12:03:51 +07:00
Ruslan Bakiev
43960d0374 feat(auth): enforce login route with global middleware 2026-02-23 12:01:03 +07:00
Ruslan Bakiev
5918a0593d fix(workspace): guard trim calls against undefined data 2026-02-23 11:51:55 +07:00
Ruslan Bakiev
8be6e7d581 refactor(workspace): extract communications sidebars 2026-02-23 11:44:53 +07:00
Ruslan Bakiev
82bc5dd04e refactor(frontend): extract calendar scene into workspace component 2026-02-23 11:35:57 +07:00
Ruslan Bakiev
d5f7280297 refactor(frontend): extract pilot sidebar into workspace component 2026-02-23 11:30:49 +07:00
Ruslan Bakiev
2b72d42956 refactor(frontend): split documents and review into workspace components 2026-02-23 11:22:05 +07:00
Ruslan Bakiev
47ed805ac7 refactor(frontend): extract auth and topbar workspace components 2026-02-23 11:15:29 +07:00
Ruslan Bakiev
e5030a321f refactor(nuxt): split CRM into page routes and workspace shell 2026-02-23 11:09:59 +07:00
Ruslan Bakiev
64b25bb189 refactor: flatten scheduler service to root schedulers dir 2026-02-23 11:04:47 +07:00
Ruslan Bakiev
6e40c96abd fix(chat-ui): move source visibility controls to contact row 2026-02-23 10:59:04 +07:00
Ruslan Bakiev
70369255a2 refactor: move timeline scheduler to isolated service 2026-02-23 10:58:55 +07:00
Ruslan Bakiev
2b5aab1210 calendar: keep zoom ladder inside month blocks on single scene 2026-02-23 10:57:51 +07:00
Ruslan Bakiev
f67cef22be feat: add dedicated calendar timeline scheduler service 2026-02-23 10:54:06 +07:00
Ruslan Bakiev
4b9682e447 feat: add unified client timeline query 2026-02-23 10:48:21 +07:00
Ruslan Bakiev
c9e4c3172e chore(frontend): trigger webhook redeploy after graphql asset fix 2026-02-23 10:42:40 +07:00
Ruslan Bakiev
95fd9a64ce feat(chat): add contact inbox sources with per-user hide filters 2026-02-23 10:41:02 +07:00
Ruslan Bakiev
6bc154a1e6 calendar: mask scene before level swap after zoom-in fill 2026-02-23 10:37:26 +07:00
Ruslan Bakiev
23d8035571 fix(frontend): resolve graphql imports from root in app dir 2026-02-23 10:08:35 +07:00
Ruslan Bakiev
21d6e440e3 chat: pin messages via context menu and align pinned bubble layout 2026-02-23 10:05:59 +07:00
Ruslan Bakiev
d4af315e2e chore(frontend): move nuxt ui source into app directory 2026-02-23 10:03:24 +07:00
Ruslan Bakiev
c41849745c refactor nuxt entry: move monolith app to pages/index 2026-02-23 09:59:38 +07:00
Ruslan Bakiev
9c712e0129 calendar: make zoom-out use the same camera panzoom pipeline 2026-02-23 09:58:03 +07:00
Ruslan Bakiev
8ef266e09d calendar: switch zoom-in to DOM camera panzoom flow 2026-02-23 09:55:45 +07:00
Ruslan Bakiev
43b487ccec refactor ai naming and make omni raw-json first 2026-02-23 09:32:59 +07:00
209 changed files with 4408 additions and 52836 deletions

1
.gitignore vendored
View File

@@ -10,3 +10,4 @@ coverage
npm-debug.log*
pnpm-lock.yaml
yarn.lock
frontend/server/generated

12
.gitmodules vendored
View File

@@ -1,3 +1,15 @@
[submodule "instructions"]
path = instructions
url = git@gitea.dsrptlab.com:dsrptlab/instructions.git
[submodule "frontend"]
path = frontend
url = git@gitea.dsrptlab.com:clientflow/frontend.git
branch = main
[submodule "backend"]
path = backend
url = git@gitea.dsrptlab.com:clientflow/backend.git
branch = main
[submodule "backend_worker"]
path = backend_worker
url = git@gitea.dsrptlab.com:clientflow/backend_worker.git
branch = main

1
backend Submodule

Submodule backend added at 42e9dc7bcb

1
backend_worker Submodule

Submodule backend_worker added at 653617983a

View File

@@ -2,7 +2,9 @@ version = 1
[services]
frontend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
omni_outbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
omni_inbound = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
omni_chat = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
langfuse = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "langfuse/docker-compose.yml" }
backend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
backend_worker = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
telegram_backend = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
telegram_worker = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }
hatchet = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "hatchet/docker-compose.yml" }
vault = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui" }

View File

@@ -1,141 +0,0 @@
services:
langfuse-worker:
image: docker.io/langfuse/langfuse-worker:3
restart: always
depends_on:
langfuse-postgres:
condition: service_healthy
langfuse-minio:
condition: service_healthy
langfuse-redis:
condition: service_healthy
langfuse-clickhouse:
condition: service_healthy
environment: &langfuse_env
NEXTAUTH_URL: "http://localhost:3001"
DATABASE_URL: "postgresql://langfuse:langfuse@langfuse-postgres:5432/langfuse"
SALT: "clientsflow-local-salt"
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
TELEMETRY_ENABLED: "false"
CLICKHOUSE_MIGRATION_URL: "clickhouse://langfuse-clickhouse:9000"
CLICKHOUSE_URL: "http://langfuse-clickhouse:8123"
CLICKHOUSE_USER: "clickhouse"
CLICKHOUSE_PASSWORD: "clickhouse"
CLICKHOUSE_CLUSTER_ENABLED: "false"
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: "langfuse"
LANGFUSE_S3_EVENT_UPLOAD_REGION: "auto"
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: "minio"
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "miniosecret"
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: "http://langfuse-minio:9000"
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: "events/"
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: "langfuse"
LANGFUSE_S3_MEDIA_UPLOAD_REGION: "auto"
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: "minio"
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "miniosecret"
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: "http://langfuse-minio:9000"
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: "media/"
REDIS_HOST: "langfuse-redis"
REDIS_PORT: "6379"
REDIS_AUTH: "langfuse-redis"
REDIS_TLS_ENABLED: "false"
langfuse-web:
image: docker.io/langfuse/langfuse:3
restart: always
depends_on:
langfuse-postgres:
condition: service_healthy
langfuse-minio:
condition: service_healthy
langfuse-redis:
condition: service_healthy
langfuse-clickhouse:
condition: service_healthy
expose:
- "3000"
environment:
<<: *langfuse_env
NEXTAUTH_SECRET: "clientsflow-local-nextauth-secret"
LANGFUSE_INIT_ORG_ID: "org-clientsflow"
LANGFUSE_INIT_ORG_NAME: "Clientsflow Local"
LANGFUSE_INIT_PROJECT_ID: "proj-clientsflow"
LANGFUSE_INIT_PROJECT_NAME: "clientsflow"
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "pk-lf-local"
LANGFUSE_INIT_PROJECT_SECRET_KEY: "sk-lf-local"
LANGFUSE_INIT_USER_EMAIL: "admin@clientsflow.local"
LANGFUSE_INIT_USER_NAME: "Local Admin"
LANGFUSE_INIT_USER_PASSWORD: "clientsflow-local-admin"
langfuse-clickhouse:
image: docker.io/clickhouse/clickhouse-server:latest
restart: always
user: "101:101"
environment:
CLICKHOUSE_DB: "default"
CLICKHOUSE_USER: "clickhouse"
CLICKHOUSE_PASSWORD: "clickhouse"
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
interval: 5s
timeout: 5s
retries: 20
start_period: 5s
langfuse-minio:
image: cgr.dev/chainguard/minio:latest
restart: always
entrypoint: sh
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
MINIO_ROOT_USER: "minio"
MINIO_ROOT_PASSWORD: "miniosecret"
volumes:
- langfuse_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 2s
timeout: 5s
retries: 15
start_period: 5s
langfuse-redis:
image: docker.io/redis:7-alpine
restart: always
command: ["redis-server", "--requirepass", "langfuse-redis", "--maxmemory-policy", "noeviction"]
healthcheck:
test: ["CMD-SHELL", "redis-cli -a langfuse-redis ping | grep PONG"]
interval: 3s
timeout: 5s
retries: 20
start_period: 5s
langfuse-postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: "langfuse"
POSTGRES_USER: "langfuse"
POSTGRES_PASSWORD: "langfuse"
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U langfuse -d langfuse"]
interval: 3s
timeout: 3s
retries: 20
start_period: 5s
volumes:
langfuse_postgres_data:
langfuse_clickhouse_data:
langfuse_clickhouse_logs:
langfuse_minio_data:
networks:
dokploy-network:
external: true

View File

@@ -1,104 +1,109 @@
# ADR-0001: Разделение Chat Platform на 3 сервиса
# ADR-0001: Chat Platform Boundaries (GraphQL + Hatchet)
Дата: 2026-02-21
Дата: 2026-03-08
Статус: accepted
## Контекст
Сейчас delivery уже вынесен отдельно, но часть omni-интеграции остается в приложении `frontend`.
Нужна архитектура, где входящие вебхуки, доменная логика чатов и исходящая доставка развиваются независимо и не ломают друг друга.
Нужна минимальная и предсказуемая схема из 6 сервисов:
Критичные требования:
- `frontend`
- `backend`
- `backend_worker`
- `telegram_backend`
- `telegram_worker`
- `hatchet`
- входящие webhook-события не теряются при рестартах;
- delivery управляет retry/rate-limit централизованно;
- omni_chat остается единственным местом доменной логики и хранения состояния диалогов;
- сервисы можно обновлять независимо.
Ключевые ограничения:
- основная Prisma/доменная БД только в `backend`;
- `telegram_backend` и `telegram_worker` не содержат CRM-домен и не пишут в основную БД;
- взаимодействие между сервисами только через GraphQL;
- асинхронность и ретраи централизованы в Hatchet.
## Решение
Принимаем разделение на 3 сервиса:
Принимаем архитектуру:
1. `omni_inbound`
- Принимает вебхуки провайдеров.
- Валидирует подпись/секрет.
- Нормализует событие в универсальный envelope.
- Пишет событие в durable queue (`receiver.flow`) с идемпотентным `jobId`.
- Возвращает `200` только после успешной durable enqueue.
- Не содержит бизнес-логики CRM.
1. `backend`
- владеет доменной моделью чатов и единственной основной Prisma-базой;
- принимает inbound события от `telegram_worker` через GraphQL (`ingestTelegramInbound`);
- создает outbound задачи в `telegram_backend` через GraphQL (`requestTelegramOutbound`);
- принимает delivery-отчеты от `telegram_worker` через GraphQL (`reportTelegramOutbound`).
2. `omni_chat`
- Потребляет входящие события из `receiver.flow`.
- Разрешает идентичности и треды.
- Создает/обновляет `OmniMessage`, `OmniThread`, статусы и доменные эффекты.
- Формирует исходящие команды и кладет их в `sender.flow`.
2. `telegram_backend`
- принимает webhook Telegram;
- нормализует payload в `OmniInboundEnvelopeV1`;
- ставит задачи в Hatchet (`process-telegram-inbound`, `process-telegram-outbound`);
- предоставляет GraphQL API для enqueue и отправки в Telegram API.
3. `omni_outbound`
- Потребляет `sender.flow`.
- Выполняет отправку в провайдеров (Telegram Business и др.).
- Управляет retry/backoff/failover, DLQ и статусами доставки.
- Не содержит UI и доменной логики чатов.
3. `telegram_worker`
- исполняет задачи Hatchet;
- для inbound вызывает `backend /graphql`;
- для outbound вызывает `telegram_backend /graphql` (`sendTelegramMessage`), затем `backend /graphql` (`reportTelegramOutbound`);
- не имеет собственной Prisma-базы.
## Почему webhook и delivery разделены
4. `backend_worker`
- исполняет периодические backend workflow в Hatchet;
- для cron-задач вызывает `backend /graphql` (без прямого доступа к Prisma).
- Входящий контур должен отвечать быстро и предсказуемо.
- Исходящий контур живет с долгими retry и ограничениями провайдера.
- Сбой внешнего API не должен блокировать прием входящих сообщений.
5. `hatchet`
- единый оркестратор задач, ретраев и backoff-политик.
## Потоки
### Inbound (Telegram -> CRM)
1. Telegram webhook приходит в `telegram_backend`.
2. `telegram_backend` нормализует событие и enqueue в Hatchet `process-telegram-inbound`.
3. `telegram_worker` исполняет задачу и вызывает `backend.ingestTelegramInbound`.
4. `backend` сохраняет доменные изменения в своей БД.
### Outbound (CRM -> Telegram)
1. `backend` инициирует отправку (`requestTelegramOutbound`) в `telegram_backend`.
2. `telegram_backend` enqueue в Hatchet `process-telegram-outbound`.
3. `telegram_worker` вызывает `telegram_backend.sendTelegramMessage`.
4. `telegram_worker` репортит итог в `backend.reportTelegramOutbound`.
### Calendar Predue (Backend cron)
1. Hatchet по cron запускает workflow в `backend_worker`.
2. `backend_worker` вызывает `backend.syncCalendarPredueTimeline`.
3. `backend` делает upsert `ClientTimelineEntry` для `CalendarEvent` в окне `startsAt - preDue`.
## Границы ответственности
`omni_inbound`:
`backend`:
- можно: вся бизнес-логика и состояние;
- нельзя: прямой вызов Telegram API.
- можно: auth, валидация, нормализация, дедуп, enqueue;
- нельзя: запись доменных сущностей CRM, принятие продуктовых решений.
`telegram_backend`:
- можно: webhook ingress, нормализация, enqueue, адаптер Telegram API;
- нельзя: доменные записи CRM.
`omni_chat`:
`telegram_worker`:
- можно: исполнение задач, ретраи, orchestration шагов;
- нельзя: хранение CRM-состояния и прямой доступ к основной БД.
- можно: вся доменная модель чатов, orchestration, бизнес-правила;
- нельзя: прямые вызовы провайдеров из sync API-контекста.
`backend_worker`:
- можно: периодические orchestration задачи через Hatchet;
- нельзя: прямой доступ к основной БД (только через backend GraphQL).
`omni_outbound`:
## Надежность
- можно: провайдерные адаптеры, retry, rate limits;
- нельзя: резолвинг бизнес-правил и маршрутизации диалога.
## Универсальный протокол событий
Внутренний контракт входящих событий: `docs/contracts/omni-inbound-envelope.v1.json`.
Обязательные поля:
- `version`
- `idempotencyKey`
- `provider`, `channel`, `direction`
- `providerEventId`, `providerMessageId`
- `eventType`, `occurredAt`, `receivedAt`
- `payloadRaw`, `payloadNormalized`
## Идемпотентность и надежность
- `jobId` в очереди строится из `idempotencyKey`.
- Дубликаты входящих webhook событий безопасны и возвращают `200`.
- `200` от `omni_inbound` отдается только после успешного добавления в Redis/BullMQ.
- При ошибке durable enqueue `omni_inbound` возвращает `5xx`, провайдер выполняет повторную доставку.
- Базовые рабочие очереди: `receiver.flow` и `sender.flow`; технические очереди для эскалации: `receiver.retry`, `sender.retry`, `receiver.dlq`, `sender.dlq`.
- webhook отвечает `200` только после успешной постановки задачи в Hatchet;
- при недоступности сервисов задача ретраится Hatchet;
- inbound обработка идемпотентна через `idempotencyKey` и provider identifiers в `backend`.
- календарный sync использует advisory-lock в `backend`, поэтому параллельные cron-run безопасны.
## Последствия
Плюсы:
- независимые релизы и масштабирование по ролям;
- меньше blast radius при инцидентах;
- проще подключать новые каналы поверх общего контракта.
- меньше скрытых связей;
- изоляция доменной БД в `backend`;
- единая точка ретраев/оркестрации (Hatchet).
Минусы:
- больше инфраструктурных компонентов (очереди, мониторинг, трассировка);
- требуется дисциплина по контрактам между сервисами.
## План внедрения
1. Вводим `omni_inbound` как отдельный сервис для Telegram Business.
2. Потребление `receiver.flow` реализуем в `omni_chat`.
3. Текущее исходящее API оставляем за `omni_outbound`.
4. После стабилизации выносим оставшиеся omni endpoint'ы из `frontend` в `omni_chat`/`omni_inbound`.
- выше требования к стабильности GraphQL-контрактов между сервисами;
- нужна наблюдаемость по цепочкам `telegram_backend -> hatchet -> telegram_worker -> backend` и `hatchet -> backend_worker -> backend`.

View File

@@ -3,18 +3,17 @@
## Single source of truth
- Canonical Prisma schema: `frontend/prisma/schema.prisma`.
- Service copies:
- `omni_chat/prisma/schema.prisma`
- `omni_outbound/prisma/schema.prisma`
- Service copy:
- `backend/prisma/schema.prisma`
## Update flow
1. Edit only `frontend/prisma/schema.prisma`.
2. Run `./scripts/prisma-sync.sh`.
3. Run `./scripts/prisma-check.sh`.
4. Commit changed schema copies.
4. Commit changed schema copy.
## Rollout policy
- Schema rollout (`prisma db push` / migrations) is allowed only in `frontend`.
- `omni_chat` and `omni_outbound` must use generated Prisma client only.
- `backend` must use generated Prisma client only.

1
frontend Submodule

Submodule frontend added at 7656cf5f44

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,33 +0,0 @@
FROM node:22-bookworm-slim
WORKDIR /app/frontend
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --ignore-scripts --legacy-peer-deps
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
if [ "$arch" = "amd64" ]; then \
npm rebuild sharp --platform=linux --arch=x64 || npm install --no-save sharp --platform=linux --arch=x64; \
elif [ "$arch" = "arm64" ]; then \
npm rebuild sharp --platform=linux --arch=arm64 || npm install --no-save sharp --platform=linux --arch=arm64; \
else \
npm rebuild sharp || true; \
fi
COPY . .
# Build server bundle at image build time.
RUN npm run postinstall && npm run build
ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
EXPOSE 3000
# Keep schema in sync, then start Nitro production server.
CMD ["bash", "-lc", "npx prisma db push && node .output/server/index.mjs"]

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +0,0 @@
export const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
export function buildContactDocumentScope(contactId: string, contactName: string) {
return `${CONTACT_DOCUMENT_SCOPE_PREFIX}${encodeURIComponent(contactId)}:${encodeURIComponent(contactName)}`;
}
export function parseContactDocumentScope(scope: string) {
const raw = String(scope ?? "").trim();
if (!raw.startsWith(CONTACT_DOCUMENT_SCOPE_PREFIX)) return null;
const payload = raw.slice(CONTACT_DOCUMENT_SCOPE_PREFIX.length);
const [idRaw, ...nameParts] = payload.split(":");
const contactId = decodeURIComponent(idRaw ?? "").trim();
const contactName = decodeURIComponent(nameParts.join(":") ?? "").trim();
if (!contactId) return null;
return {
contactId,
contactName,
};
}
export function formatDocumentScope(scope: string) {
const linked = parseContactDocumentScope(scope);
if (!linked) return scope;
return linked.contactName ? `Contact · ${linked.contactName}` : "Contact document";
}
export function isDocumentLinkedToContact(
scope: string,
contact: { id: string; name: string } | null | undefined,
) {
if (!contact) return false;
const linked = parseContactDocumentScope(scope);
if (!linked) return false;
if (linked.contactId) return linked.contactId === contact.id;
return Boolean(linked.contactName && linked.contactName === contact.name);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
mutation CreateWorkspaceDocument($input: CreateWorkspaceDocumentInput!) {
createWorkspaceDocument(input: $input) {
id
title
type
owner
scope
updatedAt
summary
body
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
mutation RollbackChangeSetItemsMutation($changeSetId: ID!, $itemIds: [ID!]!) {
rollbackChangeSetItems(changeSetId: $changeSetId, itemIds: $itemIds) {
ok
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

23266
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,392 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum TeamRole {
OWNER
MEMBER
}
enum MessageDirection {
IN
OUT
}
enum MessageChannel {
TELEGRAM
WHATSAPP
INSTAGRAM
PHONE
EMAIL
INTERNAL
}
enum ContactMessageKind {
MESSAGE
CALL
}
enum ChatRole {
USER
ASSISTANT
SYSTEM
}
enum OmniMessageStatus {
PENDING
SENT
FAILED
DELIVERED
READ
}
enum FeedCardDecision {
PENDING
ACCEPTED
REJECTED
}
enum WorkspaceDocumentType {
Regulation
Playbook
Policy
Template
}
model Team {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members TeamMember[]
contacts Contact[]
calendarEvents CalendarEvent[]
deals Deal[]
conversations ChatConversation[]
chatMessages ChatMessage[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
telegramBusinessConnections TelegramBusinessConnection[]
feedCards FeedCard[]
contactPins ContactPin[]
documents WorkspaceDocument[]
}
model User {
id String @id @default(cuid())
phone String @unique
passwordHash String
email String? @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TeamMember[]
conversations ChatConversation[] @relation("ConversationCreator")
chatMessages ChatMessage[] @relation("ChatAuthor")
}
model TeamMember {
id String @id @default(cuid())
teamId String
userId String
role TeamRole @default(MEMBER)
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId])
@@index([userId])
}
model Contact {
id String @id @default(cuid())
teamId String
name String
company String?
country String?
location String?
avatarUrl String?
email String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
note ContactNote?
messages ContactMessage[]
events CalendarEvent[]
deals Deal[]
feedCards FeedCard[]
pins ContactPin[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
@@index([teamId, updatedAt])
}
model ContactNote {
id String @id @default(cuid())
contactId String @unique
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
}
model ContactMessage {
id String @id @default(cuid())
contactId String
kind ContactMessageKind @default(MESSAGE)
direction MessageDirection
channel MessageChannel
content String
audioUrl String?
durationSec Int?
transcriptJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([contactId, occurredAt])
}
model OmniContactIdentity {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([teamId, channel, externalId])
@@index([contactId])
@@index([teamId, updatedAt])
}
model OmniThread {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalChatId String
businessConnectionId String?
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
messages OmniMessage[]
@@unique([teamId, channel, externalChatId, businessConnectionId])
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model OmniMessage {
id String @id @default(cuid())
teamId String
contactId String
threadId String
direction MessageDirection
channel MessageChannel
status OmniMessageStatus @default(PENDING)
text String
providerMessageId String?
providerUpdateId String?
rawJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@unique([threadId, providerMessageId])
@@index([teamId, occurredAt])
@@index([threadId, occurredAt])
}
model TelegramBusinessConnection {
id String @id @default(cuid())
teamId String
businessConnectionId String
isEnabled Boolean?
canReply Boolean?
rawJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, businessConnectionId])
@@index([teamId, updatedAt])
}
model CalendarEvent {
id String @id @default(cuid())
teamId String
contactId String?
title String
startsAt DateTime
endsAt DateTime?
note String?
isArchived Boolean @default(false)
archiveNote String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([startsAt])
@@index([contactId, startsAt])
@@index([teamId, startsAt])
}
model Deal {
id String @id @default(cuid())
teamId String
contactId String
title String
stage String
amount Int?
nextStep String?
summary String?
currentStepId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
steps DealStep[]
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
@@index([currentStepId])
}
model DealStep {
id String @id @default(cuid())
dealId String
title String
description String?
status String @default("todo")
dueAt DateTime?
order Int @default(0)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
@@index([dealId, order])
@@index([status, dueAt])
}
model ChatConversation {
id String @id @default(cuid())
teamId String
createdByUserId String
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
messages ChatMessage[]
@@index([teamId, updatedAt])
@@index([createdByUserId])
}
model ChatMessage {
id String @id @default(cuid())
teamId String
conversationId String
authorUserId String?
role ChatRole
text String
planJson Json?
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@index([teamId, createdAt])
@@index([conversationId, createdAt])
}
model FeedCard {
id String @id @default(cuid())
teamId String
contactId String?
happenedAt DateTime
text String
proposalJson Json
decision FeedCardDecision @default(PENDING)
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([teamId, happenedAt])
@@index([contactId, happenedAt])
}
model ContactPin {
id String @id @default(cuid())
teamId String
contactId String
text String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model WorkspaceDocument {
id String @id @default(cuid())
teamId String
title String
type WorkspaceDocumentType
owner String
scope String
summary String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,79 +0,0 @@
import { readBody } from "h3";
import { prisma } from "../../../../../utils/prisma";
type CompleteBody = {
token?: string;
};
export default defineEventHandler(async (event) => {
const body = await readBody<CompleteBody>(event);
const token = String(body?.token ?? "").trim();
if (!token) {
throw createError({ statusCode: 400, statusMessage: "token is required" });
}
const pendingId = `pending:${token}`;
const pending = await prisma.telegramBusinessConnection.findFirst({
where: {
businessConnectionId: pendingId,
},
});
if (!pending) {
return { ok: false, status: "session_not_found" };
}
const raw = (pending.rawJson ?? {}) as any;
const exp = Number(raw?.link?.exp ?? 0);
if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) {
return { ok: false, status: "invalid_or_expired_token" };
}
const telegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId).trim() : "";
if (!telegramUserId) {
return { ok: false, status: "awaiting_telegram_start" };
}
const linkedConnectionId = `link:${telegramUserId}`;
await prisma.$transaction([
prisma.telegramBusinessConnection.upsert({
where: {
teamId_businessConnectionId: {
teamId: pending.teamId,
businessConnectionId: linkedConnectionId,
},
},
create: {
teamId: pending.teamId,
businessConnectionId: linkedConnectionId,
isEnabled: true,
canReply: true,
rawJson: {
state: "connected",
mode: "token_link",
linkedAt: new Date().toISOString(),
telegramUserId,
tokenNonce: token,
},
},
update: {
isEnabled: true,
canReply: true,
rawJson: {
state: "connected",
mode: "token_link",
linkedAt: new Date().toISOString(),
telegramUserId,
tokenNonce: token,
},
},
}),
prisma.telegramBusinessConnection.delete({ where: { id: pending.id } }),
]);
return {
ok: true,
status: "connected",
businessConnectionId: linkedConnectionId,
};
});

View File

@@ -1,71 +0,0 @@
import { readBody } from "h3";
import { getAuthContext } from "../../../../../utils/auth";
import { prisma } from "../../../../../utils/prisma";
import { telegramBotApi } from "../../../../../utils/telegram";
type RefreshBody = {
businessConnectionId?: string;
};
function mapFlags(raw: any) {
const isEnabled = typeof raw?.is_enabled === "boolean" ? raw.is_enabled : null;
const canReply = typeof raw?.can_reply === "boolean"
? raw.can_reply
: typeof raw?.rights?.can_reply === "boolean"
? raw.rights.can_reply
: null;
return { isEnabled, canReply };
}
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const body = await readBody<RefreshBody>(event);
const businessConnectionId = String(body?.businessConnectionId ?? "").trim();
if (!businessConnectionId) {
throw createError({ statusCode: 400, statusMessage: "businessConnectionId is required" });
}
const existing = await prisma.telegramBusinessConnection.findFirst({
where: {
teamId: auth.teamId,
businessConnectionId,
},
select: { id: true },
});
if (!existing) {
throw createError({ statusCode: 404, statusMessage: "business connection not found" });
}
const response = await telegramBotApi<any>("getBusinessConnection", { business_connection_id: businessConnectionId });
const { isEnabled, canReply } = mapFlags(response);
const updated = await prisma.telegramBusinessConnection.update({
where: { id: existing.id },
data: {
isEnabled,
canReply,
rawJson: {
state: "connected",
refreshedAt: new Date().toISOString(),
businessConnection: response,
},
},
select: {
businessConnectionId: true,
isEnabled: true,
canReply: true,
updatedAt: true,
},
});
return {
ok: true,
connection: {
businessConnectionId: updated.businessConnectionId,
isEnabled: updated.isEnabled,
canReply: updated.canReply,
updatedAt: updated.updatedAt.toISOString(),
},
};
});

View File

@@ -1,51 +0,0 @@
import { getAuthContext } from "../../../../../utils/auth";
import { prisma } from "../../../../../utils/prisma";
import { buildTelegramStartUrl, issueLinkToken } from "../../../../../utils/telegramBusinessConnect";
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const { token, payload } = issueLinkToken({ teamId: auth.teamId, userId: auth.userId });
const pendingId = `pending:${payload.nonce}`;
await prisma.telegramBusinessConnection.upsert({
where: {
teamId_businessConnectionId: {
teamId: auth.teamId,
businessConnectionId: pendingId,
},
},
create: {
teamId: auth.teamId,
businessConnectionId: pendingId,
rawJson: {
state: "pending_link",
link: {
nonce: payload.nonce,
exp: payload.exp,
createdAt: new Date().toISOString(),
createdByUserId: auth.userId,
},
},
},
update: {
isEnabled: null,
canReply: null,
rawJson: {
state: "pending_link",
link: {
nonce: payload.nonce,
exp: payload.exp,
createdAt: new Date().toISOString(),
createdByUserId: auth.userId,
},
},
},
});
return {
ok: true,
status: "pending_link",
connectUrl: buildTelegramStartUrl(token),
expiresAt: new Date(payload.exp * 1000).toISOString(),
};
});

View File

@@ -1,60 +0,0 @@
import { getAuthContext } from "../../../../../utils/auth";
import { prisma } from "../../../../../utils/prisma";
function normalizeStatus(input: {
pendingCount: number;
linkedPendingCount: number;
connectedCount: number;
enabledCount: number;
replyEnabledCount: number;
}) {
if (input.connectedCount > 0) {
if (input.replyEnabledCount > 0 && input.enabledCount > 0) return "connected";
if (input.enabledCount === 0) return "disabled";
return "no_reply_rights";
}
if (input.linkedPendingCount > 0) return "pending_business_connection";
if (input.pendingCount > 0) return "pending_link";
return "not_connected";
}
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const rows = await prisma.telegramBusinessConnection.findMany({
where: { teamId: auth.teamId },
orderBy: { updatedAt: "desc" },
take: 50,
});
const pending = rows.filter((r) => r.businessConnectionId.startsWith("pending:"));
const active = rows.filter((r) => !r.businessConnectionId.startsWith("pending:"));
const linkedPendingCount = pending.filter((r) => {
const raw = (r.rawJson ?? {}) as any;
return Boolean(raw?.link?.telegramUserId || raw?.link?.chatId);
}).length;
const enabledCount = active.filter((r) => r.isEnabled !== false).length;
const replyEnabledCount = active.filter((r) => r.canReply === true).length;
const status = normalizeStatus({
pendingCount: pending.length,
linkedPendingCount,
connectedCount: active.length,
enabledCount,
replyEnabledCount,
});
return {
ok: true,
status,
pendingCount: pending.length,
connectedCount: active.length,
connections: active.map((r) => ({
businessConnectionId: r.businessConnectionId,
isEnabled: r.isEnabled,
canReply: r.canReply,
updatedAt: r.updatedAt.toISOString(),
})),
};
});

View File

@@ -1,212 +0,0 @@
import { getHeader, readBody } from "h3";
import { prisma } from "../../../../utils/prisma";
import { telegramBotApi } from "../../../../utils/telegram";
import {
extractLinkTokenFromStartText,
getBusinessConnectionFromUpdate,
getTelegramChatIdFromUpdate,
} from "../../../../utils/telegramBusinessConnect";
function hasValidSecret(event: any) {
const expected = String(process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
if (!expected) return true;
const incoming = String(getHeader(event, "x-telegram-bot-api-secret-token") || "").trim();
return incoming !== "" && incoming === expected;
}
function pickStartText(update: any): string | null {
const text =
update?.message?.text ??
update?.business_message?.text ??
update?.edited_business_message?.text ??
null;
if (typeof text !== "string") return null;
return text;
}
function crmConnectUrl() {
return String(process.env.CRM_APP_URL || "https://clientsflow.dsrptlab.com").trim();
}
function crmConnectButton(linkToken?: string) {
const base = crmConnectUrl();
let target = base;
if (linkToken) {
try {
const u = new URL(base);
u.searchParams.set("tg_link_token", linkToken);
target = u.toString();
} catch {
target = `${base}${base.includes("?") ? "&" : "?"}tg_link_token=${encodeURIComponent(linkToken)}`;
}
}
return {
inline_keyboard: [
[
{
text: "Открыть CRM и подтвердить",
url: target,
},
],
],
};
}
export default defineEventHandler(async (event) => {
if (!hasValidSecret(event)) {
throw createError({ statusCode: 401, statusMessage: "invalid webhook secret" });
}
const update = await readBody<any>(event);
const nowIso = new Date().toISOString();
const startText = pickStartText(update);
const linkToken = startText ? extractLinkTokenFromStartText(startText) : null;
const startChatId = getTelegramChatIdFromUpdate(update);
if (startText && !linkToken) {
if (startChatId) {
void telegramBotApi("sendMessage", {
chat_id: startChatId,
text: "Чтобы привязать Telegram Business к CRM, открой CRM → Settings → Telegram Business → Connect. Кнопка сгенерирует персональную ссылку привязки.",
reply_markup: crmConnectButton(),
}).catch(() => {});
}
return { ok: true, accepted: true, type: "start_without_link_token" };
}
if (linkToken) {
const pendingId = `pending:${linkToken}`;
const pending = await prisma.telegramBusinessConnection.findFirst({
where: {
businessConnectionId: pendingId,
},
});
if (!pending) {
if (startChatId) {
void telegramBotApi("sendMessage", {
chat_id: startChatId,
text: "Ссылка привязки недействительна или истекла. Вернись в CRM и нажми Connect заново.",
reply_markup: crmConnectButton(),
}).catch(() => {});
}
return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" };
}
const rawPending = (pending.rawJson ?? {}) as any;
const exp = Number(rawPending?.link?.exp ?? 0);
if (Number.isFinite(exp) && exp > 0 && Math.floor(Date.now() / 1000) > exp) {
if (startChatId) {
void telegramBotApi("sendMessage", {
chat_id: startChatId,
text: "Ссылка привязки истекла. Вернись в CRM и нажми Connect заново.",
reply_markup: crmConnectButton(),
}).catch(() => {});
}
return { ok: true, accepted: false, reason: "invalid_or_expired_link_token" };
}
const chatId = startChatId;
await prisma.telegramBusinessConnection.updateMany({
where: {
teamId: pending.teamId,
businessConnectionId: pendingId,
},
data: {
rawJson: {
state: "pending_business_connection",
link: {
...(rawPending?.link ?? {}),
linkedAt: nowIso,
telegramUserId: chatId,
chatId,
},
lastStartUpdate: update,
},
},
});
if (chatId) {
void telegramBotApi("sendMessage", {
chat_id: chatId,
text: "CRM: связка аккаунта получена. Нажми кнопку ниже и вернись в CRM для подтверждения.",
reply_markup: crmConnectButton(linkToken),
}).catch(() => {});
}
return { ok: true, accepted: true, type: "start_link" };
}
const businessConnection = getBusinessConnectionFromUpdate(update);
if (businessConnection) {
const pendingRows = await prisma.telegramBusinessConnection.findMany({
where: {
businessConnectionId: {
startsWith: "pending:",
},
},
orderBy: { updatedAt: "desc" },
take: 200,
});
const matchedPending = pendingRows.find((row) => {
const raw = (row.rawJson ?? {}) as any;
const linkedTelegramUserId = raw?.link?.telegramUserId != null ? String(raw.link.telegramUserId) : null;
if (!businessConnection.userChatId) return false;
return linkedTelegramUserId === businessConnection.userChatId;
});
if (!matchedPending) {
return { ok: true, accepted: false, reason: "team_not_linked_for_business_connection" };
}
await prisma.$transaction([
prisma.telegramBusinessConnection.upsert({
where: {
teamId_businessConnectionId: {
teamId: matchedPending.teamId,
businessConnectionId: businessConnection.id,
},
},
create: {
teamId: matchedPending.teamId,
businessConnectionId: businessConnection.id,
isEnabled: businessConnection.isEnabled,
canReply: businessConnection.canReply,
rawJson: {
state: "connected",
connectedAt: nowIso,
userChatId: businessConnection.userChatId,
businessConnection: businessConnection.raw,
update,
},
},
update: {
isEnabled: businessConnection.isEnabled,
canReply: businessConnection.canReply,
rawJson: {
state: "connected",
connectedAt: nowIso,
userChatId: businessConnection.userChatId,
businessConnection: businessConnection.raw,
update,
},
},
}),
prisma.telegramBusinessConnection.delete({ where: { id: matchedPending.id } }),
]);
if (businessConnection.userChatId) {
void telegramBotApi("sendMessage", {
chat_id: businessConnection.userChatId,
text: "CRM: Telegram Business подключен. Теперь входящие сообщения будут появляться в CRM.",
}).catch(() => {});
}
return { ok: true, accepted: true, type: "business_connection" };
}
return { ok: true, accepted: true, type: "ignored" };
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
import { OUTBOUND_DELIVERY_QUEUE_NAME, startOutboundDeliveryWorker } from "./outboundDelivery";
import { prisma } from "../utils/prisma";
import { getRedis } from "../utils/redis";
const worker = startOutboundDeliveryWorker();
console.log(`[omni_outbound(legacy-in-frontend)] started queue ${OUTBOUND_DELIVERY_QUEUE_NAME}`);
async function shutdown(signal: string) {
console.log(`[omni_outbound(legacy-in-frontend)] shutting down by ${signal}`);
try {
await worker.close();
} catch {
// ignore shutdown errors
}
try {
const redis = getRedis();
await redis.quit();
} catch {
// ignore shutdown errors
}
try {
await prisma.$disconnect();
} catch {
// ignore shutdown errors
}
process.exit(0);
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});

View File

@@ -1,165 +0,0 @@
import { prisma } from "../../utils/prisma";
const COOKIE_USER = "cf_user";
const COOKIE_TEAM = "cf_team";
const COOKIE_CONV = "cf_conv";
const TEAM_POLL_INTERVAL_MS = 2000;
const peersByTeam = new Map<string, Set<any>>();
const peerTeamById = new Map<string, string>();
const lastSignatureByTeam = new Map<string, string>();
let pollTimer: ReturnType<typeof setInterval> | null = null;
function parseCookies(raw: string | null) {
const out = new Map<string, string>();
for (const part of String(raw ?? "").split(";")) {
const [key, ...rest] = part.trim().split("=");
if (!key) continue;
const value = rest.join("=");
try {
out.set(key, decodeURIComponent(value));
} catch {
out.set(key, value);
}
}
return out;
}
function attachPeerToTeam(peer: any, teamId: string) {
if (!peersByTeam.has(teamId)) peersByTeam.set(teamId, new Set());
peersByTeam.get(teamId)?.add(peer);
peerTeamById.set(String(peer.id), teamId);
}
function detachPeer(peer: any) {
const key = String(peer.id);
const teamId = peerTeamById.get(key);
if (!teamId) return;
peerTeamById.delete(key);
const peers = peersByTeam.get(teamId);
if (!peers) return;
peers.delete(peer);
if (peers.size === 0) {
peersByTeam.delete(teamId);
lastSignatureByTeam.delete(teamId);
}
}
function stopPollIfIdle() {
if (peersByTeam.size > 0 || !pollTimer) return;
clearInterval(pollTimer);
pollTimer = null;
}
async function validateSessionFromPeer(peer: any) {
const cookieHeader = peer?.request?.headers?.get?.("cookie") ?? null;
const cookies = parseCookies(cookieHeader);
const userId = String(cookies.get(COOKIE_USER) ?? "").trim();
const teamId = String(cookies.get(COOKIE_TEAM) ?? "").trim();
const conversationId = String(cookies.get(COOKIE_CONV) ?? "").trim();
if (!userId || !teamId || !conversationId) return null;
const [user, team, conv] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { id: true } }),
prisma.team.findUnique({ where: { id: teamId }, select: { id: true } }),
prisma.chatConversation.findFirst({
where: { id: conversationId, teamId, createdByUserId: userId },
select: { id: true },
}),
]);
if (!user || !team || !conv) return null;
return { teamId };
}
async function computeTeamSignature(teamId: string) {
const [omniMessageMax, contactMax, contactMessageMax, telegramConnectionMax] = await Promise.all([
prisma.omniMessage.aggregate({
where: { teamId },
_max: { updatedAt: true },
}),
prisma.contact.aggregate({
where: { teamId },
_max: { updatedAt: true },
}),
prisma.contactMessage.aggregate({
where: { contact: { teamId } },
_max: { createdAt: true },
}),
prisma.telegramBusinessConnection.aggregate({
where: { teamId },
_max: { updatedAt: true },
}),
]);
return [
omniMessageMax._max.updatedAt?.toISOString() ?? "",
contactMax._max.updatedAt?.toISOString() ?? "",
contactMessageMax._max.createdAt?.toISOString() ?? "",
telegramConnectionMax._max.updatedAt?.toISOString() ?? "",
].join("|");
}
function sendJson(peer: any, payload: Record<string, unknown>) {
try {
peer.send(JSON.stringify(payload));
} catch {
// ignore socket write errors
}
}
async function pollAndBroadcast() {
for (const [teamId, peers] of peersByTeam.entries()) {
if (!peers.size) continue;
const signature = await computeTeamSignature(teamId);
const previous = lastSignatureByTeam.get(teamId);
if (signature === previous) continue;
lastSignatureByTeam.set(teamId, signature);
const payload = {
type: "dashboard.changed",
teamId,
at: new Date().toISOString(),
};
for (const peer of peers) {
sendJson(peer, payload);
}
}
}
function ensurePoll() {
if (pollTimer) return;
pollTimer = setInterval(() => {
void pollAndBroadcast();
}, TEAM_POLL_INTERVAL_MS);
}
export default defineWebSocketHandler({
async open(peer) {
const session = await validateSessionFromPeer(peer);
if (!session) {
peer.close(4401, "Unauthorized");
return;
}
attachPeerToTeam(peer, session.teamId);
ensurePoll();
sendJson(peer, { type: "realtime.connected", at: new Date().toISOString() });
void pollAndBroadcast();
},
close(peer) {
detachPeer(peer);
stopPollIfIdle();
},
error(peer) {
detachPeer(peer);
stopPollIfIdle();
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,99 +0,0 @@
import { randomBytes } from "node:crypto";
export type LinkTokenPayloadV1 = {
v: 1;
nonce: string;
exp: number;
};
const TOKEN_TTL_SEC = Number(process.env.TELEGRAM_LINK_TOKEN_TTL_SEC || 10 * 60);
export function requireBotUsername() {
const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, "");
if (!botUsername) {
throw createError({ statusCode: 500, statusMessage: "TELEGRAM_BOT_USERNAME is required" });
}
return botUsername;
}
export function issueLinkToken(input: { teamId: string; userId: string }) {
void input;
// Telegram deep-link `start` parameter is limited, keep token very short.
const token = randomBytes(12).toString("hex");
const payload: LinkTokenPayloadV1 = {
v: 1,
nonce: token,
exp: Math.floor(Date.now() / 1000) + Math.max(60, TOKEN_TTL_SEC),
};
return { token, payload };
}
export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null {
const raw = String(token || "").trim();
if (!/^[a-f0-9]{24}$/.test(raw)) return null;
return {
v: 1,
nonce: raw,
exp: 0,
};
}
export function extractLinkTokenFromStartText(text: string) {
const trimmed = String(text || "").trim();
if (!trimmed.startsWith("/start")) return null;
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length < 2) return null;
const arg = parts[1] || "";
if (!arg.startsWith("link_")) return null;
return arg.slice("link_".length);
}
export function buildTelegramStartUrl(token: string) {
const botUsername = requireBotUsername();
return `https://t.me/${botUsername}?start=link_${token}`;
}
export function getTelegramChatIdFromUpdate(update: any): string | null {
const candidates = [
update?.message?.chat?.id,
update?.business_message?.chat?.id,
update?.edited_business_message?.chat?.id,
update?.business_connection?.user_chat_id,
];
for (const c of candidates) {
if (c == null) continue;
const v = String(c).trim();
if (v) return v;
}
return null;
}
export function getBusinessConnectionFromUpdate(update: any): {
id: string;
userChatId: string | null;
isEnabled: boolean | null;
canReply: boolean | null;
raw: any;
} | null {
const bc = (update?.business_connection ?? null) as any;
if (!bc || typeof bc !== "object") return null;
const id = String(bc.id ?? "").trim();
if (!id) return null;
const userChatId = bc.user_chat_id != null ? String(bc.user_chat_id) : null;
const isEnabled = typeof bc.is_enabled === "boolean" ? bc.is_enabled : null;
const canReply = typeof bc.can_reply === "boolean"
? bc.can_reply
: typeof bc.rights?.can_reply === "boolean"
? bc.rights.can_reply
: null;
return {
id,
userChatId,
isEnabled,
canReply,
raw: bc,
};
}

View File

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

View File

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

34
hatchet/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Hatchet (Dokploy)
Compose-стек для self-hosted Hatchet, перенесенный из соседнего проекта `gl` и адаптированный под ENV.
## Файлы
- `docker-compose.yml` — сервисы Hatchet (Postgres, RabbitMQ, migration, setup-config, engine, dashboard).
## Обязательные ENV (Dokploy UI)
- `HATCHET_POSTGRES_USER` (default: `hatchet`)
- `HATCHET_POSTGRES_PASSWORD` (default: `hatchet`)
- `HATCHET_POSTGRES_DB` (default: `hatchet`)
- `HATCHET_DATABASE_URL` (default: `postgres://hatchet:hatchet@postgres:5432/hatchet`)
- `HATCHET_RABBITMQ_USER` (default: `user`)
- `HATCHET_RABBITMQ_PASSWORD` (default: `password`)
- `HATCHET_RABBITMQ_URL` (default: `amqp://user:password@rabbitmq:5672/`)
- `HATCHET_SERVER_AUTH_COOKIE_DOMAIN` (например, `hatchet.<ваш-домен>`)
- `HATCHET_SERVER_AUTH_COOKIE_INSECURE` (`t`/`f`)
- `HATCHET_SERVER_GRPC_INSECURE` (`t`/`f`)
- `HATCHET_SERVER_GRPC_BROADCAST_ADDRESS` (например, `hatchet-engine:7070` внутри сети)
## ENV для приложений-воркеров (Node SDK)
- `HATCHET_CLIENT_TOKEN` — токен клиента из Hatchet.
- `HATCHET_CLIENT_TLS_STRATEGY` — для self-host без TLS: `none`.
- `HATCHET_CLIENT_HOST_PORT` — gRPC адрес (например, `hatchet-engine:7070` в одной Docker-сети).
- `HATCHET_CLIENT_API_URL` — URL API Hatchet dashboard/api.
## Развертывание
Сервис описан в `deploy-map.toml` как:
`hatchet = { deploy_mode = "dokploy_webhook", env_storage = "dokploy_ui", compose_path = "hatchet/docker-compose.yml" }`

121
hatchet/docker-compose.yml Normal file
View File

@@ -0,0 +1,121 @@
services:
postgres:
image: postgres:15.6
command: postgres -c "max_connections=1000"
restart: always
hostname: postgres
environment:
POSTGRES_USER: ${HATCHET_POSTGRES_USER:-hatchet}
POSTGRES_PASSWORD: ${HATCHET_POSTGRES_PASSWORD:-hatchet}
POSTGRES_DB: ${HATCHET_POSTGRES_DB:-hatchet}
expose:
- "5432"
volumes:
- hatchet_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -d ${HATCHET_POSTGRES_DB:-hatchet} -U ${HATCHET_POSTGRES_USER:-hatchet}"]
interval: 10s
timeout: 10s
retries: 5
start_period: 10s
rabbitmq:
image: rabbitmq:3-management
hostname: rabbitmq
expose:
- "5672"
- "15672"
environment:
RABBITMQ_DEFAULT_USER: ${HATCHET_RABBITMQ_USER:-user}
RABBITMQ_DEFAULT_PASS: ${HATCHET_RABBITMQ_PASSWORD:-password}
volumes:
- hatchet_rabbitmq_data:/var/lib/rabbitmq
- hatchet_rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf
healthcheck:
test: ["CMD", "rabbitmqctl", "status"]
interval: 10s
timeout: 10s
retries: 5
migration:
image: ghcr.io/hatchet-dev/hatchet/hatchet-migrate:latest
command: /hatchet/hatchet-migrate
environment:
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
depends_on:
postgres:
condition: service_healthy
setup-config:
image: ghcr.io/hatchet-dev/hatchet/hatchet-admin:latest
command: /hatchet/hatchet-admin quickstart --skip certs --generated-config-dir /hatchet/config --overwrite=false
environment:
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
SERVER_MSGQUEUE_RABBITMQ_URL: ${HATCHET_RABBITMQ_URL:-amqp://user:password@rabbitmq:5672/}
SERVER_AUTH_COOKIE_DOMAIN: ${HATCHET_SERVER_AUTH_COOKIE_DOMAIN:-hatchet.local}
SERVER_AUTH_COOKIE_INSECURE: ${HATCHET_SERVER_AUTH_COOKIE_INSECURE:-t}
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
SERVER_GRPC_BROADCAST_ADDRESS: ${HATCHET_SERVER_GRPC_BROADCAST_ADDRESS:-hatchet-engine:7070}
SERVER_DEFAULT_ENGINE_VERSION: V1
SERVER_INTERNAL_CLIENT_INTERNAL_GRPC_BROADCAST_ADDRESS: hatchet-engine:7070
volumes:
- hatchet_certs:/hatchet/certs
- hatchet_config:/hatchet/config
depends_on:
migration:
condition: service_completed_successfully
rabbitmq:
condition: service_healthy
postgres:
condition: service_healthy
hatchet-engine:
image: ghcr.io/hatchet-dev/hatchet/hatchet-engine:latest
command: /hatchet/hatchet-engine --config /hatchet/config
restart: on-failure
depends_on:
setup-config:
condition: service_completed_successfully
migration:
condition: service_completed_successfully
expose:
- "7070"
environment:
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
SERVER_GRPC_BIND_ADDRESS: 0.0.0.0
SERVER_GRPC_INSECURE: ${HATCHET_SERVER_GRPC_INSECURE:-t}
volumes:
- hatchet_certs:/hatchet/certs
- hatchet_config:/hatchet/config
networks:
- default
- dokploy-network
hatchet-dashboard:
image: ghcr.io/hatchet-dev/hatchet/hatchet-dashboard:latest
command: sh ./entrypoint.sh --config /hatchet/config
expose:
- "80"
restart: on-failure
depends_on:
setup-config:
condition: service_completed_successfully
migration:
condition: service_completed_successfully
environment:
DATABASE_URL: ${HATCHET_DATABASE_URL:-postgres://hatchet:hatchet@postgres:5432/hatchet}
volumes:
- hatchet_certs:/hatchet/certs
- hatchet_config:/hatchet/config
networks:
dokploy-network:
external: true
volumes:
hatchet_postgres_data:
hatchet_rabbitmq_data:
hatchet_rabbitmq.conf:
hatchet_config:
hatchet_certs:

View File

@@ -1,11 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
COPY . .
CMD ["npm", "run", "start"]

View File

@@ -1,27 +0,0 @@
# omni_chat
Изолированный сервис chat-core (домен диалогов).
## Назначение
- потребляет входящие события из `receiver.flow`;
- применяет бизнес-логику диалогов;
- публикует исходящие команды в `sender.flow`.
Текущий шаг: выделен отдельный сервисный контур и health endpoint.
## API
- `GET /health`
## Переменные окружения
- `PORT` (default: `8090`)
- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`)
- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`)
## Prisma policy
- Источник схемы: `frontend/prisma/schema.prisma`.
- Локальная копия в `omni_chat/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
- Миграции/`db push` выполняются только в `frontend`.

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
{
"name": "crm-omni-chat",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit",
"postinstall": "node ./node_modules/prisma/build/index.js generate --schema ./prisma/schema.prisma"
},
"devDependencies": {
"@types/node": "^22.13.9",
"prisma": "^6.16.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"dependencies": {
"@prisma/client": "^6.16.1",
"bullmq": "^5.70.0"
}
}

View File

@@ -1,392 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum TeamRole {
OWNER
MEMBER
}
enum MessageDirection {
IN
OUT
}
enum MessageChannel {
TELEGRAM
WHATSAPP
INSTAGRAM
PHONE
EMAIL
INTERNAL
}
enum ContactMessageKind {
MESSAGE
CALL
}
enum ChatRole {
USER
ASSISTANT
SYSTEM
}
enum OmniMessageStatus {
PENDING
SENT
FAILED
DELIVERED
READ
}
enum FeedCardDecision {
PENDING
ACCEPTED
REJECTED
}
enum WorkspaceDocumentType {
Regulation
Playbook
Policy
Template
}
model Team {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members TeamMember[]
contacts Contact[]
calendarEvents CalendarEvent[]
deals Deal[]
conversations ChatConversation[]
chatMessages ChatMessage[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
telegramBusinessConnections TelegramBusinessConnection[]
feedCards FeedCard[]
contactPins ContactPin[]
documents WorkspaceDocument[]
}
model User {
id String @id @default(cuid())
phone String @unique
passwordHash String
email String? @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TeamMember[]
conversations ChatConversation[] @relation("ConversationCreator")
chatMessages ChatMessage[] @relation("ChatAuthor")
}
model TeamMember {
id String @id @default(cuid())
teamId String
userId String
role TeamRole @default(MEMBER)
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId])
@@index([userId])
}
model Contact {
id String @id @default(cuid())
teamId String
name String
company String?
country String?
location String?
avatarUrl String?
email String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
note ContactNote?
messages ContactMessage[]
events CalendarEvent[]
deals Deal[]
feedCards FeedCard[]
pins ContactPin[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
@@index([teamId, updatedAt])
}
model ContactNote {
id String @id @default(cuid())
contactId String @unique
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
}
model ContactMessage {
id String @id @default(cuid())
contactId String
kind ContactMessageKind @default(MESSAGE)
direction MessageDirection
channel MessageChannel
content String
audioUrl String?
durationSec Int?
transcriptJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([contactId, occurredAt])
}
model OmniContactIdentity {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([teamId, channel, externalId])
@@index([contactId])
@@index([teamId, updatedAt])
}
model OmniThread {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalChatId String
businessConnectionId String?
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
messages OmniMessage[]
@@unique([teamId, channel, externalChatId, businessConnectionId])
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model OmniMessage {
id String @id @default(cuid())
teamId String
contactId String
threadId String
direction MessageDirection
channel MessageChannel
status OmniMessageStatus @default(PENDING)
text String
providerMessageId String?
providerUpdateId String?
rawJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@unique([threadId, providerMessageId])
@@index([teamId, occurredAt])
@@index([threadId, occurredAt])
}
model TelegramBusinessConnection {
id String @id @default(cuid())
teamId String
businessConnectionId String
isEnabled Boolean?
canReply Boolean?
rawJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, businessConnectionId])
@@index([teamId, updatedAt])
}
model CalendarEvent {
id String @id @default(cuid())
teamId String
contactId String?
title String
startsAt DateTime
endsAt DateTime?
note String?
isArchived Boolean @default(false)
archiveNote String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([startsAt])
@@index([contactId, startsAt])
@@index([teamId, startsAt])
}
model Deal {
id String @id @default(cuid())
teamId String
contactId String
title String
stage String
amount Int?
nextStep String?
summary String?
currentStepId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
steps DealStep[]
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
@@index([currentStepId])
}
model DealStep {
id String @id @default(cuid())
dealId String
title String
description String?
status String @default("todo")
dueAt DateTime?
order Int @default(0)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
@@index([dealId, order])
@@index([status, dueAt])
}
model ChatConversation {
id String @id @default(cuid())
teamId String
createdByUserId String
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
messages ChatMessage[]
@@index([teamId, updatedAt])
@@index([createdByUserId])
}
model ChatMessage {
id String @id @default(cuid())
teamId String
conversationId String
authorUserId String?
role ChatRole
text String
planJson Json?
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@index([teamId, createdAt])
@@index([conversationId, createdAt])
}
model FeedCard {
id String @id @default(cuid())
teamId String
contactId String?
happenedAt DateTime
text String
proposalJson Json
decision FeedCardDecision @default(PENDING)
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([teamId, happenedAt])
@@index([contactId, happenedAt])
}
model ContactPin {
id String @id @default(cuid())
teamId String
contactId String
text String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model WorkspaceDocument {
id String @id @default(cuid())
teamId String
title String
type WorkspaceDocumentType
owner String
scope String
summary String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
}

View File

@@ -1,52 +0,0 @@
import { createServer } from "node:http";
import { closeReceiverWorker, RECEIVER_FLOW_QUEUE_NAME, receiverQueue, startReceiverWorker } from "./worker";
const port = Number(process.env.PORT || 8090);
const service = "omni_chat";
const server = createServer(async (req, res) => {
if (req.method === "GET" && req.url === "/health") {
const q = receiverQueue();
const counts = await q.getJobCounts("wait", "active", "failed", "completed", "delayed");
await q.close();
const payload = JSON.stringify({
ok: true,
service,
receiverFlow: RECEIVER_FLOW_QUEUE_NAME,
senderFlow: process.env.SENDER_FLOW_QUEUE_NAME || "sender.flow",
queue: counts,
now: new Date().toISOString(),
});
res.statusCode = 200;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(payload);
return;
}
res.statusCode = 404;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: false, error: "not_found" }));
});
startReceiverWorker();
server.listen(port, "0.0.0.0", () => {
console.log(`[omni_chat] listening on :${port}`);
console.log(`[omni_chat] receiver worker started for queue ${RECEIVER_FLOW_QUEUE_NAME}`);
});
async function shutdown(signal: string) {
console.log(`[omni_chat] shutting down by ${signal}`);
try {
await closeReceiverWorker();
} finally {
server.close(() => process.exit(0));
}
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});

View File

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

View File

@@ -1,421 +0,0 @@
import { Queue, Worker, type ConnectionOptions, type Job } from "bullmq";
import { Prisma } from "@prisma/client";
import { prisma } from "./utils/prisma";
type JsonObject = Record<string, unknown>;
type OmniInboundEnvelopeV1 = {
version: 1;
idempotencyKey: string;
provider: string;
channel: "TELEGRAM" | "WHATSAPP" | "INSTAGRAM" | "PHONE" | "EMAIL" | "INTERNAL";
direction: "IN" | "OUT";
providerEventId: string;
providerMessageId: string | null;
eventType: string;
occurredAt: string;
receivedAt: string;
payloadRaw: unknown;
payloadNormalized: {
threadExternalId: string | null;
contactExternalId: string | null;
text: string | null;
businessConnectionId: string | null;
updateId?: string | null;
[key: string]: unknown;
};
};
export const RECEIVER_FLOW_QUEUE_NAME = (process.env.RECEIVER_FLOW_QUEUE_NAME || "receiver.flow").trim();
const TELEGRAM_PLACEHOLDER_PREFIX = "Telegram ";
function redisConnectionFromEnv(): ConnectionOptions {
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
const parsed = new URL(raw);
return {
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : 6379,
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
maxRetriesPerRequest: null,
};
}
function normalizeText(input: unknown) {
const t = String(input ?? "").trim();
return t || "[no text]";
}
function parseOccurredAt(input: string | null | undefined) {
const d = new Date(String(input ?? ""));
if (Number.isNaN(d.getTime())) return new Date();
return d;
}
function asString(input: unknown) {
if (typeof input !== "string") return null;
const trimmed = input.trim();
return trimmed || null;
}
function safeDirection(input: unknown): "IN" | "OUT" {
return input === "OUT" ? "OUT" : "IN";
}
function isUniqueConstraintError(error: unknown) {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
}
type ContactProfile = {
displayName: string;
avatarUrl: string | null;
};
function buildContactProfile(
normalized: OmniInboundEnvelopeV1["payloadNormalized"],
externalContactId: string,
): ContactProfile {
const firstName =
asString(normalized.contactFirstName) ??
asString(normalized.fromFirstName) ??
asString(normalized.chatFirstName);
const lastName =
asString(normalized.contactLastName) ??
asString(normalized.fromLastName) ??
asString(normalized.chatLastName);
const username =
asString(normalized.contactUsername) ??
asString(normalized.fromUsername) ??
asString(normalized.chatUsername);
const title = asString(normalized.contactTitle) ?? asString(normalized.chatTitle);
const fullName = [firstName, lastName].filter(Boolean).join(" ");
const displayName =
fullName ||
(username ? `@${username.replace(/^@/, "")}` : null) ||
title ||
`${TELEGRAM_PLACEHOLDER_PREFIX}${externalContactId}`;
return {
displayName,
avatarUrl: asString(normalized.contactAvatarUrl),
};
}
async function maybeHydrateContact(contactId: string, profile: ContactProfile) {
const current = await prisma.contact.findUnique({
where: { id: contactId },
select: { name: true, avatarUrl: true },
});
if (!current) return;
const updates: Prisma.ContactUpdateInput = {};
const currentName = asString(current.name);
const nextName = asString(profile.displayName);
if (nextName && (!currentName || currentName.startsWith(TELEGRAM_PLACEHOLDER_PREFIX)) && currentName !== nextName) {
updates.name = nextName;
}
const currentAvatar = asString(current.avatarUrl);
if (profile.avatarUrl && !currentAvatar) {
updates.avatarUrl = profile.avatarUrl;
}
if (Object.keys(updates).length === 0) return;
await prisma.contact.update({
where: { id: contactId },
data: updates,
});
}
async function resolveTeamId(env: OmniInboundEnvelopeV1) {
const n = env.payloadNormalized ?? ({} as OmniInboundEnvelopeV1["payloadNormalized"]);
const bcId = String(n.businessConnectionId ?? "").trim();
if (bcId) {
const linked = await prisma.telegramBusinessConnection.findFirst({
where: { businessConnectionId: bcId },
orderBy: { updatedAt: "desc" },
select: { teamId: true },
});
if (linked?.teamId) return linked.teamId;
}
const externalContactId = String(n.contactExternalId ?? n.threadExternalId ?? "").trim();
if (externalContactId) {
const pseudo = `link:${externalContactId}`;
const linked = await prisma.telegramBusinessConnection.findFirst({
where: { businessConnectionId: pseudo },
orderBy: { updatedAt: "desc" },
select: { teamId: true },
});
if (linked?.teamId) return linked.teamId;
}
const fallbackTeamId = String(process.env.DEFAULT_TEAM_ID || "").trim();
if (fallbackTeamId) return fallbackTeamId;
const demo = await prisma.team.findFirst({
where: { id: "demo-team" },
select: { id: true },
});
return demo?.id ?? null;
}
async function resolveContact(input: {
teamId: string;
externalContactId: string;
profile: ContactProfile;
}) {
const existingIdentity = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (existingIdentity?.contactId) {
await maybeHydrateContact(existingIdentity.contactId, input.profile);
return existingIdentity.contactId;
}
const contact = await prisma.contact.create({
data: {
teamId: input.teamId,
name: input.profile.displayName,
avatarUrl: input.profile.avatarUrl,
company: null,
country: null,
location: null,
},
select: { id: true },
});
try {
await prisma.omniContactIdentity.create({
data: {
teamId: input.teamId,
contactId: contact.id,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
});
} catch (error) {
if (!isUniqueConstraintError(error)) throw error;
const concurrentIdentity = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (!concurrentIdentity?.contactId) throw error;
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
await maybeHydrateContact(concurrentIdentity.contactId, input.profile);
return concurrentIdentity.contactId;
}
return contact.id;
}
async function upsertThread(input: {
teamId: string;
contactId: string;
externalChatId: string;
businessConnectionId: string | null;
title: string | null;
}) {
const existing = await prisma.omniThread.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
},
select: { id: true, title: true },
});
if (existing) {
const data: Prisma.OmniThreadUpdateInput = {
contactId: input.contactId,
};
if (input.title && !existing.title) {
data.title = input.title;
}
await prisma.omniThread.update({
where: { id: existing.id },
data,
});
return existing;
}
try {
return await prisma.omniThread.create({
data: {
teamId: input.teamId,
contactId: input.contactId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
title: input.title,
},
select: { id: true },
});
} catch (error) {
if (!isUniqueConstraintError(error)) throw error;
const concurrentThread = await prisma.omniThread.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
},
select: { id: true },
});
if (!concurrentThread) throw error;
await prisma.omniThread.update({
where: { id: concurrentThread.id },
data: { contactId: input.contactId },
});
return concurrentThread;
}
}
async function ingestInbound(env: OmniInboundEnvelopeV1) {
if (env.channel !== "TELEGRAM") return;
const teamId = await resolveTeamId(env);
if (!teamId) {
console.warn("[omni_chat] skip inbound: team not resolved", env.providerEventId);
return;
}
const n = env.payloadNormalized ?? ({} as OmniInboundEnvelopeV1["payloadNormalized"]);
const externalContactId = String(n.contactExternalId ?? n.threadExternalId ?? "").trim();
const externalChatId = String(n.threadExternalId ?? n.contactExternalId ?? "").trim();
if (!externalContactId || !externalChatId) {
console.warn("[omni_chat] skip inbound: missing contact/chat ids", env.providerEventId);
return;
}
const businessConnectionId = String(n.businessConnectionId ?? "").trim() || null;
const text = normalizeText(n.text);
const occurredAt = parseOccurredAt(env.occurredAt);
const direction = safeDirection(env.direction);
const contactProfile = buildContactProfile(n, externalContactId);
const contactId = await resolveContact({
teamId,
externalContactId,
profile: contactProfile,
});
const thread = await upsertThread({
teamId,
contactId,
externalChatId,
businessConnectionId,
title: asString(n.chatTitle),
});
if (env.providerMessageId) {
await prisma.omniMessage.upsert({
where: {
threadId_providerMessageId: {
threadId: thread.id,
providerMessageId: env.providerMessageId,
},
},
create: {
teamId,
contactId,
threadId: thread.id,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: env.providerMessageId,
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
occurredAt,
},
update: {
text,
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
occurredAt,
},
});
} else {
await prisma.omniMessage.create({
data: {
teamId,
contactId,
threadId: thread.id,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: null,
providerUpdateId: String((n.updateId as string | null | undefined) ?? env.providerEventId),
rawJson: (env.payloadRaw ?? null) as Prisma.InputJsonValue,
occurredAt,
},
});
}
await prisma.contactMessage.create({
data: {
contactId,
kind: "MESSAGE",
direction,
channel: "TELEGRAM",
content: text,
occurredAt,
},
});
}
let workerInstance: Worker<OmniInboundEnvelopeV1, unknown, "ingest"> | null = null;
export function startReceiverWorker() {
if (workerInstance) return workerInstance;
const worker = new Worker<OmniInboundEnvelopeV1, unknown, "ingest">(
RECEIVER_FLOW_QUEUE_NAME,
async (job) => {
await ingestInbound(job.data);
},
{
connection: redisConnectionFromEnv(),
concurrency: Number(process.env.OMNI_CHAT_WORKER_CONCURRENCY || 4),
},
);
worker.on("failed", (job: Job<OmniInboundEnvelopeV1, unknown, "ingest"> | undefined, err: Error) => {
console.error(`[omni_chat] receiver job failed id=${job?.id || "unknown"}: ${err?.message || err}`);
});
workerInstance = worker;
return worker;
}
export async function closeReceiverWorker() {
if (!workerInstance) return;
await workerInstance.close();
workerInstance = null;
}
export function receiverQueue() {
return new Queue<OmniInboundEnvelopeV1, unknown, "ingest">(RECEIVER_FLOW_QUEUE_NAME, {
connection: redisConnectionFromEnv(),
});
}

View File

@@ -1,10 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "start"]

View File

@@ -1,48 +0,0 @@
# omni_inbound
Отдельный сервис приема входящих webhook-событий каналов (первый канал: Telegram Business).
## Задача сервиса
- принимать webhook;
- валидировать секрет;
- нормализовать событие в универсальный envelope;
- делать durable enqueue в BullMQ (`receiver.flow`);
- возвращать `200` только после успешного enqueue.
Сервис **не** содержит бизнес-логику CRM и не вызывает provider API для исходящих сообщений.
## API
### `GET /health`
Проверка живости сервиса.
### `POST /webhooks/telegram/business`
Прием Telegram Business webhook.
При активном `TELEGRAM_WEBHOOK_SECRET` ожидается заголовок:
- `x-telegram-bot-api-secret-token: <TELEGRAM_WEBHOOK_SECRET>`
## Переменные окружения
- `PORT` (default: `8080`)
- `REDIS_URL` (default: `redis://localhost:6379`)
- `RECEIVER_FLOW_QUEUE_NAME` (default: `receiver.flow`)
- `INBOUND_QUEUE_NAME` (legacy alias, optional)
- `TELEGRAM_WEBHOOK_SECRET` (optional, но обязателен для production)
- `TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL` (optional; URL CRM endpoint для линковки Telegram Business)
- `MAX_BODY_SIZE_BYTES` (default: `1048576`)
## Запуск
```bash
npm ci
npm run start
```
## Надежность
- Идемпотентность: `jobId` строится из `idempotencyKey` (SHA-256).
- Дубликаты webhook безопасны и не приводят к повторной постановке события.
- При ошибке enqueue сервис возвращает `503`, чтобы провайдер повторил доставку.

View File

@@ -1,908 +0,0 @@
{
"name": "crm-omni-inbound",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crm-omni-inbound",
"dependencies": {
"bullmq": "^5.58.2"
},
"devDependencies": {
"@types/node": "^22.13.9",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@ioredis/commands": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
"license": "MIT"
},
"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/@types/node": {
"version": "22.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/bullmq": {
"version": "5.69.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.69.4.tgz",
"integrity": "sha512-Lp7ymp875I/rtjMm6oxzQ3PrvDDHkgge0oaAznmZsKtGyglfdrg9zbidPSszTXgWFkS2rCgMcTRNJfM3uUMOjQ==",
"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/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"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/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"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/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"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/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"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/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-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/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"license": "MIT",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"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"
}
}
}
}

View File

@@ -1,67 +0,0 @@
import { createHash } from "node:crypto";
import { Queue, type ConnectionOptions } from "bullmq";
import type { OmniInboundEnvelopeV1 } from "./types";
export const RECEIVER_FLOW_QUEUE_NAME = (
process.env.RECEIVER_FLOW_QUEUE_NAME ||
process.env.INBOUND_QUEUE_NAME ||
"receiver.flow"
).trim();
let queueInstance: Queue<OmniInboundEnvelopeV1, unknown, "ingest"> | null = null;
function redisConnectionFromEnv(): ConnectionOptions {
const raw = (process.env.REDIS_URL || "redis://localhost:6379").trim();
const parsed = new URL(raw);
return {
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : 6379,
username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
db: parsed.pathname && parsed.pathname !== "/" ? Number(parsed.pathname.slice(1)) : undefined,
maxRetriesPerRequest: null,
};
}
function toJobId(idempotencyKey: string) {
const hash = createHash("sha256").update(idempotencyKey).digest("hex");
return `inbound-${hash}`;
}
export function inboundQueue() {
if (queueInstance) return queueInstance;
queueInstance = new Queue<OmniInboundEnvelopeV1, unknown, "ingest">(RECEIVER_FLOW_QUEUE_NAME, {
connection: redisConnectionFromEnv(),
defaultJobOptions: {
removeOnComplete: { count: 10000 },
removeOnFail: { count: 20000 },
attempts: 8,
backoff: { type: "exponential", delay: 1000 },
},
});
return queueInstance;
}
export async function enqueueInboundEvent(envelope: OmniInboundEnvelopeV1) {
const q = inboundQueue();
const jobId = toJobId(envelope.idempotencyKey);
return q.add("ingest", envelope, {
jobId,
});
}
export function isDuplicateJobError(error: unknown) {
if (!error || typeof error !== "object") return false;
const message = String((error as { message?: string }).message || "").toLowerCase();
return message.includes("job") && message.includes("exists");
}
export async function closeInboundQueue() {
if (!queueInstance) return;
await queueInstance.close();
queueInstance = null;
}

View File

@@ -1,144 +0,0 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { RECEIVER_FLOW_QUEUE_NAME, enqueueInboundEvent, isDuplicateJobError } from "./queue";
import { parseTelegramBusinessUpdate } from "./telegram";
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 1024 * 1024);
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
const payload = JSON.stringify(body);
res.statusCode = statusCode;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(payload);
}
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buf.length;
if (total > MAX_BODY_SIZE_BYTES) {
throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`);
}
chunks.push(buf);
}
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw) return {};
return JSON.parse(raw);
}
function validateTelegramSecret(req: IncomingMessage): boolean {
const expected = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
if (!expected) return true;
const incoming = String(req.headers["x-telegram-bot-api-secret-token"] || "").trim();
return incoming !== "" && incoming === expected;
}
async function forwardTelegramConnectWebhook(rawBody: unknown) {
const url = (process.env.TELEGRAM_CONNECT_WEBHOOK_FORWARD_URL || "").trim();
if (!url) return;
const headers: Record<string, string> = {
"content-type": "application/json",
};
const secret = (process.env.TELEGRAM_WEBHOOK_SECRET || "").trim();
if (secret) {
headers["x-telegram-bot-api-secret-token"] = secret;
}
try {
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(rawBody ?? {}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.warn(`[omni_inbound] telegram connect forward failed: ${res.status} ${text.slice(0, 300)}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[omni_inbound] telegram connect forward error: ${message}`);
}
}
export function startServer() {
const port = Number(process.env.PORT || 8080);
const server = createServer(async (req, res) => {
if (!req.url || !req.method) {
writeJson(res, 404, { ok: false, error: "not_found" });
return;
}
if (req.url === "/health" && req.method === "GET") {
writeJson(res, 200, {
ok: true,
service: "omni_inbound",
queue: RECEIVER_FLOW_QUEUE_NAME,
now: new Date().toISOString(),
});
return;
}
if (req.url === "/webhooks/telegram/business" && req.method === "POST") {
if (!validateTelegramSecret(req)) {
writeJson(res, 401, { ok: false, error: "invalid_webhook_secret" });
return;
}
let body: unknown = {};
let envelope: ReturnType<typeof parseTelegramBusinessUpdate> | null = null;
try {
body = await readJsonBody(req);
envelope = parseTelegramBusinessUpdate(body);
await enqueueInboundEvent(envelope);
void forwardTelegramConnectWebhook(body);
writeJson(res, 200, {
ok: true,
queued: true,
duplicate: false,
providerEventId: envelope.providerEventId,
idempotencyKey: envelope.idempotencyKey,
});
} catch (error) {
if (isDuplicateJobError(error)) {
void forwardTelegramConnectWebhook(body);
writeJson(res, 200, {
ok: true,
queued: false,
duplicate: true,
});
return;
}
const message = error instanceof Error ? error.message : String(error);
const statusCode = message.startsWith("payload_too_large:") ? 413 : 503;
writeJson(res, statusCode, {
ok: false,
error: "receiver_enqueue_failed",
message,
});
}
return;
}
writeJson(res, 404, { ok: false, error: "not_found" });
});
server.listen(port, "0.0.0.0", () => {
console.log(`[omni_inbound] listening on :${port}`);
});
return server;
}

View File

@@ -1,20 +0,0 @@
FROM node:22-bookworm-slim
WORKDIR /app/delivery
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --legacy-peer-deps
COPY prisma ./prisma
RUN npx prisma generate
COPY src ./src
COPY tsconfig.json ./tsconfig.json
ENV NODE_ENV=production
CMD ["npm", "run", "start"]

View File

@@ -1,22 +0,0 @@
# omni_outbound
Изолированный сервис исходящей доставки.
## Назначение
- потребляет задачи из `sender.flow`;
- выполняет отправку в провайдеров;
- применяет retry/backoff и финальный fail-status.
## Переменные окружения
- `REDIS_URL`
- `DATABASE_URL`
- `SENDER_FLOW_QUEUE_NAME` (default: `sender.flow`)
- `OUTBOUND_DELIVERY_QUEUE_NAME` (legacy alias, optional)
## Prisma policy
- Источник схемы: `frontend/prisma/schema.prisma`.
- Локальная копия в `omni_outbound/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
- Миграции/`db push` выполняются только в `frontend`.

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More